Compare commits

...

3 Commits

Author SHA1 Message Date
4699f13a72 Spec 196: restore native Filament table contracts (#236)
## Summary
- replace the inventory dependency GET/apply flow with an embedded native Filament `TableComponent`
- convert tenant required permissions and evidence overview to native page-owned Filament tables with mount-only query seeding and preserved scope authority
- extend focused Pest, Livewire, RBAC, and guard coverage, and update the Spec 196 artifacts and release close-out notes

## Verification
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/InventoryItemDependenciesTest.php tests/Feature/Filament/InventoryItemDependencyEdgesTableTest.php tests/Feature/Rbac/TenantRequiredPermissionsTrustedStateTest.php tests/Feature/Filament/TenantRequiredPermissionsPageTest.php tests/Feature/Evidence/EvidenceOverviewPageTest.php tests/Feature/Filament/EvidenceOverviewDerivedStateMemoizationTest.php tests/Feature/Guards/FilamentTableStandardsGuardTest.php tests/Unit/TenantRequiredPermissionsFilteringTest.php tests/Unit/TenantRequiredPermissionsOverallStatusTest.php tests/Unit/TenantRequiredPermissionsFeatureImpactTest.php tests/Unit/TenantRequiredPermissionsFreshnessTest.php tests/Unit/TenantRequiredPermissionsCopyPayloadTest.php` (`45` tests, `177` assertions)
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- integrated-browser smoke on localhost for inventory detail dependencies, tenant required permissions, and evidence overview

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #236
2026-04-14 23:30:53 +00:00
bb72a54e84 Refactor: remove compare job legacy drift path (#235)
## Summary
- remove the dead legacy drift-computation path from `CompareBaselineToTenantJob` so the strategy-driven compare engine is the only execution path left in the orchestration file
- tighten compare guard and regression coverage around strategy selection, strategy execution context, findings, gaps, and no-drift outcomes
- fix the repo-wide suite blockers uncovered during validation by making the governance taxonomy registry test-double compatible and aligning the capture capability guard test with current unsupported-scope behavior
- add the Spec 205 planning artifacts and mark the implementation tasks complete

## Verification
- `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests --stop-on-failure`
  - result: `3659 passed, 8 skipped (21016 assertions)`
- browser smoke test passed on the Baseline Compare landing surface via the local smoke-login flow

## Notes
- no Filament resource, panel, global search, destructive action, or asset registration behavior was changed
- provider registration remains unchanged in `apps/platform/bootstrap/providers.php`
- the compare path remains strategy-driven and Livewire v4 / Filament v5 assumptions are unchanged

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #235
2026-04-14 21:54:37 +00:00
ad16eee591 Spec 204: harden platform core vocabulary (#234)
## Summary
- add the Spec 204 platform vocabulary foundation, including canonical glossary terms, registry ownership descriptors, canonical operation type and alias resolution, and explicit reason ownership and platform reason-family metadata
- harden platform-facing compare, snapshot, evidence, monitoring, review, and reporting surfaces so they prefer governed-subject and canonical operation semantics while preserving intentional Intune-owned terminology
- extend Spec 204 unit, feature, Filament, and architecture coverage and add the full spec artifacts, checklist, and completed task ledger

## Verification
- ran the focused recent-change Sail verification pack for the new glossary and reason-semantics work
- ran the full Spec 204 quickstart verification pack under Sail
- ran `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent`
- ran an integrated-browser smoke pass covering tenant dashboard, operations, operation detail, baseline compare, evidence, reviews, review packs, provider connections, inventory items, backup schedules, onboarding, and the system dashboard/operations/failures/run-detail surfaces

## Notes
- provider registration is unchanged and remains in `bootstrap/providers.php`
- no new destructive actions or asset-registration changes are introduced by this branch

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #234
2026-04-14 06:09:42 +00:00
121 changed files with 7898 additions and 1645 deletions

View File

@ -182,6 +182,10 @@ ## Active Technologies
- PostgreSQL via existing `baseline_profiles.scope_jsonb`, `baseline_tenant_assignments.override_scope_jsonb`, and `operation_runs.context`; no new tables planned (202-governance-subject-taxonomy) - PostgreSQL via existing `baseline_profiles.scope_jsonb`, `baseline_tenant_assignments.override_scope_jsonb`, and `operation_runs.context`; no new tables planned (202-governance-subject-taxonomy)
- 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 (203-baseline-compare-strategy) - 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 (203-baseline-compare-strategy)
- 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) - 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) - PHP 8.4.15 (feat/005-bulk-operations)
@ -216,8 +220,8 @@ ## Code Style
PHP 8.4.15: Follow standard conventions PHP 8.4.15: Follow standard conventions
## Recent Changes ## 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 - 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`
- 196-hard-filament-nativity-cleanup: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `DependencyQueryService`, `DependencyTargetResolver`, `TenantRequiredPermissionsViewModelBuilder`, `ArtifactTruthPresenter`, `WorkspaceContext`, Filament `InteractsWithTable`, Filament `TableComponent`, and existing badge and action-surface helpers
<!-- MANUAL ADDITIONS START --> <!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END --> <!-- MANUAL ADDITIONS END -->

View File

@ -21,6 +21,7 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
@ -203,6 +204,10 @@ public function refreshStats(): void
protected function getViewData(): array protected function getViewData(): array
{ {
$evidenceGapSummary = is_array($this->evidenceGapSummary) ? $this->evidenceGapSummary : []; $evidenceGapSummary = is_array($this->evidenceGapSummary) ? $this->evidenceGapSummary : [];
$reasonPresenter = app(ReasonPresenter::class);
$reasonSemantics = $reasonPresenter->semantics(
$reasonPresenter->forArtifactTruth($this->reasonCode, 'baseline_compare_landing'),
);
$hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true); $hasCoverageWarnings = in_array($this->coverageStatus, ['warning', 'unproven'], true);
$evidenceGapsCountValue = is_numeric($evidenceGapSummary['count'] ?? null) $evidenceGapsCountValue = is_numeric($evidenceGapSummary['count'] ?? null)
? (int) $evidenceGapSummary['count'] ? (int) $evidenceGapSummary['count']
@ -276,6 +281,7 @@ protected function getViewData(): array
'whyNoFindingsFallback' => $whyNoFindingsFallback, 'whyNoFindingsFallback' => $whyNoFindingsFallback,
'whyNoFindingsColor' => $whyNoFindingsColor, 'whyNoFindingsColor' => $whyNoFindingsColor,
'summaryAssessment' => is_array($this->summaryAssessment) ? $this->summaryAssessment : null, 'summaryAssessment' => is_array($this->summaryAssessment) ? $this->summaryAssessment : null,
'reasonSemantics' => $reasonSemantics,
]; ];
} }

View File

@ -130,15 +130,15 @@ public function form(Schema $schema): Schema
]) ])
->schema([ ->schema([
Select::make('draftSelectedPolicyTypes') Select::make('draftSelectedPolicyTypes')
->label('Policy types') ->label('Governed subjects')
->options(fn (): array => $this->matrixOptions('policyTypeOptions')) ->options(fn (): array => $this->matrixOptions('policyTypeOptions'))
->multiple() ->multiple()
->searchable() ->searchable()
->preload() ->preload()
->native(false) ->native(false)
->placeholder('All policy types') ->placeholder('All governed subjects')
->helperText(fn (): ?string => $this->matrixOptions('policyTypeOptions') === [] ->helperText(fn (): ?string => $this->matrixOptions('policyTypeOptions') === []
? 'Policy type filters appear after a usable reference snapshot is available.' ? 'Governed subject filters appear after a usable reference snapshot is available.'
: null) : null)
->extraFieldWrapperAttributes([ ->extraFieldWrapperAttributes([
'data-testid' => 'matrix-policy-type-filter', 'data-testid' => 'matrix-policy-type-filter',
@ -426,7 +426,7 @@ public function activeFilterSummary(): array
$summary = []; $summary = [];
if ($this->selectedPolicyTypes !== []) { if ($this->selectedPolicyTypes !== []) {
$summary['Policy types'] = count($this->selectedPolicyTypes); $summary['Governed subjects'] = count($this->selectedPolicyTypes);
} }
if ($this->selectedStates !== []) { if ($this->selectedStates !== []) {
@ -452,7 +452,7 @@ public function stagedFilterSummary(): array
$summary = []; $summary = [];
if ($this->draftSelectedPolicyTypes !== $this->selectedPolicyTypes) { if ($this->draftSelectedPolicyTypes !== $this->selectedPolicyTypes) {
$summary['Policy types'] = count($this->draftSelectedPolicyTypes); $summary['Governed subjects'] = count($this->draftSelectedPolicyTypes);
} }
if ($this->draftSelectedStates !== $this->selectedStates) { if ($this->draftSelectedStates !== $this->selectedStates) {

View File

@ -20,14 +20,25 @@
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope; use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthEnvelope;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter; use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use App\Support\Filament\TablePaginationProfiles;
use BackedEnum; use BackedEnum;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Pages\Page; 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\Auth\AuthenticationException;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use UnitEnum; use UnitEnum;
class EvidenceOverview extends Page class EvidenceOverview extends Page implements HasTable
{ {
use InteractsWithTable;
protected static bool $isDiscovered = false; protected static bool $isDiscovered = false;
protected static bool $shouldRegisterNavigation = false; protected static bool $shouldRegisterNavigation = false;
@ -45,7 +56,12 @@ class EvidenceOverview extends Page
*/ */
public array $rows = []; 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 public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
{ {
@ -58,6 +74,134 @@ public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
} }
public function mount(): void 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(); $user = auth()->user();
@ -65,35 +209,127 @@ public function mount(): void
throw new AuthenticationException; throw new AuthenticationException;
} }
$workspaceContext = app(WorkspaceContext::class); app(WorkspaceContext::class)->currentWorkspaceForMemberOrFail($user, request());
$workspace = $workspaceContext->currentWorkspaceForMemberOrFail($user, request()); }
$workspaceId = (int) $workspace->getKey();
$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) ->where('tenants.workspace_id', $workspaceId)
->orderBy('tenants.name') ->orderBy('tenants.name')
->get() ->get()
->filter(fn (Tenant $tenant): bool => (int) $tenant->workspace_id === $workspaceId && $user->can('evidence.view', $tenant)) ->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() $query = EvidenceSnapshot::query()
->with('tenant') ->with('tenant')
->where('workspace_id', $workspaceId) ->where('workspace_id', $this->workspaceId())
->whereIn('tenant_id', $tenantIds)
->where('status', 'active') ->where('status', 'active')
->latest('generated_at'); ->latest('generated_at');
if ($this->tenantFilter !== null) { if ($tenantIds === []) {
$query->where('tenant_id', $this->tenantFilter); $query->whereRaw('1 = 0');
} else {
$query->whereIn('tenant_id', $tenantIds);
} }
$snapshots = $query->get()->unique('tenant_id')->values(); return $this->cachedSnapshots = $query->get()->unique('tenant_id')->values();
$currentReviewTenantIds = TenantReview::query() }
->where('workspace_id', $workspaceId)
/**
* @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('tenant_id', $snapshots->pluck('tenant_id')->map(static fn (mixed $tenantId): int => (int) $tenantId)->all())
->whereIn('status', [ ->whereIn('status', [
TenantReviewStatus::Draft->value, TenantReviewStatus::Draft->value,
@ -103,8 +339,14 @@ public function mount(): void
->pluck('tenant_id') ->pluck('tenant_id')
->mapWithKeys(static fn (mixed $tenantId): array => [(int) $tenantId => true]) ->mapWithKeys(static fn (mixed $tenantId): array => [(int) $tenantId => true])
->all(); ->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); $truth = $this->snapshotTruth($snapshot);
$freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState); $freshnessSpec = BadgeCatalog::spec(BadgeDomain::GovernanceArtifactFreshness, $truth->freshnessState);
$tenantId = (int) $snapshot->tenant_id; $tenantId = (int) $snapshot->tenant_id;
@ -117,16 +359,22 @@ public function mount(): void
'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant', 'tenant_name' => $snapshot->tenant?->name ?? 'Unknown tenant',
'tenant_id' => $tenantId, 'tenant_id' => $tenantId,
'snapshot_id' => (int) $snapshot->getKey(), 'snapshot_id' => (int) $snapshot->getKey(),
'completeness_state' => (string) $snapshot->completeness_state,
'generated_at' => $snapshot->generated_at?->toDateTimeString(), 'generated_at' => $snapshot->generated_at?->toDateTimeString(),
'missing_dimensions' => (int) (($snapshot->summary['missing_dimensions'] ?? 0)), 'missing_dimensions' => (int) ($snapshot->summary['missing_dimensions'] ?? 0),
'stale_dimensions' => (int) (($snapshot->summary['stale_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' => [ 'artifact_truth' => [
'label' => $truth->primaryLabel, 'label' => $truth->primaryLabel,
'color' => $truth->primaryBadgeSpec()->color, 'color' => $truth->primaryBadgeSpec()->color,
'icon' => $truth->primaryBadgeSpec()->icon, 'icon' => $truth->primaryBadgeSpec()->icon,
'explanation' => $truth->primaryExplanation, 'explanation' => $truth->primaryExplanation,
], ],
'freshness_label' => $freshnessSpec->label,
'freshness_color' => $freshnessSpec->color,
'freshness_icon' => $freshnessSpec->icon,
'freshness' => [ 'freshness' => [
'label' => $freshnessSpec->label, 'label' => $freshnessSpec->label,
'color' => $freshnessSpec->color, 'color' => $freshnessSpec->color,
@ -137,29 +385,105 @@ public function mount(): void
? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant) ? EvidenceSnapshotResource::getUrl('view', ['record' => $snapshot], tenant: $snapshot->tenant)
: null, : 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 [ $sortColumn = in_array($sortColumn, ['tenant_name', 'artifact_truth_label', 'freshness_label', 'generated_at', 'missing_dimensions', 'stale_dimensions'], true)
Action::make('clear_filters') ? $sortColumn
->label('Clear filters') : 'tenant_name';
->color('gray') $descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc';
->visible(fn (): bool => $this->tenantFilter !== null)
->url(route('admin.evidence.overview')), $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'] ?? ''));
}
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 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 snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope private function normalizeTenantFilter(mixed $value): ?int
{ {
$presenter = app(ArtifactTruthPresenter::class); if (! is_numeric($value)) {
return null;
}
return $fresh $requestedTenantId = (int) $value;
? $presenter->forEvidenceSnapshotFresh($snapshot) $allowedTenantIds = collect($this->accessibleTenants())
: $presenter->forEvidenceSnapshot($snapshot); ->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();
} }
} }

View File

@ -10,16 +10,30 @@
use App\Models\User; use App\Models\User;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Services\Intune\TenantRequiredPermissionsViewModelBuilder; 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\ActionSurfaceDeclaration;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot; use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Actions\Action;
use Filament\Pages\Page; 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 Livewire\Attributes\Locked;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; 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 $isDiscovered = false;
protected static bool $shouldRegisterNavigation = 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.'); ->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] #[Locked]
public ?int $scopedTenantId = null; public ?int $scopedTenantId = null;
/**
* @var array<string, mixed>|null
*/
private ?array $cachedViewModel = null;
private ?string $cachedViewModelStateKey = null;
public static function canAccess(): bool public static function canAccess(): bool
{ {
return static::hasScopedTenantAccess(static::resolveScopedTenant()); return static::hasScopedTenantAccess(static::resolveScopedTenant());
@ -69,9 +74,9 @@ public function currentTenant(): ?Tenant
return $this->trustedScopedTenant(); 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)) { if (! $tenant instanceof Tenant || ! static::hasScopedTenantAccess($tenant)) {
abort(404); abort(404);
@ -81,109 +86,120 @@ public function mount(): void
$this->heading = $tenant->getFilamentName(); $this->heading = $tenant->getFilamentName();
$this->subheading = 'Required permissions'; $this->subheading = 'Required permissions';
$queryFeatures = request()->query('features', $this->features); $this->seedTableStateFromQuery();
$this->mountInteractsWithTable();
}
$state = TenantRequiredPermissionsViewModelBuilder::normalizeFilterState([ public function table(Table $table): Table
'status' => request()->query('status', $this->status), {
'type' => request()->query('type', $this->type), return $table
'features' => is_array($queryFeatures) ? $queryFeatures : [], ->defaultSort('sort_priority')
'search' => request()->query('search', $this->search), ->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 session()->put($this->getTableFiltersSessionKey(), $this->tableFilters);
{ session()->put($this->getTableSearchSessionKey(), $this->tableSearch);
$this->refreshViewModel();
}
public function updatedSearch(): void $this->resetPage();
{
$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);
}
} }
public function reRunVerificationUrl(): string public function reRunVerificationUrl(): string
@ -208,8 +224,18 @@ public function manageProviderConnectionUrl(): ?string
return ProviderConnectionResource::getUrl('index', ['tenant' => $tenant], panel: 'admin'); 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'); $routeTenant = request()->route('tenant');
if ($routeTenant instanceof Tenant) { if ($routeTenant instanceof Tenant) {
@ -222,6 +248,14 @@ protected static function resolveScopedTenant(): ?Tenant
->first(); ->first();
} }
$queryTenant = request()->query('tenant');
if (is_string($queryTenant) && $queryTenant !== '') {
return Tenant::query()
->where('external_id', $queryTenant)
->first();
}
return null; return null;
} }
@ -293,4 +327,216 @@ private function trustedScopedTenant(): ?Tenant
return null; 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,
};
}
} }

View File

@ -10,14 +10,11 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\Auth\CapabilityResolver; use App\Services\Auth\CapabilityResolver;
use App\Services\Inventory\DependencyQueryService;
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\TagBadgeDomain; use App\Support\Badges\TagBadgeDomain;
use App\Support\Badges\TagBadgeRenderer; use App\Support\Badges\TagBadgeRenderer;
use App\Support\Enums\RelationshipType;
use App\Support\Filament\FilterOptionCatalog; use App\Support\Filament\FilterOptionCatalog;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
@ -179,29 +176,6 @@ public static function infolist(Schema $schema): Schema
ViewEntry::make('dependencies') ViewEntry::make('dependencies')
->label('') ->label('')
->view('filament.components.dependency-edges') ->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(),
]) ])
->columnSpanFull(), ->columnSpanFull(),

View File

@ -31,6 +31,7 @@
use App\Support\OpsUx\RunDurationInsights; use App\Support\OpsUx\RunDurationInsights;
use App\Support\OpsUx\SummaryCountsNormalizer; use App\Support\OpsUx\SummaryCountsNormalizer;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\RestoreSafety\RestoreSafetyResolver; use App\Support\RestoreSafety\RestoreSafetyResolver;
use App\Support\Tenants\ReferencedTenantLifecyclePresentation; use App\Support\Tenants\ReferencedTenantLifecyclePresentation;
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration; use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
@ -219,6 +220,15 @@ public static function table(Table $table): Table
->all(); ->all();
return FilterOptionCatalog::operationTypes(array_keys($types)); return FilterOptionCatalog::operationTypes(array_keys($types));
})
->query(function (Builder $query, array $data): Builder {
$value = data_get($data, 'value');
if (! is_string($value) || trim($value) === '') {
return $query;
}
return $query->whereIn('type', OperationCatalog::rawValuesForCanonical($value));
}), }),
Tables\Filters\SelectFilter::make('status') Tables\Filters\SelectFilter::make('status')
->options(BadgeCatalog::options(BadgeDomain::OperationRunStatus, OperationRunStatus::values())), ->options(BadgeCatalog::options(BadgeDomain::OperationRunStatus, OperationRunStatus::values())),
@ -268,6 +278,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
: null; : null;
$artifactTruth = static::artifactTruthEnvelope($record); $artifactTruth = static::artifactTruthEnvelope($record);
$operatorExplanation = $artifactTruth?->operatorExplanation; $operatorExplanation = $artifactTruth?->operatorExplanation;
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($record, 'run_detail');
$primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation); $primaryNextStep = static::resolvePrimaryNextStep($record, $artifactTruth, $operatorExplanation);
$restoreContinuation = static::restoreContinuation($record); $restoreContinuation = static::restoreContinuation($record);
$supportingGroups = static::supportingGroups( $supportingGroups = static::supportingGroups(
@ -275,6 +286,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
factory: $factory, factory: $factory,
referencedTenantLifecycle: $referencedTenantLifecycle, referencedTenantLifecycle: $referencedTenantLifecycle,
operatorExplanation: $operatorExplanation, operatorExplanation: $operatorExplanation,
reasonEnvelope: $reasonEnvelope,
primaryNextStep: $primaryNextStep, primaryNextStep: $primaryNextStep,
); );
@ -439,7 +451,7 @@ private static function enterpriseDetailPage(OperationRun $record): \App\Support
id: 'baseline_compare_gap_details', id: 'baseline_compare_gap_details',
kind: 'type_specific_detail', kind: 'type_specific_detail',
title: 'Evidence gap details', title: 'Evidence gap details',
description: 'Policies affected by evidence gaps, grouped by reason and searchable by reason, policy type, or subject key.', description: 'Governed subjects affected by evidence gaps, grouped by reason and searchable by reason, governed subject, or subject key.',
view: 'filament.infolists.entries.evidence-gap-subjects', view: 'filament.infolists.entries.evidence-gap-subjects',
viewData: [ viewData: [
'summary' => $gapSummary, 'summary' => $gapSummary,
@ -537,10 +549,12 @@ private static function supportingGroups(
\App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory, \App\Support\Ui\EnterpriseDetail\EnterpriseDetailSectionFactory $factory,
?ReferencedTenantLifecyclePresentation $referencedTenantLifecycle, ?ReferencedTenantLifecyclePresentation $referencedTenantLifecycle,
?OperatorExplanationPattern $operatorExplanation, ?OperatorExplanationPattern $operatorExplanation,
?ReasonResolutionEnvelope $reasonEnvelope,
array $primaryNextStep, array $primaryNextStep,
): array { ): array {
$groups = []; $groups = [];
$hasElevatedLifecycleState = static::lifecycleAttentionSummary($record) !== null; $hasElevatedLifecycleState = static::lifecycleAttentionSummary($record) !== null;
$reasonSemantics = app(ReasonPresenter::class)->semantics($reasonEnvelope);
$guidanceItems = array_values(array_filter([ $guidanceItems = array_values(array_filter([
$operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null $operatorExplanation instanceof OperatorExplanationPattern && $operatorExplanation->coverageStatement !== null
@ -579,6 +593,24 @@ private static function supportingGroups(
); );
} }
$reasonSemanticsItems = array_values(array_filter([
is_string($reasonSemantics['owner_label'] ?? null)
? $factory->keyFact('Reason owner', (string) $reasonSemantics['owner_label'])
: null,
is_string($reasonSemantics['family_label'] ?? null)
? $factory->keyFact('Platform reason family', (string) $reasonSemantics['family_label'])
: null,
]));
if ($reasonSemanticsItems !== []) {
$groups[] = $factory->supportingFactsCard(
kind: 'reason_semantics',
title: 'Explanation semantics',
items: $reasonSemanticsItems,
description: 'Platform meaning stays separate from domain-specific diagnostic detail during rollout.',
);
}
$lifecycleItems = array_values(array_filter([ $lifecycleItems = array_values(array_filter([
$referencedTenantLifecycle !== null $referencedTenantLifecycle !== null
? $factory->keyFact( ? $factory->keyFact(

View File

@ -21,6 +21,7 @@
use App\Support\OperationRunLinks; use App\Support\OperationRunLinks;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Rbac\UiEnforcement; use App\Support\Rbac\UiEnforcement;
use App\Support\TenantReviewCompletenessState; use App\Support\TenantReviewCompletenessState;
use App\Support\TenantReviewStatus; use App\Support\TenantReviewStatus;
@ -564,9 +565,12 @@ private static function reviewCompletenessCountLabel(string $state): string
private static function summaryPresentation(TenantReview $record): array private static function summaryPresentation(TenantReview $record): array
{ {
$summary = is_array($record->summary) ? $record->summary : []; $summary = is_array($record->summary) ? $record->summary : [];
$truthEnvelope = static::truthEnvelope($record);
$reasonPresenter = app(ReasonPresenter::class);
return [ return [
'operator_explanation' => static::truthEnvelope($record)->operatorExplanation?->toArray(), 'operator_explanation' => $truthEnvelope->operatorExplanation?->toArray(),
'reason_semantics' => $reasonPresenter->semantics($truthEnvelope->reason?->toReasonResolutionEnvelope()),
'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [], 'highlights' => is_array($summary['highlights'] ?? null) ? $summary['highlights'] : [],
'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [], 'next_actions' => is_array($summary['recommended_next_actions'] ?? null) ? $summary['recommended_next_actions'] : [],
'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [], 'publish_blockers' => is_array($summary['publish_blockers'] ?? null) ? $summary['publish_blockers'] : [],

View File

@ -14,6 +14,7 @@
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\OpsUx\OperationUxPresenter; use App\Support\OpsUx\OperationUxPresenter;
use App\Support\OpsUx\OpsUxBrowserEvents; use App\Support\OpsUx\OpsUxBrowserEvents;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\ReviewPackStatus; use App\Support\ReviewPackStatus;
use Filament\Actions\Action; use Filament\Actions\Action;
use Filament\Facades\Filament; use Filament\Facades\Filament;
@ -131,7 +132,7 @@ protected function getViewData(): array
$canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant); $canManage = $isTenantMember && $user->can(Capabilities::REVIEW_PACK_MANAGE, $tenant);
$latestPack = ReviewPack::query() $latestPack = ReviewPack::query()
->with('tenantReview') ->with(['tenantReview', 'operationRun'])
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
->orderByDesc('created_at') ->orderByDesc('created_at')
->orderByDesc('id') ->orderByDesc('id')
@ -166,10 +167,25 @@ protected function getViewData(): array
} }
$failedReason = null; $failedReason = null;
$failedReasonDetail = null;
$failedReasonSemantics = null;
if ($statusEnum === ReviewPackStatus::Failed && $latestPack->operationRun) { if ($statusEnum === ReviewPackStatus::Failed && $latestPack->operationRun) {
$reasonPresenter = app(ReasonPresenter::class);
$failedEnvelope = $reasonPresenter->forOperationRun($latestPack->operationRun, 'review_pack_widget');
if ($failedEnvelope !== null) {
$failedReason = $failedEnvelope->operatorLabel;
$failedReasonDetail = $failedEnvelope->shortExplanation;
$failedReasonSemantics = $reasonPresenter->semantics($failedEnvelope);
}
$opContext = is_array($latestPack->operationRun->context) ? $latestPack->operationRun->context : []; $opContext = is_array($latestPack->operationRun->context) ? $latestPack->operationRun->context : [];
if ($failedReason === null) {
$failedReason = (string) ($opContext['reason_code'] ?? 'Unknown error'); $failedReason = (string) ($opContext['reason_code'] ?? 'Unknown error');
} }
}
return [ return [
'tenant' => $tenant, 'tenant' => $tenant,
@ -180,6 +196,8 @@ protected function getViewData(): array
'canManage' => $canManage, 'canManage' => $canManage,
'downloadUrl' => $downloadUrl, 'downloadUrl' => $downloadUrl,
'failedReason' => $failedReason, 'failedReason' => $failedReason,
'failedReasonDetail' => $failedReasonDetail,
'failedReasonSemantics' => $failedReasonSemantics,
'reviewUrl' => $reviewUrl, 'reviewUrl' => $reviewUrl,
]; ];
} }
@ -208,6 +226,8 @@ private function emptyState(): array
'canManage' => false, 'canManage' => false,
'downloadUrl' => null, 'downloadUrl' => null,
'failedReason' => null, 'failedReason' => null,
'failedReasonDetail' => null,
'failedReasonSemantics' => null,
'reviewUrl' => null, 'reviewUrl' => null,
]; ];
} }

View File

@ -12,7 +12,6 @@
use App\Models\Finding; use App\Models\Finding;
use App\Models\InventoryItem; use App\Models\InventoryItem;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Models\PolicyVersion;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
@ -21,19 +20,13 @@
use App\Services\Baselines\BaselineSnapshotIdentity; use App\Services\Baselines\BaselineSnapshotIdentity;
use App\Services\Baselines\BaselineSnapshotTruthResolver; use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Services\Baselines\CurrentStateHashResolver; use App\Services\Baselines\CurrentStateHashResolver;
use App\Services\Baselines\Evidence\BaselinePolicyVersionResolver;
use App\Services\Baselines\Evidence\ContentEvidenceProvider; use App\Services\Baselines\Evidence\ContentEvidenceProvider;
use App\Services\Baselines\Evidence\EvidenceProvenance; use App\Services\Baselines\Evidence\EvidenceProvenance;
use App\Services\Baselines\Evidence\MetaEvidenceProvider; use App\Services\Baselines\Evidence\MetaEvidenceProvider;
use App\Services\Baselines\Evidence\ResolvedEvidence; 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\FindingSlaPolicy;
use App\Services\Findings\FindingWorkflowService; use App\Services\Findings\FindingWorkflowService;
use App\Services\Intune\AuditLogger; use App\Services\Intune\AuditLogger;
use App\Services\Intune\IntuneRoleDefinitionNormalizer;
use App\Services\OperationRunService; use App\Services\OperationRunService;
use App\Services\Settings\SettingsResolver; use App\Services\Settings\SettingsResolver;
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
@ -76,11 +69,6 @@ class CompareBaselineToTenantJob implements ShouldQueue
public bool $failOnTimeout = true; public bool $failOnTimeout = true;
/**
* @var array<int, string>
*/
private array $baselineContentHashCache = [];
public ?OperationRun $operationRun = null; public ?OperationRun $operationRun = null;
public function __construct( public function __construct(
@ -825,7 +813,7 @@ private function rekeyResolvedEvidenceBySubjectKey(array $currentItems, array $r
* captured_versions?: array<string, array{ * captured_versions?: array<string, array{
* policy_type: string, * policy_type: string,
* subject_external_id: string, * subject_external_id: string,
* version: PolicyVersion, * version: \App\Models\PolicyVersion,
* observed_at: string, * observed_at: string,
* observed_operation_run_id: ?int * observed_operation_run_id: ?int
* }> * }>
@ -855,7 +843,7 @@ private function resolveCapturedCurrentEvidenceByExternalId(array $phaseResult):
$observedOperationRunId = $capturedVersion['observed_operation_run_id'] ?? null; $observedOperationRunId = $capturedVersion['observed_operation_run_id'] ?? null;
$observedOperationRunId = is_numeric($observedOperationRunId) ? (int) $observedOperationRunId : 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; continue;
} }
@ -870,6 +858,7 @@ private function resolveCapturedCurrentEvidenceByExternalId(array $phaseResult):
return $resolved; return $resolved;
} }
private function completeWithCoverageWarning( private function completeWithCoverageWarning(
OperationRunService $operationRunService, OperationRunService $operationRunService,
AuditLogger $auditLogger, AuditLogger $auditLogger,
@ -1423,750 +1412,6 @@ private function truthfulTypesFromContext(array $context, BaselineScope $effecti
return $effectiveScope->allTypes(); 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( private function normalizeSubjectKey(
string $policyType, string $policyType,
?string $storedSubjectKey = null, ?string $storedSubjectKey = null,
@ -2182,50 +1427,6 @@ private function normalizeSubjectKey(
return BaselineSubjectKey::forPolicy($policyType, $displayName, $subjectExternalId) ?? ''; 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} * @return array{total_compared: int, unchanged: int, modified: int, missing: int, unexpected: int}
*/ */

View File

@ -94,13 +94,13 @@ public function table(Table $table): Table
->sortable() ->sortable()
->wrap() ->wrap()
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('policy_type') TextColumn::make('governed_subject_label')
->label(__('baseline-compare.evidence_gap_policy_type')) ->label(__('baseline-compare.evidence_gap_policy_type'))
->badge() ->badge()
->formatStateUsing(TagBadgeRenderer::label(TagBadgeDomain::PolicyType)) ->formatStateUsing(fn (mixed $state): string => is_string($state) && trim($state) !== '' ? $state : 'Unknown governed subject')
->color(TagBadgeRenderer::color(TagBadgeDomain::PolicyType)) ->color(fn (mixed $state, Model $record): string => TagBadgeRenderer::spec(TagBadgeDomain::PolicyType, $record->getAttribute('policy_type'))->color)
->icon(TagBadgeRenderer::icon(TagBadgeDomain::PolicyType)) ->icon(fn (mixed $state, Model $record): ?string => TagBadgeRenderer::spec(TagBadgeDomain::PolicyType, $record->getAttribute('policy_type'))->icon)
->iconColor(TagBadgeRenderer::iconColor(TagBadgeDomain::PolicyType)) ->iconColor(fn (mixed $state, Model $record): ?string => TagBadgeRenderer::spec(TagBadgeDomain::PolicyType, $record->getAttribute('policy_type'))->iconColor)
->searchable() ->searchable()
->sortable() ->sortable()
->wrap(), ->wrap(),

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

View File

@ -6,6 +6,7 @@
use App\Support\OperationCatalog; use App\Support\OperationCatalog;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\OperationTypeResolution;
use App\Support\OperationRunType; use App\Support\OperationRunType;
use App\Support\Operations\OperationLifecyclePolicy; use App\Support\Operations\OperationLifecyclePolicy;
use App\Support\Operations\OperationRunFreshnessState; use App\Support\Operations\OperationRunFreshnessState;
@ -291,6 +292,16 @@ public function inventoryCoverage(): ?InventoryCoverage
return InventoryCoverage::fromContext($this->context); return InventoryCoverage::fromContext($this->context);
} }
public function resolvedOperationType(): OperationTypeResolution
{
return OperationCatalog::resolve((string) $this->type);
}
public function canonicalOperationType(): string
{
return $this->resolvedOperationType()->canonical->canonicalCode;
}
public function isGovernanceArtifactOperation(): bool public function isGovernanceArtifactOperation(): bool
{ {
return OperationCatalog::isGovernanceArtifactOperation((string) $this->type); return OperationCatalog::isGovernanceArtifactOperation((string) $this->type);

View File

@ -9,6 +9,7 @@
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer; use App\Support\Badges\BadgeRenderer;
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder; use App\Support\Ui\EnterpriseDetail\EnterpriseDetailBuilder;
use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData; use App\Support\Ui\EnterpriseDetail\EnterpriseDetailPageData;
@ -51,6 +52,8 @@ public function present(BaselineSnapshot $snapshot): RenderedSnapshot
static fn (RenderedSnapshotGroup $group): array => [ static fn (RenderedSnapshotGroup $group): array => [
'policyType' => $group->policyType, 'policyType' => $group->policyType,
'label' => $group->label, 'label' => $group->label,
'governedSubjectLabel' => data_get($group->subjectDescriptor, 'display_label', $group->label),
'subjectDescriptor' => $group->subjectDescriptor,
'itemCount' => $group->itemCount, 'itemCount' => $group->itemCount,
'fidelity' => $group->fidelity->value, 'fidelity' => $group->fidelity->value,
'gapCount' => $group->gapSummary->count, 'gapCount' => $group->gapSummary->count,
@ -166,7 +169,7 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
title: 'Coverage summary', title: 'Coverage summary',
view: 'filament.infolists.entries.baseline-snapshot-summary-table', view: 'filament.infolists.entries.baseline-snapshot-summary-table',
viewData: ['rows' => $rendered->summaryRows], viewData: ['rows' => $rendered->summaryRows],
emptyState: $factory->emptyState('No captured policy types are available in this snapshot.'), emptyState: $factory->emptyState('No captured governed subjects are available in this snapshot.'),
), ),
$factory->viewSection( $factory->viewSection(
id: 'related_context', id: 'related_context',
@ -179,7 +182,7 @@ public function presentEnterpriseDetail(BaselineSnapshot $snapshot, array $relat
$factory->viewSection( $factory->viewSection(
id: 'captured_policy_types', id: 'captured_policy_types',
kind: 'domain_detail', kind: 'domain_detail',
title: 'Captured policy types', title: 'Captured governed subjects',
view: 'filament.infolists.entries.baseline-snapshot-groups', view: 'filament.infolists.entries.baseline-snapshot-groups',
viewData: ['groups' => array_map( viewData: ['groups' => array_map(
static fn (RenderedSnapshotGroup $group): array => $group->toArray(), static fn (RenderedSnapshotGroup $group): array => $group->toArray(),
@ -250,7 +253,8 @@ private function presentGroup(string $policyType, Collection $items): RenderedSn
$renderer = $this->registry->rendererFor($policyType); $renderer = $this->registry->rendererFor($policyType);
$fallbackRenderer = $this->registry->fallbackRenderer(); $fallbackRenderer = $this->registry->fallbackRenderer();
$renderingError = null; $renderingError = null;
$technicalPayload = $this->technicalPayload($items); $subjectDescriptor = $this->subjectDescriptor($policyType);
$technicalPayload = $this->technicalPayload($items) + ['subject_descriptor' => $subjectDescriptor];
try { try {
$renderedItems = $items $renderedItems = $items
@ -261,7 +265,7 @@ private function presentGroup(string $policyType, Collection $items): RenderedSn
->map(fn (BaselineSnapshotItem $item): RenderedSnapshotItem => $fallbackRenderer->render($item)) ->map(fn (BaselineSnapshotItem $item): RenderedSnapshotItem => $fallbackRenderer->render($item))
->all(); ->all();
$renderingError = 'Structured rendering failed for this policy type. Fallback metadata is shown instead.'; $renderingError = 'Structured rendering failed for this governed subject family. Fallback metadata is shown instead.';
} }
/** @var array<int, RenderedSnapshotItem> $renderedItems */ /** @var array<int, RenderedSnapshotItem> $renderedItems */
@ -299,6 +303,7 @@ private function presentGroup(string $policyType, Collection $items): RenderedSn
coverageHint: $coverageHint, coverageHint: $coverageHint,
capturedAt: is_string($capturedAt) ? $capturedAt : null, capturedAt: is_string($capturedAt) ? $capturedAt : null,
technicalPayload: $technicalPayload, technicalPayload: $technicalPayload,
subjectDescriptor: $subjectDescriptor,
); );
} }
@ -404,9 +409,28 @@ private function currentTruthPresentation(ArtifactTruthEnvelope $truth): array
private function typeLabel(string $policyType): string private function typeLabel(string $policyType): string
{ {
return InventoryPolicyTypeMeta::baselineCompareLabel($policyType) return (string) (data_get($this->subjectDescriptor($policyType), 'display_label')
?? InventoryPolicyTypeMeta::baselineCompareLabel($policyType)
?? InventoryPolicyTypeMeta::label($policyType) ?? InventoryPolicyTypeMeta::label($policyType)
?? Str::headline($policyType); ?? Str::headline($policyType));
}
/**
* @return array<string, mixed>
*/
private function subjectDescriptor(string $policyType): array
{
static $cache = [];
if (array_key_exists($policyType, $cache)) {
return $cache[$policyType];
}
$result = app(PlatformSubjectDescriptorNormalizer::class)->fromArray([
'policy_type' => $policyType,
], 'baseline_snapshot');
return $cache[$policyType] = $result->descriptor->toArray();
} }
private function formatTimestamp(?string $value): string private function formatTimestamp(?string $value): string

View File

@ -9,6 +9,7 @@
/** /**
* @param array<int, RenderedSnapshotItem> $items * @param array<int, RenderedSnapshotItem> $items
* @param array<string, mixed> $technicalPayload * @param array<string, mixed> $technicalPayload
* @param array<string, mixed> $subjectDescriptor
*/ */
public function __construct( public function __construct(
public string $policyType, public string $policyType,
@ -22,6 +23,7 @@ public function __construct(
public ?string $coverageHint = null, public ?string $coverageHint = null,
public ?string $capturedAt = null, public ?string $capturedAt = null,
public array $technicalPayload = [], public array $technicalPayload = [],
public array $subjectDescriptor = [],
) {} ) {}
/** /**
@ -46,7 +48,8 @@ public function __construct(
* renderingError: ?string, * renderingError: ?string,
* coverageHint: ?string, * coverageHint: ?string,
* capturedAt: ?string, * capturedAt: ?string,
* technicalPayload: array<string, mixed> * technicalPayload: array<string, mixed>,
* subjectDescriptor: array<string, mixed>
* } * }
*/ */
public function toArray(): array public function toArray(): array
@ -66,6 +69,7 @@ public function toArray(): array
'coverageHint' => $this->coverageHint, 'coverageHint' => $this->coverageHint,
'capturedAt' => $this->capturedAt, 'capturedAt' => $this->capturedAt,
'technicalPayload' => $this->technicalPayload, 'technicalPayload' => $this->technicalPayload,
'subjectDescriptor' => $this->subjectDescriptor,
]; ];
} }
} }

View File

@ -53,6 +53,8 @@ public function build(Tenant $tenant, array $filters = []): array
$filteredPermissions = self::applyFilterState($allPermissions, $state); $filteredPermissions = self::applyFilterState($allPermissions, $state);
$freshness = self::deriveFreshness(self::parseLastRefreshedAt($comparison['last_refreshed_at'] ?? null)); $freshness = self::deriveFreshness(self::parseLastRefreshedAt($comparison['last_refreshed_at'] ?? null));
$summaryPermissions = $filteredPermissions;
return [ return [
'tenant' => [ 'tenant' => [
'id' => (int) $tenant->getKey(), 'id' => (int) $tenant->getKey(),
@ -60,9 +62,9 @@ public function build(Tenant $tenant, array $filters = []): array
'name' => (string) $tenant->name, 'name' => (string) $tenant->name,
], ],
'overview' => [ 'overview' => [
'overall' => self::deriveOverallStatus($allPermissions, (bool) ($freshness['is_stale'] ?? true)), 'overall' => self::deriveOverallStatus($summaryPermissions, (bool) ($freshness['is_stale'] ?? true)),
'counts' => self::deriveCounts($allPermissions), 'counts' => self::deriveCounts($summaryPermissions),
'feature_impacts' => self::deriveFeatureImpacts($allPermissions), 'feature_impacts' => self::deriveFeatureImpacts($summaryPermissions),
'freshness' => $freshness, 'freshness' => $freshness,
], ],
'permissions' => $filteredPermissions, 'permissions' => $filteredPermissions,

View File

@ -5,6 +5,7 @@
namespace App\Support\Baselines; namespace App\Support\Baselines;
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
use Illuminate\Support\Str; use Illuminate\Support\Str;
final class BaselineCompareEvidenceGapDetails final class BaselineCompareEvidenceGapDetails
@ -333,6 +334,8 @@ public static function tableRows(array $buckets): array
'reason_code' => $reasonCode, 'reason_code' => $reasonCode,
'reason_label' => self::reasonLabel($reasonCode), 'reason_label' => self::reasonLabel($reasonCode),
'policy_type' => $policyType, 'policy_type' => $policyType,
'governed_subject_label' => (string) ($row['governed_subject_label'] ?? self::governedSubjectLabel($policyType)),
'governed_subject' => is_array($row['governed_subject'] ?? null) ? $row['governed_subject'] : self::subjectDescriptor($policyType),
'subject_key' => $subjectKey, 'subject_key' => $subjectKey,
'subject_class' => $subjectClass, 'subject_class' => $subjectClass,
'subject_class_label' => self::subjectClassLabel($subjectClass), 'subject_class_label' => self::subjectClassLabel($subjectClass),
@ -373,10 +376,11 @@ public static function reasonFilterOptions(array $rows): array
public static function policyTypeFilterOptions(array $rows): array public static function policyTypeFilterOptions(array $rows): array
{ {
return collect($rows) return collect($rows)
->pluck('policy_type') ->filter(fn (array $row): bool => filled($row['policy_type'] ?? null))
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '') ->mapWithKeys(fn (array $row): array => [
->mapWithKeys(fn (string $value): array => [$value => $value]) (string) $row['policy_type'] => (string) ($row['governed_subject_label'] ?? $row['policy_type']),
->sortKeysUsing('strnatcasecmp') ])
->sortBy(fn (string $label): string => Str::lower($label))
->all(); ->all();
} }
@ -659,16 +663,20 @@ private static function projectSubjectRow(array $subject): array
$subjectClass = (string) $subject['subject_class']; $subjectClass = (string) $subject['subject_class'];
$resolutionOutcome = (string) $subject['resolution_outcome']; $resolutionOutcome = (string) $subject['resolution_outcome'];
$operatorActionCategory = (string) $subject['operator_action_category']; $operatorActionCategory = (string) $subject['operator_action_category'];
$policyType = (string) ($subject['policy_type'] ?? '');
return array_merge($subject, [ return array_merge($subject, [
'reason_label' => self::reasonLabel($reasonCode), 'reason_label' => self::reasonLabel($reasonCode),
'governed_subject' => self::subjectDescriptor($policyType),
'governed_subject_label' => self::governedSubjectLabel($policyType),
'subject_class_label' => self::subjectClassLabel($subjectClass), 'subject_class_label' => self::subjectClassLabel($subjectClass),
'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome), 'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome),
'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory), 'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory),
'search_text' => Str::lower(trim(implode(' ', array_filter([ 'search_text' => Str::lower(trim(implode(' ', array_filter([
$reasonCode, $reasonCode,
self::reasonLabel($reasonCode), self::reasonLabel($reasonCode),
(string) ($subject['policy_type'] ?? ''), $policyType,
self::governedSubjectLabel($policyType),
(string) ($subject['subject_key'] ?? ''), (string) ($subject['subject_key'] ?? ''),
$subjectClass, $subjectClass,
self::subjectClassLabel($subjectClass), self::subjectClassLabel($subjectClass),
@ -682,6 +690,29 @@ private static function projectSubjectRow(array $subject): array
]); ]);
} }
/**
* @return array<string, mixed>
*/
private static function subjectDescriptor(string $policyType): array
{
static $cache = [];
if (array_key_exists($policyType, $cache)) {
return $cache[$policyType];
}
$result = app(PlatformSubjectDescriptorNormalizer::class)->fromArray([
'policy_type' => $policyType,
], 'baseline_compare');
return $cache[$policyType] = $result->descriptor->toArray();
}
private static function governedSubjectLabel(string $policyType): string
{
return (string) (data_get(self::subjectDescriptor($policyType), 'display_label') ?: $policyType);
}
private static function stringOrNull(mixed $value): ?string private static function stringOrNull(mixed $value): ?string
{ {
if (! is_string($value)) { if (! is_string($value)) {

View File

@ -128,7 +128,7 @@ public function forStats(BaselineCompareStats $stats): OperatorExplanationPatter
$stats->state === 'idle' => 'No compare run has produced a result yet for this tenant.', $stats->state === 'idle' => 'No compare run has produced a result yet for this tenant.',
$isFailed => 'The last compare run did not finish cleanly, so current counts should not be treated as decision-grade.', $isFailed => 'The last compare run did not finish cleanly, so current counts should not be treated as decision-grade.',
$stats->coverageStatus === 'unproven' => 'Coverage proof was unavailable, so suppressed output is possible even when the findings count is zero.', $stats->coverageStatus === 'unproven' => 'Coverage proof was unavailable, so suppressed output is possible even when the findings count is zero.',
$stats->uncoveredTypes !== [] => 'One or more in-scope policy types were not fully covered in this compare run.', $stats->uncoveredTypes !== [] => 'One or more in-scope governed subjects were not fully covered in this compare run.',
$hasEvidenceGaps => 'Evidence gaps limited the quality of the visible result for some subjects.', $hasEvidenceGaps => 'Evidence gaps limited the quality of the visible result for some subjects.',
$isInProgress => 'Counts will become decision-grade after the compare run finishes.', $isInProgress => 'Counts will become decision-grade after the compare run finishes.',
in_array($family, [ExplanationFamily::MissingInput, ExplanationFamily::BlockedPrerequisite, ExplanationFamily::Unavailable], true) => $reason?->shortExplanation ?? 'The compare inputs were not complete enough to produce a normal result.', in_array($family, [ExplanationFamily::MissingInput, ExplanationFamily::BlockedPrerequisite, ExplanationFamily::Unavailable], true) => $reason?->shortExplanation ?? 'The compare inputs were not complete enough to produce a normal result.',

View File

@ -17,6 +17,7 @@
use App\Support\Auth\Capabilities; use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog; use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain; use App\Support\Badges\BadgeDomain;
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
@ -81,8 +82,8 @@ public function build(BaselineProfile $profile, User $user, array $filters = [])
->filter(static fn (mixed $type): bool => is_string($type) && trim($type) !== '') ->filter(static fn (mixed $type): bool => is_string($type) && trim($type) !== '')
->unique() ->unique()
->sort() ->sort()
->mapWithKeys(static fn (string $type): array => [ ->mapWithKeys(fn (string $type): array => [
$type => InventoryPolicyTypeMeta::label($type) ?? $type, $type => $this->governedSubjectLabel($type),
]) ])
->all(); ->all();
@ -118,7 +119,7 @@ public function build(BaselineProfile $profile, User $user, array $filters = [])
], ],
'subjectSortOptions' => [ 'subjectSortOptions' => [
'deviation_breadth' => 'Deviation breadth', 'deviation_breadth' => 'Deviation breadth',
'policy_type' => 'Policy type', 'policy_type' => 'Governed subject',
'display_name' => 'Display name', 'display_name' => 'Display name',
], ],
'stateLegend' => $this->legendSpecs(BadgeDomain::BaselineCompareMatrixState, [ 'stateLegend' => $this->legendSpecs(BadgeDomain::BaselineCompareMatrixState, [
@ -209,6 +210,8 @@ public function build(BaselineProfile $profile, User $user, array $filters = [])
$subject = [ $subject = [
'subjectKey' => $subjectKey, 'subjectKey' => $subjectKey,
'policyType' => (string) $item->policy_type, 'policyType' => (string) $item->policy_type,
'governedSubjectLabel' => $this->governedSubjectLabel((string) $item->policy_type),
'subjectDescriptor' => $this->subjectDescriptor((string) $item->policy_type),
'displayName' => $this->subjectDisplayName($item), 'displayName' => $this->subjectDisplayName($item),
'baselineExternalId' => is_string($item->subject_external_id) ? $item->subject_external_id : null, 'baselineExternalId' => is_string($item->subject_external_id) ? $item->subject_external_id : null,
]; ];
@ -572,7 +575,7 @@ private function reasonSummary(string $state, ?string $reasonCode, bool $policyT
'stale_result' => 'Refresh recommended before acting on this result.', 'stale_result' => 'Refresh recommended before acting on this result.',
'not_compared' => $policyTypeCovered 'not_compared' => $policyTypeCovered
? 'No completed compare result is available yet.' ? 'No completed compare result is available yet.'
: 'Policy type coverage was not proven in the latest compare run.', : 'Governed subject coverage was not proven in the latest compare run.',
default => null, default => null,
}; };
} }
@ -642,6 +645,8 @@ private function subjectSummary(array $subject, array $cells): array
return [ return [
'subjectKey' => $subject['subjectKey'], 'subjectKey' => $subject['subjectKey'],
'policyType' => $subject['policyType'], 'policyType' => $subject['policyType'],
'governedSubjectLabel' => $subject['governedSubjectLabel'] ?? $this->governedSubjectLabel((string) $subject['policyType']),
'subjectDescriptor' => $subject['subjectDescriptor'] ?? $this->subjectDescriptor((string) $subject['policyType']),
'displayName' => $subject['displayName'], 'displayName' => $subject['displayName'],
'baselineExternalId' => $subject['baselineExternalId'], 'baselineExternalId' => $subject['baselineExternalId'],
'deviationBreadth' => $this->countStates($cells, ['differ', 'missing']), 'deviationBreadth' => $this->countStates($cells, ['differ', 'missing']),
@ -809,8 +814,13 @@ private function sortRows(array $rows, string $sort): array
$rightSubject = $right['subject'] ?? []; $rightSubject = $right['subject'] ?? [];
return match ($sort) { return match ($sort) {
'policy_type' => [(string) ($leftSubject['policyType'] ?? ''), Str::lower((string) ($leftSubject['displayName'] ?? ''))] 'policy_type' => [
<=> [(string) ($rightSubject['policyType'] ?? ''), Str::lower((string) ($rightSubject['displayName'] ?? ''))], Str::lower((string) ($leftSubject['governedSubjectLabel'] ?? $leftSubject['policyType'] ?? '')),
Str::lower((string) ($leftSubject['displayName'] ?? '')),
] <=> [
Str::lower((string) ($rightSubject['governedSubjectLabel'] ?? $rightSubject['policyType'] ?? '')),
Str::lower((string) ($rightSubject['displayName'] ?? '')),
],
'display_name' => [Str::lower((string) ($leftSubject['displayName'] ?? '')), (string) ($leftSubject['subjectKey'] ?? '')] 'display_name' => [Str::lower((string) ($leftSubject['displayName'] ?? '')), (string) ($leftSubject['subjectKey'] ?? '')]
<=> [Str::lower((string) ($rightSubject['displayName'] ?? '')), (string) ($rightSubject['subjectKey'] ?? '')], <=> [Str::lower((string) ($rightSubject['displayName'] ?? '')), (string) ($rightSubject['subjectKey'] ?? '')],
default => [ default => [
@ -914,6 +924,8 @@ private function compactResults(array $rows, array $tenantSummaries): array
'subjectKey' => (string) ($subject['subjectKey'] ?? ''), 'subjectKey' => (string) ($subject['subjectKey'] ?? ''),
'displayName' => $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject', 'displayName' => $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject',
'policyType' => (string) ($subject['policyType'] ?? ''), 'policyType' => (string) ($subject['policyType'] ?? ''),
'governedSubjectLabel' => (string) ($subject['governedSubjectLabel'] ?? $this->governedSubjectLabel((string) ($subject['policyType'] ?? ''))),
'subjectDescriptor' => $subject['subjectDescriptor'] ?? $this->subjectDescriptor((string) ($subject['policyType'] ?? '')),
'baselineExternalId' => $subject['baselineExternalId'] ?? null, 'baselineExternalId' => $subject['baselineExternalId'] ?? null,
'deviationBreadth' => (int) ($subject['deviationBreadth'] ?? 0), 'deviationBreadth' => (int) ($subject['deviationBreadth'] ?? 0),
'missingBreadth' => (int) ($subject['missingBreadth'] ?? 0), 'missingBreadth' => (int) ($subject['missingBreadth'] ?? 0),
@ -974,7 +986,7 @@ private function emptyState(
if ($renderedRowsCount === 0) { if ($renderedRowsCount === 0) {
return [ return [
'title' => 'No rows match the current filters', 'title' => 'No rows match the current filters',
'body' => 'Adjust the policy type, state, or severity filters to broaden the matrix view.', 'body' => 'Adjust the governed subject, state, or severity filters to broaden the matrix view.',
]; ];
} }
@ -1002,4 +1014,31 @@ static function (string $value) use ($domain): array {
$values, $values,
); );
} }
private function governedSubjectLabel(string $policyType): string
{
return (string) data_get(
$this->subjectDescriptor($policyType),
'display_label',
InventoryPolicyTypeMeta::label($policyType) ?? Str::headline($policyType),
);
}
/**
* @return array<string, mixed>
*/
private function subjectDescriptor(string $policyType): array
{
static $cache = [];
if (array_key_exists($policyType, $cache)) {
return $cache[$policyType];
}
$result = app(PlatformSubjectDescriptorNormalizer::class)->fromArray([
'policy_type' => $policyType,
], 'baseline_compare_matrix');
return $cache[$policyType] = $result->descriptor->toArray();
}
} }

View File

@ -7,6 +7,7 @@
use App\Support\Governance\GovernanceDomainKey; use App\Support\Governance\GovernanceDomainKey;
use App\Support\Governance\GovernanceSubjectClass; use App\Support\Governance\GovernanceSubjectClass;
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry; use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use InvalidArgumentException; use InvalidArgumentException;
@ -303,6 +304,16 @@ public function summaryGroups(?GovernanceSubjectTaxonomyRegistry $registry = nul
return $groups; return $groups;
} }
/**
* @return list<array<string, mixed>>
*/
public function subjectDescriptors(?PlatformSubjectDescriptorNormalizer $normalizer = null): array
{
$normalizer ??= app(PlatformSubjectDescriptorNormalizer::class);
return $normalizer->descriptorsForScopeEntries($this->entries);
}
/** /**
* Effective scope payload for OperationRun.context. * Effective scope payload for OperationRun.context.
* *
@ -321,6 +332,7 @@ public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard =
'all_types' => $allTypes, 'all_types' => $allTypes,
'selected_type_keys' => $allTypes, 'selected_type_keys' => $allTypes,
'foundations_included' => $expanded->foundationTypes !== [], 'foundations_included' => $expanded->foundationTypes !== [],
'governed_subjects' => $expanded->subjectDescriptors(),
]; ];
if (! is_string($operation) || $operation === '') { if (! is_string($operation) || $operation === '') {

View File

@ -10,6 +10,7 @@ final class CompareSubjectProjection
{ {
/** /**
* @param array<string, string> $additionalLabels * @param array<string, string> $additionalLabels
* @param array<string, mixed>|null $subjectDescriptor
*/ */
public function __construct( public function __construct(
public readonly string $platformSubjectClass, public readonly string $platformSubjectClass,
@ -18,6 +19,7 @@ public function __construct(
public readonly string $operatorLabel, public readonly string $operatorLabel,
public readonly ?string $summaryKind = null, public readonly ?string $summaryKind = null,
public readonly array $additionalLabels = [], public readonly array $additionalLabels = [],
public readonly ?array $subjectDescriptor = null,
) { ) {
if (trim($this->platformSubjectClass) === '' || trim($this->domainKey) === '' || trim($this->subjectTypeKey) === '' || trim($this->operatorLabel) === '') { if (trim($this->platformSubjectClass) === '' || trim($this->domainKey) === '' || trim($this->subjectTypeKey) === '' || trim($this->operatorLabel) === '') {
throw new InvalidArgumentException('Compare subject projections require non-empty platform subject class, domain key, subject type key, and operator label values.'); throw new InvalidArgumentException('Compare subject projections require non-empty platform subject class, domain key, subject type key, and operator label values.');
@ -31,7 +33,8 @@ public function __construct(
* subject_type_key: string, * subject_type_key: string,
* operator_label: string, * operator_label: string,
* summary_kind: ?string, * summary_kind: ?string,
* additional_labels: array<string, string> * additional_labels: array<string, string>,
* subject_descriptor: ?array<string, mixed>
* } * }
*/ */
public function toArray(): array public function toArray(): array
@ -43,6 +46,7 @@ public function toArray(): array
'operator_label' => $this->operatorLabel, 'operator_label' => $this->operatorLabel,
'summary_kind' => $this->summaryKind, 'summary_kind' => $this->summaryKind,
'additional_labels' => $this->additionalLabels, 'additional_labels' => $this->additionalLabels,
'subject_descriptor' => $this->subjectDescriptor,
]; ];
} }
} }

View File

@ -23,6 +23,7 @@
use App\Support\Baselines\SubjectResolver; use App\Support\Baselines\SubjectResolver;
use App\Support\Governance\GovernanceDomainKey; use App\Support\Governance\GovernanceDomainKey;
use App\Support\Governance\GovernanceSubjectClass; use App\Support\Governance\GovernanceSubjectClass;
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel; use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
@ -557,10 +558,30 @@ private function subjectProjection(string $policyType, string $operatorLabel, ?s
summaryKind: $summaryKind, summaryKind: $summaryKind,
additionalLabels: [ additionalLabels: [
'policy_type_label' => InventoryPolicyTypeMeta::baselineCompareLabel($policyType) ?? $policyType, 'policy_type_label' => InventoryPolicyTypeMeta::baselineCompareLabel($policyType) ?? $policyType,
'governed_subject_label' => (string) data_get($this->subjectDescriptor($policyType), 'display_label', $policyType),
], ],
subjectDescriptor: $this->subjectDescriptor($policyType),
); );
} }
/**
* @return array<string, mixed>
*/
private function subjectDescriptor(string $policyType): array
{
static $cache = [];
if (array_key_exists($policyType, $cache)) {
return $cache[$policyType];
}
$result = app(PlatformSubjectDescriptorNormalizer::class)->fromArray([
'policy_type' => $policyType,
], 'baseline_compare');
return $cache[$policyType] = $result->descriptor->toArray();
}
private function domainKeyFor(string $policyType): string private function domainKeyFor(string $policyType): string
{ {
return InventoryPolicyTypeMeta::isFoundation($policyType) return InventoryPolicyTypeMeta::isFoundation($policyType)

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Support;
final readonly class CanonicalOperationType
{
public function __construct(
public string $canonicalCode,
public ?string $domainKey,
public ?string $artifactFamily,
public string $displayLabel,
public bool $supportsOperatorExplanation = false,
public ?int $expectedDurationSeconds = null,
) {}
/**
* @return array{
* canonical_code: string,
* domain_key: ?string,
* artifact_family: ?string,
* display_label: string,
* supports_operator_explanation: bool,
* expected_duration_seconds: ?int
* }
*/
public function toArray(): array
{
return [
'canonical_code' => $this->canonicalCode,
'domain_key' => $this->domainKey,
'artifact_family' => $this->artifactFamily,
'display_label' => $this->displayLabel,
'supports_operator_explanation' => $this->supportsOperatorExplanation,
'expected_duration_seconds' => $this->expectedDurationSeconds,
];
}
}

View File

@ -203,17 +203,7 @@ public static function findingGovernanceAttentionStates(): array
*/ */
public static function operationTypes(?iterable $types = null): array public static function operationTypes(?iterable $types = null): array
{ {
$values = collect($types ?? array_keys(OperationCatalog::labels())) return OperationCatalog::filterOptions($types);
->filter(fn (mixed $type): bool => is_string($type) && trim($type) !== '')
->map(fn (string $type): string => trim($type))
->unique()
->sort()
->values();
return $values
->mapWithKeys(fn (string $type): array => [$type => OperationCatalog::label($type)])
->sort()
->all();
} }
/** /**

View File

@ -6,7 +6,7 @@
use App\Support\Inventory\InventoryPolicyTypeMeta; use App\Support\Inventory\InventoryPolicyTypeMeta;
final class GovernanceSubjectTaxonomyRegistry class GovernanceSubjectTaxonomyRegistry
{ {
/** /**
* @var array<string, list<string>> * @var array<string, list<string>>
@ -76,6 +76,50 @@ public function find(string $domainKey, string $subjectTypeKey): ?GovernanceSubj
return null; return null;
} }
public function findBySubjectTypeKey(string $subjectTypeKey, ?string $legacyBucket = null): ?GovernanceSubjectType
{
$subjectTypeKey = trim($subjectTypeKey);
$legacyBucket = is_string($legacyBucket) ? trim($legacyBucket) : null;
foreach ($this->all() as $subjectType) {
if ($subjectType->subjectTypeKey !== $subjectTypeKey) {
continue;
}
if ($legacyBucket !== null && $subjectType->legacyBucket !== $legacyBucket) {
continue;
}
return $subjectType;
}
return null;
}
/**
* @return list<string>
*/
public function canonicalNouns(): array
{
return ['domain_key', 'subject_class', 'subject_type_key', 'subject_type_label'];
}
public function ownershipDescriptor(?PlatformVocabularyGlossary $glossary = null): RegistryOwnershipDescriptor
{
$glossary ??= app(PlatformVocabularyGlossary::class);
return $glossary->registry('governance_subject_taxonomy_registry')
?? RegistryOwnershipDescriptor::fromArray([
'registry_key' => 'governance_subject_taxonomy_registry',
'boundary_classification' => PlatformVocabularyGlossary::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => PlatformVocabularyGlossary::OWNER_PLATFORM_CORE,
'source_class_or_file' => self::class,
'canonical_nouns' => $this->canonicalNouns(),
'allowed_consumers' => ['baseline_scope', 'compare', 'snapshot', 'review'],
'compatibility_notes' => 'Governed-subject registry lookups remain the canonical bridge from legacy policy-type payloads to platform-safe descriptors.',
]);
}
public function isKnownDomain(string $domainKey): bool public function isKnownDomain(string $domainKey): bool
{ {
return array_key_exists(trim($domainKey), self::DOMAIN_CLASSES); return array_key_exists(trim($domainKey), self::DOMAIN_CLASSES);

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
use InvalidArgumentException;
final readonly class PlatformSubjectDescriptor
{
public function __construct(
public string $domainKey,
public string $subjectClass,
public string $subjectTypeKey,
public string $subjectTypeLabel,
public string $platformNoun,
public string $displayLabel,
public ?string $legacyPolicyType = null,
public string $ownerLayer = PlatformVocabularyGlossary::OWNER_PLATFORM_CORE,
) {
foreach ([
'domain key' => $this->domainKey,
'subject class' => $this->subjectClass,
'subject type key' => $this->subjectTypeKey,
'subject type label' => $this->subjectTypeLabel,
'platform noun' => $this->platformNoun,
'display label' => $this->displayLabel,
] as $label => $value) {
if (trim($value) === '') {
throw new InvalidArgumentException(sprintf('Platform subject descriptors require a non-empty %s.', $label));
}
}
}
/**
* @return array{
* domain_key: string,
* subject_class: string,
* subject_type_key: string,
* subject_type_label: string,
* platform_noun: string,
* display_label: string,
* legacy_policy_type: ?string,
* owner_layer: string
* }
*/
public function toArray(): array
{
return [
'domain_key' => $this->domainKey,
'subject_class' => $this->subjectClass,
'subject_type_key' => $this->subjectTypeKey,
'subject_type_label' => $this->subjectTypeLabel,
'platform_noun' => $this->platformNoun,
'display_label' => $this->displayLabel,
'legacy_policy_type' => $this->legacyPolicyType,
'owner_layer' => $this->ownerLayer,
];
}
}

View File

@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
use Illuminate\Support\Str;
final class PlatformSubjectDescriptorNormalizer
{
public function __construct(
private readonly GovernanceSubjectTaxonomyRegistry $registry,
private readonly PlatformVocabularyGlossary $glossary,
) {}
/**
* @param array<string, mixed> $payload
*/
public function fromArray(array $payload, string $sourceSurface = 'runtime'): SubjectDescriptorNormalizationResult
{
$usedLegacySource = ! isset($payload['subject_type_key'])
&& (isset($payload['policy_type']) || isset($payload['subject_type']));
$subjectTypeKey = $this->stringValue(
$payload['subject_type_key']
?? $payload['policy_type']
?? $payload['subject_type']
?? null,
);
$result = $this->normalize(
subjectTypeKey: $subjectTypeKey,
domainKey: $this->stringValue($payload['domain_key'] ?? null),
subjectClass: $this->stringValue($payload['subject_class'] ?? null),
legacyPolicyType: $this->stringValue($payload['policy_type'] ?? null),
sourceSurface: $sourceSurface,
);
if (! $usedLegacySource || $result->usedLegacyAlias) {
return $result;
}
return new SubjectDescriptorNormalizationResult(
descriptor: $result->descriptor,
sourceSurface: $result->sourceSurface,
usedLegacyAlias: true,
warnings: array_values(array_unique(array_merge(
['Resolved a compatibility-only policy_type payload through governed-subject normalization.'],
$result->warnings,
))),
);
}
public function fromLegacyBucket(string $legacyBucket, string $subjectTypeKey, string $sourceSurface = 'runtime'): SubjectDescriptorNormalizationResult
{
$subjectType = $this->registry->findBySubjectTypeKey($subjectTypeKey, $legacyBucket);
return $this->buildResult(
subjectType: $subjectType,
sourceSurface: $sourceSurface,
legacyPolicyType: $subjectTypeKey,
usedLegacyAlias: true,
);
}
public function normalize(
?string $subjectTypeKey,
?string $domainKey = null,
?string $subjectClass = null,
?string $legacyPolicyType = null,
string $sourceSurface = 'runtime',
): SubjectDescriptorNormalizationResult {
$subjectType = null;
if (is_string($subjectTypeKey) && trim($subjectTypeKey) !== '') {
$subjectType = $domainKey !== null
? $this->registry->find($domainKey, $subjectTypeKey)
: $this->registry->findBySubjectTypeKey($subjectTypeKey);
}
if ($subjectType === null && is_string($legacyPolicyType) && trim($legacyPolicyType) !== '') {
return $this->fromLegacyBucket('policy_types', $legacyPolicyType, $sourceSurface);
}
return $this->buildResult(
subjectType: $subjectType,
sourceSurface: $sourceSurface,
explicitDomainKey: $domainKey,
explicitSubjectClass: $subjectClass,
legacyPolicyType: $legacyPolicyType,
usedLegacyAlias: false,
);
}
/**
* @param list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}> $entries
* @return list<array<string, mixed>>
*/
public function descriptorsForScopeEntries(array $entries): array
{
$descriptors = [];
foreach ($entries as $entry) {
foreach ($entry['subject_type_keys'] as $subjectTypeKey) {
$result = $this->normalize(
subjectTypeKey: $subjectTypeKey,
domainKey: $entry['domain_key'],
subjectClass: $entry['subject_class'],
legacyPolicyType: $subjectTypeKey,
sourceSurface: 'baseline_scope',
);
$descriptors[] = $result->toArray();
}
}
return $descriptors;
}
private function buildResult(
?GovernanceSubjectType $subjectType,
string $sourceSurface,
?string $explicitDomainKey = null,
?string $explicitSubjectClass = null,
?string $legacyPolicyType = null,
bool $usedLegacyAlias = false,
): SubjectDescriptorNormalizationResult {
$platformNoun = $this->glossary->term('governed_subject')?->canonicalLabel ?? 'Governed subject';
$warnings = [];
if (! $subjectType instanceof GovernanceSubjectType) {
$warnings[] = 'Governed-subject descriptor fell back to compatibility-only naming because the subject type could not be resolved in the taxonomy registry.';
return new SubjectDescriptorNormalizationResult(
descriptor: new PlatformSubjectDescriptor(
domainKey: $explicitDomainKey ?? GovernanceDomainKey::Intune->value,
subjectClass: $explicitSubjectClass ?? GovernanceSubjectClass::Policy->value,
subjectTypeKey: $legacyPolicyType ?? 'unknown_subject',
subjectTypeLabel: Str::headline($legacyPolicyType ?? 'Unknown subject'),
platformNoun: $platformNoun,
displayLabel: Str::headline($legacyPolicyType ?? 'Unknown subject'),
legacyPolicyType: $legacyPolicyType,
),
sourceSurface: $sourceSurface,
usedLegacyAlias: true,
warnings: $warnings,
);
}
if ($usedLegacyAlias) {
$warnings[] = sprintf(
'Resolved legacy subject alias "%s" through the governed-subject taxonomy registry for %s.',
$legacyPolicyType ?? $subjectType->subjectTypeKey,
$sourceSurface,
);
}
return new SubjectDescriptorNormalizationResult(
descriptor: new PlatformSubjectDescriptor(
domainKey: $subjectType->domainKey->value,
subjectClass: $subjectType->subjectClass->value,
subjectTypeKey: $subjectType->subjectTypeKey,
subjectTypeLabel: $subjectType->label,
platformNoun: $platformNoun,
displayLabel: $subjectType->label,
legacyPolicyType: $legacyPolicyType,
),
sourceSurface: $sourceSurface,
usedLegacyAlias: $usedLegacyAlias,
warnings: $warnings,
);
}
private function stringValue(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$trimmed = trim($value);
return $trimmed !== '' ? $trimmed : null;
}
}

View File

@ -0,0 +1,570 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationCatalog;
use App\Support\Providers\ProviderReasonCodes;
final class PlatformVocabularyGlossary
{
public const string BOUNDARY_PLATFORM_CORE = 'platform_core';
public const string BOUNDARY_CROSS_DOMAIN_GOVERNANCE = 'cross_domain_governance';
public const string BOUNDARY_INTUNE_SPECIFIC = 'intune_specific';
public const string OWNER_PLATFORM_CORE = 'platform_core';
public const string OWNER_DOMAIN_OWNED = 'domain_owned';
public const string OWNER_PROVIDER_OWNED = 'provider_owned';
public const string OWNER_COMPATIBILITY_ALIAS = 'compatibility_alias';
public const string OWNER_COMPATIBILITY_ONLY = 'compatibility_only';
/**
* @return list<PlatformVocabularyTerm>
*/
public function terms(): array
{
return array_values(array_map(
static fn (array $term): PlatformVocabularyTerm => PlatformVocabularyTerm::fromArray($term),
$this->configuredTerms(),
));
}
public function term(string $term): ?PlatformVocabularyTerm
{
$normalized = trim(mb_strtolower($term));
foreach ($this->terms() as $candidate) {
if (trim(mb_strtolower($candidate->termKey)) === $normalized) {
return $candidate;
}
}
return $this->resolveAlias($term);
}
public function resolveAlias(string $term, ?string $context = null): ?PlatformVocabularyTerm
{
$normalized = trim(mb_strtolower($term));
$normalizedContext = is_string($context) ? trim(mb_strtolower($context)) : null;
foreach ($this->terms() as $candidate) {
$aliases = array_map(
static fn (string $alias): string => trim(mb_strtolower($alias)),
$candidate->legacyAliases,
);
if (! in_array($normalized, $aliases, true)) {
continue;
}
if ($normalizedContext !== null && $candidate->allowedContexts !== []) {
$contexts = array_map(
static fn (string $allowedContext): string => trim(mb_strtolower($allowedContext)),
$candidate->allowedContexts,
);
if (! in_array($normalizedContext, $contexts, true)) {
continue;
}
}
return $candidate;
}
return null;
}
public function canonicalName(string $term): ?string
{
return $this->term($term)?->termKey;
}
public function isCanonical(string $term): bool
{
$resolved = $this->term($term);
if (! $resolved instanceof PlatformVocabularyTerm) {
return false;
}
return trim(mb_strtolower($term)) === trim(mb_strtolower($resolved->termKey));
}
public function ownership(string $term): ?string
{
return $this->term($term)?->ownerLayer;
}
/**
* @return list<string>
*/
public function canonicalTerms(): array
{
return array_values(array_map(
static fn (PlatformVocabularyTerm $term): string => $term->termKey,
$this->terms(),
));
}
/**
* @return array<string, array{
* term_key: string,
* canonical_label: string,
* canonical_description: string,
* boundary_classification: string,
* owner_layer: string,
* allowed_contexts: list<string>,
* legacy_aliases: list<string>,
* alias_retirement_path: ?string,
* forbidden_platform_aliases: list<string>
* }>
*/
public function termInventory(): array
{
return collect($this->terms())
->mapWithKeys(static fn (PlatformVocabularyTerm $term): array => [
$term->termKey => $term->toArray(),
])
->all();
}
/**
* @return array<string, list<string>>
*/
public function legacyAliases(): array
{
return collect($this->terms())
->filter(static fn (PlatformVocabularyTerm $term): bool => $term->legacyAliases !== [])
->mapWithKeys(static fn (PlatformVocabularyTerm $term): array => [
$term->termKey => $term->legacyAliases,
])
->all();
}
/**
* @return array<string, array{
* canonical_name: string,
* legacy_aliases: list<string>,
* retirement_path: ?string,
* forbidden_platform_aliases: list<string>
* }>
*/
public function aliasRetirementInventory(): array
{
return collect($this->terms())
->filter(static fn (PlatformVocabularyTerm $term): bool => $term->legacyAliases !== [])
->mapWithKeys(static fn (PlatformVocabularyTerm $term): array => [
$term->termKey => [
'canonical_name' => $term->termKey,
'legacy_aliases' => $term->legacyAliases,
'retirement_path' => $term->aliasRetirementPath,
'forbidden_platform_aliases' => $term->forbiddenPlatformAliases,
],
])
->all();
}
public function boundaryClassification(string $term): ?string
{
return $this->term($term)?->boundaryClassification;
}
/**
* @return list<string>
*/
public function allowedBoundaryClassifications(): array
{
return [
self::BOUNDARY_PLATFORM_CORE,
self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
self::BOUNDARY_INTUNE_SPECIFIC,
];
}
/**
* @return list<RegistryOwnershipDescriptor>
*/
public function registries(): array
{
return array_values(array_map(
static fn (array $descriptor): RegistryOwnershipDescriptor => RegistryOwnershipDescriptor::fromArray($descriptor),
$this->configuredRegistries(),
));
}
public function registry(string $registryKey): ?RegistryOwnershipDescriptor
{
$normalized = trim(mb_strtolower($registryKey));
foreach ($this->registries() as $descriptor) {
if (trim(mb_strtolower($descriptor->registryKey)) === $normalized) {
return $descriptor;
}
}
return null;
}
/**
* @return array<string, array{
* registry_key: string,
* boundary_classification: string,
* owner_layer: string,
* source_class_or_file: string,
* canonical_nouns: list<string>,
* allowed_consumers: list<string>,
* compatibility_notes: ?string
* }>
*/
public function registryInventory(): array
{
return collect($this->registries())
->mapWithKeys(static fn (RegistryOwnershipDescriptor $descriptor): array => [
$descriptor->registryKey => $descriptor->toArray(),
])
->all();
}
/**
* @return array<string, array{
* owner_namespace: string,
* boundary_classification: string,
* owner_layer: string,
* compatibility_notes: ?string
* }>
*/
public function reasonNamespaceInventory(): array
{
return $this->configuredReasonNamespaces();
}
/**
* @return array{
* owner_namespace: string,
* boundary_classification: string,
* owner_layer: string,
* compatibility_notes: ?string
* }|null
*/
public function reasonNamespace(string $ownerNamespace): ?array
{
$normalized = trim(mb_strtolower($ownerNamespace));
foreach ($this->reasonNamespaceInventory() as $descriptor) {
$candidate = is_string($descriptor['owner_namespace'] ?? null)
? trim(mb_strtolower((string) $descriptor['owner_namespace']))
: null;
if ($candidate === $normalized) {
return $descriptor;
}
}
return null;
}
public function classifyReasonNamespace(string $ownerNamespace): ?string
{
return $this->reasonNamespace($ownerNamespace)['boundary_classification'] ?? null;
}
public function classifyOperationType(string $operationType): ?string
{
if (trim($operationType) === '') {
return null;
}
return $this->registry('operation_catalog')?->boundaryClassification;
}
/**
* @return array<string, array<string, mixed>>
*/
private function configuredTerms(): array
{
$configured = config('tenantpilot.platform_vocabulary.terms');
if (is_array($configured) && $configured !== []) {
return $configured;
}
return [
'governed_subject' => [
'term_key' => 'governed_subject',
'canonical_label' => 'Governed subject',
'canonical_description' => 'The platform-facing noun for a governed object across compare, snapshot, evidence, and review surfaces.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['compare', 'snapshot', 'evidence', 'review', 'reporting'],
'legacy_aliases' => ['policy_type'],
'alias_retirement_path' => 'Retire false-universal policy_type wording from platform-owned summaries and descriptors while preserving Intune-owned storage.',
'forbidden_platform_aliases' => ['policy_type'],
],
'domain_key' => [
'term_key' => 'domain_key',
'canonical_label' => 'Governance domain',
'canonical_description' => 'The canonical domain discriminator for cross-domain and platform-near contracts.',
'boundary_classification' => self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['governance', 'compare', 'snapshot', 'reporting'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'subject_class' => [
'term_key' => 'subject_class',
'canonical_label' => 'Subject class',
'canonical_description' => 'The canonical subject-class discriminator for governed-subject contracts.',
'boundary_classification' => self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['governance', 'compare', 'snapshot'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'subject_type_key' => [
'term_key' => 'subject_type_key',
'canonical_label' => 'Governed subject key',
'canonical_description' => 'The domain-owned subject-family key used by platform-near descriptors.',
'boundary_classification' => self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['governance', 'compare', 'snapshot', 'evidence'],
'legacy_aliases' => ['policy_type'],
'alias_retirement_path' => 'Prefer subject_type_key on platform-near payloads and keep policy_type only where the owning model is Intune-specific.',
'forbidden_platform_aliases' => ['policy_type'],
],
'subject_type_label' => [
'term_key' => 'subject_type_label',
'canonical_label' => 'Governed subject label',
'canonical_description' => 'The operator-facing label for a governed subject family.',
'boundary_classification' => self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['snapshot', 'evidence', 'compare', 'review'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'resource_type' => [
'term_key' => 'resource_type',
'canonical_label' => 'Resource type',
'canonical_description' => 'Optional resource-shaped noun for platform-facing summaries when a governed subject also needs a resource family label.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['reporting', 'review'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'operation_type' => [
'term_key' => 'operation_type',
'canonical_label' => 'Operation type',
'canonical_description' => 'The canonical platform operation identifier shown on monitoring and launch surfaces.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['monitoring', 'reporting', 'launch_surfaces'],
'legacy_aliases' => ['type'],
'alias_retirement_path' => 'Expose canonical operation_type on read models while operation_runs.type remains a compatibility storage seam during rollout.',
'forbidden_platform_aliases' => [],
],
'platform_reason_family' => [
'term_key' => 'platform_reason_family',
'canonical_label' => 'Platform reason family',
'canonical_description' => 'The cross-domain platform family that explains the top-level cause category without erasing domain ownership.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['reason_translation', 'monitoring', 'review', 'reporting'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'reason_owner.owner_namespace' => [
'term_key' => 'reason_owner.owner_namespace',
'canonical_label' => 'Reason owner namespace',
'canonical_description' => 'The explicit namespace that marks whether a translated reason is provider-owned, governance-owned, access-owned, or runtime-owned.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['reason_translation', 'review', 'reporting'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'reason_code' => [
'term_key' => 'reason_code',
'canonical_label' => 'Reason code',
'canonical_description' => 'The original domain-owned or provider-owned reason identifier preserved for diagnostics and translation lookup.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['reason_translation', 'diagnostics'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'registry_key' => [
'term_key' => 'registry_key',
'canonical_label' => 'Registry key',
'canonical_description' => 'The stable identifier for a contributor-facing registry or catalog.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['governance', 'contributor_guidance'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'boundary_classification' => [
'term_key' => 'boundary_classification',
'canonical_label' => 'Boundary classification',
'canonical_description' => 'The explicit classification that marks a term or registry as platform_core, cross_domain_governance, or intune_specific.',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'allowed_contexts' => ['governance', 'contributor_guidance'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'policy_type' => [
'term_key' => 'policy_type',
'canonical_label' => 'Intune policy type',
'canonical_description' => 'The Intune-specific discriminator that remains valid on adapter-owned or Intune-owned models but must not be treated as a universal platform noun.',
'boundary_classification' => self::BOUNDARY_INTUNE_SPECIFIC,
'owner_layer' => self::OWNER_DOMAIN_OWNED,
'allowed_contexts' => ['intune_adapter', 'intune_inventory', 'intune_backup'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => ['governed_subject'],
],
];
}
/**
* @return array<string, array<string, mixed>>
*/
private function configuredRegistries(): array
{
$configured = config('tenantpilot.platform_vocabulary.registries');
if (is_array($configured) && $configured !== []) {
return $configured;
}
return [
'governance_subject_taxonomy_registry' => [
'registry_key' => 'governance_subject_taxonomy_registry',
'boundary_classification' => self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'source_class_or_file' => GovernanceSubjectTaxonomyRegistry::class,
'canonical_nouns' => ['domain_key', 'subject_class', 'subject_type_key', 'subject_type_label'],
'allowed_consumers' => ['baseline_scope', 'compare', 'snapshot', 'review'],
'compatibility_notes' => 'Provides the governed-subject source of truth, resolves legacy policy-type payloads, and preserves inactive or future-domain entries without exposing them as active operator choices.',
],
'operation_catalog' => [
'registry_key' => 'operation_catalog',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'source_class_or_file' => OperationCatalog::class,
'canonical_nouns' => ['operation_type'],
'allowed_consumers' => ['monitoring', 'reporting', 'launch_surfaces', 'audit'],
'compatibility_notes' => 'Resolves canonical operation meaning from historical storage values without treating every stored raw string as equally canonical.',
],
'provider_reason_codes' => [
'registry_key' => 'provider_reason_codes',
'boundary_classification' => self::BOUNDARY_INTUNE_SPECIFIC,
'owner_layer' => self::OWNER_PROVIDER_OWNED,
'source_class_or_file' => ProviderReasonCodes::class,
'canonical_nouns' => ['reason_code'],
'allowed_consumers' => ['reason_translation'],
'compatibility_notes' => 'Provider-owned reason codes remain namespaced domain details and become platform-safe only through the reason-translation boundary.',
],
'inventory_policy_type_catalog' => [
'registry_key' => 'inventory_policy_type_catalog',
'boundary_classification' => self::BOUNDARY_INTUNE_SPECIFIC,
'owner_layer' => self::OWNER_DOMAIN_OWNED,
'source_class_or_file' => InventoryPolicyTypeMeta::class,
'canonical_nouns' => ['policy_type'],
'allowed_consumers' => ['intune_adapter', 'inventory', 'backup'],
'compatibility_notes' => 'The supported Intune policy-type list is not a universal platform registry and must be wrapped before reuse on platform-near summaries.',
],
];
}
/**
* @return array<string, array{
* owner_namespace: string,
* boundary_classification: string,
* owner_layer: string,
* compatibility_notes: ?string
* }>
*/
private function configuredReasonNamespaces(): array
{
$configured = config('tenantpilot.platform_vocabulary.reason_namespaces');
if (is_array($configured) && $configured !== []) {
return $configured;
}
return [
'tenant_operability' => [
'owner_namespace' => 'tenant_operability',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'compatibility_notes' => 'Tenant operability reasons are platform-core guardrails for workspace and tenant context.',
],
'execution_denial' => [
'owner_namespace' => 'execution_denial',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'compatibility_notes' => 'Execution denial reasons remain platform-core run-legitimacy semantics.',
],
'operation_lifecycle' => [
'owner_namespace' => 'operation_lifecycle',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'compatibility_notes' => 'Lifecycle reconciliation reasons remain platform-core monitoring semantics.',
],
'governance.baseline_compare' => [
'owner_namespace' => 'governance.baseline_compare',
'boundary_classification' => self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => self::OWNER_DOMAIN_OWNED,
'compatibility_notes' => 'Baseline-compare reason codes are governance-owned details translated through the platform boundary.',
],
'governance.artifact_truth' => [
'owner_namespace' => 'governance.artifact_truth',
'boundary_classification' => self::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
'owner_layer' => self::OWNER_DOMAIN_OWNED,
'compatibility_notes' => 'Artifact-truth reason codes remain governance-owned and are surfaced through platform-safe summaries.',
],
'provider.microsoft_graph' => [
'owner_namespace' => 'provider.microsoft_graph',
'boundary_classification' => self::BOUNDARY_INTUNE_SPECIFIC,
'owner_layer' => self::OWNER_PROVIDER_OWNED,
'compatibility_notes' => 'Microsoft Graph provider reasons remain provider-owned and Intune-specific.',
],
'provider.intune_rbac' => [
'owner_namespace' => 'provider.intune_rbac',
'boundary_classification' => self::BOUNDARY_INTUNE_SPECIFIC,
'owner_layer' => self::OWNER_PROVIDER_OWNED,
'compatibility_notes' => 'Provider-owned Intune RBAC reasons remain Intune-specific.',
],
'rbac.intune' => [
'owner_namespace' => 'rbac.intune',
'boundary_classification' => self::BOUNDARY_INTUNE_SPECIFIC,
'owner_layer' => self::OWNER_DOMAIN_OWNED,
'compatibility_notes' => 'RBAC detail remains Intune-specific domain context.',
],
'reason_translation.fallback' => [
'owner_namespace' => 'reason_translation.fallback',
'boundary_classification' => self::BOUNDARY_PLATFORM_CORE,
'owner_layer' => self::OWNER_PLATFORM_CORE,
'compatibility_notes' => 'Fallback translation remains a platform-core compatibility seam until a domain owner is known.',
],
];
}
}

View File

@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
use InvalidArgumentException;
final readonly class PlatformVocabularyTerm
{
/**
* @param list<string> $allowedContexts
* @param list<string> $legacyAliases
* @param list<string> $forbiddenPlatformAliases
*/
public function __construct(
public string $termKey,
public string $canonicalLabel,
public string $canonicalDescription,
public string $boundaryClassification,
public string $ownerLayer,
public array $allowedContexts = [],
public array $legacyAliases = [],
public ?string $aliasRetirementPath = null,
public array $forbiddenPlatformAliases = [],
) {
if (trim($this->termKey) === '' || trim($this->canonicalLabel) === '' || trim($this->canonicalDescription) === '') {
throw new InvalidArgumentException('Platform vocabulary terms require a key, label, and description.');
}
if ($this->legacyAliases !== [] && blank($this->aliasRetirementPath)) {
throw new InvalidArgumentException('Platform vocabulary terms with legacy aliases must declare an alias retirement path.');
}
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
return new self(
termKey: (string) ($data['term_key'] ?? ''),
canonicalLabel: (string) ($data['canonical_label'] ?? ''),
canonicalDescription: (string) ($data['canonical_description'] ?? ''),
boundaryClassification: (string) ($data['boundary_classification'] ?? ''),
ownerLayer: (string) ($data['owner_layer'] ?? ''),
allowedContexts: self::stringList($data['allowed_contexts'] ?? []),
legacyAliases: self::stringList($data['legacy_aliases'] ?? []),
aliasRetirementPath: is_string($data['alias_retirement_path'] ?? null)
? trim((string) $data['alias_retirement_path'])
: null,
forbiddenPlatformAliases: self::stringList($data['forbidden_platform_aliases'] ?? []),
);
}
public function matches(string $term): bool
{
$normalized = trim(mb_strtolower($term));
if ($normalized === '') {
return false;
}
return in_array($normalized, array_map(
static fn (string $candidate): string => trim(mb_strtolower($candidate)),
array_merge([$this->termKey], $this->legacyAliases),
), true);
}
/**
* @return array{
* term_key: string,
* canonical_label: string,
* canonical_description: string,
* boundary_classification: string,
* owner_layer: string,
* allowed_contexts: list<string>,
* legacy_aliases: list<string>,
* alias_retirement_path: ?string,
* forbidden_platform_aliases: list<string>
* }
*/
public function toArray(): array
{
return [
'term_key' => $this->termKey,
'canonical_label' => $this->canonicalLabel,
'canonical_description' => $this->canonicalDescription,
'boundary_classification' => $this->boundaryClassification,
'owner_layer' => $this->ownerLayer,
'allowed_contexts' => $this->allowedContexts,
'legacy_aliases' => $this->legacyAliases,
'alias_retirement_path' => $this->aliasRetirementPath,
'forbidden_platform_aliases' => $this->forbiddenPlatformAliases,
];
}
/**
* @param iterable<mixed> $values
* @return list<string>
*/
private static function stringList(iterable $values): array
{
return collect($values)
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->map(static fn (string $value): string => trim($value))
->values()
->all();
}
}

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
use InvalidArgumentException;
final readonly class RegistryOwnershipDescriptor
{
/**
* @param list<string> $canonicalNouns
* @param list<string> $allowedConsumers
*/
public function __construct(
public string $registryKey,
public string $boundaryClassification,
public string $ownerLayer,
public string $sourceClassOrFile,
public array $canonicalNouns,
public array $allowedConsumers,
public ?string $compatibilityNotes = null,
) {
if (trim($this->registryKey) === '' || trim($this->sourceClassOrFile) === '') {
throw new InvalidArgumentException('Registry ownership descriptors require a registry key and source reference.');
}
if ($this->canonicalNouns === [] || $this->allowedConsumers === []) {
throw new InvalidArgumentException('Registry ownership descriptors require canonical nouns and allowed consumers.');
}
}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): self
{
return new self(
registryKey: (string) ($data['registry_key'] ?? ''),
boundaryClassification: (string) ($data['boundary_classification'] ?? ''),
ownerLayer: (string) ($data['owner_layer'] ?? ''),
sourceClassOrFile: (string) ($data['source_class_or_file'] ?? ''),
canonicalNouns: self::stringList($data['canonical_nouns'] ?? []),
allowedConsumers: self::stringList($data['allowed_consumers'] ?? []),
compatibilityNotes: is_string($data['compatibility_notes'] ?? null)
? trim((string) $data['compatibility_notes'])
: null,
);
}
/**
* @return array{
* registry_key: string,
* boundary_classification: string,
* owner_layer: string,
* source_class_or_file: string,
* canonical_nouns: list<string>,
* allowed_consumers: list<string>,
* compatibility_notes: ?string
* }
*/
public function toArray(): array
{
return [
'registry_key' => $this->registryKey,
'boundary_classification' => $this->boundaryClassification,
'owner_layer' => $this->ownerLayer,
'source_class_or_file' => $this->sourceClassOrFile,
'canonical_nouns' => $this->canonicalNouns,
'allowed_consumers' => $this->allowedConsumers,
'compatibility_notes' => $this->compatibilityNotes,
];
}
/**
* @param iterable<mixed> $values
* @return list<string>
*/
private static function stringList(iterable $values): array
{
return collect($values)
->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->map(static fn (string $value): string => trim($value))
->values()
->all();
}
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Support\Governance;
final readonly class SubjectDescriptorNormalizationResult
{
/**
* @param list<string> $warnings
*/
public function __construct(
public PlatformSubjectDescriptor $descriptor,
public string $sourceSurface,
public bool $usedLegacyAlias = false,
public array $warnings = [],
) {}
/**
* @return array{
* descriptor: array{
* domain_key: string,
* subject_class: string,
* subject_type_key: string,
* subject_type_label: string,
* platform_noun: string,
* display_label: string,
* legacy_policy_type: ?string,
* owner_layer: string
* },
* source_surface: string,
* used_legacy_alias: bool,
* warnings: list<string>
* }
*/
public function toArray(): array
{
return [
'descriptor' => $this->descriptor->toArray(),
'source_surface' => $this->sourceSurface,
'used_legacy_alias' => $this->usedLegacyAlias,
'warnings' => $this->warnings,
];
}
}

View File

@ -1,7 +1,11 @@
<?php <?php
declare(strict_types=1);
namespace App\Support; namespace App\Support;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\Governance\RegistryOwnershipDescriptor;
use App\Support\OpsUx\OperationSummaryKeys; use App\Support\OpsUx\OperationSummaryKeys;
final class OperationCatalog final class OperationCatalog
@ -13,51 +17,34 @@ final class OperationCatalog
*/ */
public static function labels(): array public static function labels(): array
{ {
return [ $labels = [];
'policy.sync' => 'Policy sync',
'policy.sync_one' => 'Policy sync', foreach (self::operationAliases() as $alias) {
'policy.capture_snapshot' => 'Policy snapshot', $labels[$alias->rawValue] = self::canonicalDefinitions()[$alias->canonicalCode]->displayLabel;
'policy.delete' => 'Delete policies', }
'policy.unignore' => 'Restore policies',
'policy.export' => 'Export policies to backup', return $labels;
'provider.connection.check' => 'Provider connection check', }
'inventory_sync' => 'Inventory sync',
'compliance.snapshot' => 'Compliance snapshot', /**
'provider.inventory.sync' => 'Inventory sync', * @return array<string, array{
'provider.compliance.snapshot' => 'Compliance snapshot', * canonical_code: string,
'entra_group_sync' => 'Directory groups sync', * domain_key: ?string,
'backup_set.add_policies' => 'Backup set update', * artifact_family: ?string,
'backup_set.remove_policies' => 'Backup set update', * display_label: string,
'backup_set.delete' => 'Archive backup sets', * supports_operator_explanation: bool,
'backup_set.restore' => 'Restore backup sets', * expected_duration_seconds: ?int
'backup_set.force_delete' => 'Delete backup sets', * }>
'backup_schedule_run' => 'Backup schedule run', */
'backup_schedule_retention' => 'Backup schedule retention', public static function canonicalInventory(): array
'backup_schedule_purge' => 'Backup schedule purge', {
'restore.execute' => 'Restore execution', $inventory = [];
'assignments.fetch' => 'Assignment fetch',
'assignments.restore' => 'Assignment restore', foreach (self::canonicalDefinitions() as $canonicalCode => $definition) {
'ops.reconcile_adapter_runs' => 'Reconcile adapter runs', $inventory[$canonicalCode] = $definition->toArray();
'directory_role_definitions.sync' => 'Role definitions sync', }
'restore_run.delete' => 'Delete restore runs',
'restore_run.restore' => 'Restore restore runs', return $inventory;
'restore_run.force_delete' => 'Force delete restore runs',
'tenant.sync' => 'Tenant sync',
'policy_version.prune' => 'Prune policy versions',
'policy_version.restore' => 'Restore policy versions',
'policy_version.force_delete' => 'Delete policy versions',
'alerts.evaluate' => 'Alerts evaluation',
'alerts.deliver' => 'Alerts delivery',
'baseline_capture' => 'Baseline capture',
'baseline_compare' => 'Baseline compare',
'permission_posture_check' => 'Permission posture check',
'entra.admin_roles.scan' => 'Entra admin roles scan',
'tenant.review_pack.generate' => 'Review pack generation',
'tenant.review.compose' => 'Review composition',
'tenant.evidence.snapshot.generate' => 'Evidence snapshot generation',
'rbac.health_check' => 'RBAC health check',
'findings.lifecycle.backfill' => 'Findings lifecycle backfill',
];
} }
public static function label(string $operationType): string public static function label(string $operationType): string
@ -68,34 +55,12 @@ public static function label(string $operationType): string
return 'Operation'; return 'Operation';
} }
return self::labels()[$operationType] ?? 'Unknown operation'; return self::resolve($operationType)->canonical->displayLabel;
} }
public static function expectedDurationSeconds(string $operationType): ?int public static function expectedDurationSeconds(string $operationType): ?int
{ {
return match (trim($operationType)) { return self::resolve($operationType)->canonical->expectedDurationSeconds;
'policy.sync', 'policy.sync_one' => 90,
'provider.connection.check' => 30,
'policy.export' => 120,
'inventory_sync' => 180,
'compliance.snapshot' => 180,
'provider.inventory.sync' => 180,
'provider.compliance.snapshot' => 180,
'entra_group_sync' => 120,
'assignments.fetch', 'assignments.restore' => 60,
'ops.reconcile_adapter_runs' => 120,
'alerts.evaluate', 'alerts.deliver' => 120,
'baseline_capture' => 120,
'baseline_compare' => 120,
'permission_posture_check' => 30,
'entra.admin_roles.scan' => 60,
'tenant.review_pack.generate' => 60,
'tenant.review.compose' => 60,
'tenant.evidence.snapshot.generate' => 120,
'rbac.health_check' => 30,
'findings.lifecycle.backfill' => 300,
default => null,
};
} }
/** /**
@ -108,13 +73,7 @@ public static function allowedSummaryKeys(): array
public static function governanceArtifactFamily(string $operationType): ?string public static function governanceArtifactFamily(string $operationType): ?string
{ {
return match (trim($operationType)) { return self::resolve($operationType)->canonical->artifactFamily;
'baseline_capture' => 'baseline_snapshot',
'tenant.evidence.snapshot.generate' => 'evidence_snapshot',
'tenant.review.compose' => 'tenant_review',
'tenant.review_pack.generate' => 'review_pack',
default => null,
};
} }
public static function isGovernanceArtifactOperation(string $operationType): bool public static function isGovernanceArtifactOperation(string $operationType): bool
@ -124,9 +83,227 @@ public static function isGovernanceArtifactOperation(string $operationType): boo
public static function supportsOperatorExplanation(string $operationType): bool public static function supportsOperatorExplanation(string $operationType): bool
{ {
$operationType = trim($operationType); return self::resolve($operationType)->canonical->supportsOperatorExplanation;
}
return self::isGovernanceArtifactOperation($operationType) public static function canonicalCode(string $operationType): string
|| $operationType === 'baseline_compare'; {
return self::resolve($operationType)->canonical->canonicalCode;
}
/**
* @return list<string>
*/
public static function canonicalNouns(): array
{
return ['operation_type'];
}
public static function ownershipDescriptor(?PlatformVocabularyGlossary $glossary = null): RegistryOwnershipDescriptor
{
$glossary ??= app(PlatformVocabularyGlossary::class);
return $glossary->registry('operation_catalog')
?? RegistryOwnershipDescriptor::fromArray([
'registry_key' => 'operation_catalog',
'boundary_classification' => PlatformVocabularyGlossary::BOUNDARY_PLATFORM_CORE,
'owner_layer' => PlatformVocabularyGlossary::OWNER_PLATFORM_CORE,
'source_class_or_file' => self::class,
'canonical_nouns' => self::canonicalNouns(),
'allowed_consumers' => ['monitoring', 'reporting', 'launch_surfaces', 'audit'],
'compatibility_notes' => 'Resolves canonical operation meaning from historical storage values without treating every stored raw string as equally canonical.',
]);
}
public static function boundaryClassification(?PlatformVocabularyGlossary $glossary = null): string
{
return self::ownershipDescriptor($glossary)->boundaryClassification;
}
/**
* @return list<string>
*/
public static function rawValuesForCanonical(string $canonicalCode): array
{
return array_values(array_map(
static fn (OperationTypeAlias $alias): string => $alias->rawValue,
array_filter(
self::operationAliases(),
static fn (OperationTypeAlias $alias): bool => $alias->canonicalCode === trim($canonicalCode),
),
));
}
/**
* @param iterable<mixed>|null $types
* @return array<string, string>
*/
public static function filterOptions(?iterable $types = null): array
{
$values = collect($types ?? array_keys(self::labels()))
->filter(static fn (mixed $type): bool => is_string($type) && trim($type) !== '')
->map(static fn (string $type): string => trim($type))
->mapWithKeys(static fn (string $type): array => [self::canonicalCode($type) => self::label($type)])
->sortBy(static fn (string $label): string => $label)
->all();
return $values;
}
/**
* @return array<string, array{
* raw_value: string,
* canonical_name: string,
* alias_status: string,
* write_allowed: bool,
* deprecation_note: ?string,
* retirement_path: ?string
* }>
*/
public static function aliasInventory(): array
{
$inventory = [];
foreach (self::operationAliases() as $alias) {
$inventory[$alias->rawValue] = $alias->retirementMetadata();
}
return $inventory;
}
public static function resolve(string $operationType): OperationTypeResolution
{
$operationType = trim($operationType);
$aliases = self::operationAliases();
$matchedAlias = collect($aliases)
->first(static fn (OperationTypeAlias $alias): bool => $alias->rawValue === $operationType);
if ($matchedAlias instanceof OperationTypeAlias) {
return new OperationTypeResolution(
rawValue: $operationType,
canonical: self::canonicalDefinitions()[$matchedAlias->canonicalCode],
aliasesConsidered: array_values(array_filter(
$aliases,
static fn (OperationTypeAlias $alias): bool => $alias->canonicalCode === $matchedAlias->canonicalCode,
)),
aliasStatus: $matchedAlias->aliasStatus,
wasLegacyAlias: $matchedAlias->aliasStatus !== 'canonical',
);
}
return new OperationTypeResolution(
rawValue: $operationType,
canonical: new CanonicalOperationType(
canonicalCode: $operationType,
domainKey: null,
artifactFamily: null,
displayLabel: 'Unknown operation',
supportsOperatorExplanation: false,
expectedDurationSeconds: null,
),
aliasesConsidered: [],
aliasStatus: 'unknown',
wasLegacyAlias: false,
);
}
/**
* @return array<string, CanonicalOperationType>
*/
private static function canonicalDefinitions(): array
{
return [
'policy.sync' => new CanonicalOperationType('policy.sync', 'intune', null, 'Policy sync', false, 90),
'policy.snapshot' => new CanonicalOperationType('policy.snapshot', 'intune', null, 'Policy snapshot', false, 120),
'policy.delete' => new CanonicalOperationType('policy.delete', 'intune', null, 'Delete policies'),
'policy.restore' => new CanonicalOperationType('policy.restore', 'intune', null, 'Restore policies'),
'policy.export' => new CanonicalOperationType('policy.export', 'intune', null, 'Export policies to backup', false, 120),
'provider.connection.check' => new CanonicalOperationType('provider.connection.check', 'intune', null, 'Provider connection check', false, 30),
'inventory.sync' => new CanonicalOperationType('inventory.sync', 'intune', null, 'Inventory sync', false, 180),
'compliance.snapshot' => new CanonicalOperationType('compliance.snapshot', 'intune', null, 'Compliance snapshot', false, 180),
'directory.groups.sync' => new CanonicalOperationType('directory.groups.sync', 'entra', null, 'Directory groups sync', false, 120),
'backup_set.update' => new CanonicalOperationType('backup_set.update', 'intune', null, 'Backup set update'),
'backup_set.archive' => new CanonicalOperationType('backup_set.archive', 'intune', null, 'Archive backup sets'),
'backup_set.restore' => new CanonicalOperationType('backup_set.restore', 'intune', null, 'Restore backup sets'),
'backup_set.delete' => new CanonicalOperationType('backup_set.delete', 'intune', null, 'Delete backup sets'),
'backup.schedule.execute' => new CanonicalOperationType('backup.schedule.execute', 'intune', null, 'Backup schedule run'),
'backup.schedule.retention' => new CanonicalOperationType('backup.schedule.retention', 'intune', null, 'Backup schedule retention'),
'backup.schedule.purge' => new CanonicalOperationType('backup.schedule.purge', 'intune', null, 'Backup schedule purge'),
'restore.execute' => new CanonicalOperationType('restore.execute', 'intune', null, 'Restore execution'),
'assignments.fetch' => new CanonicalOperationType('assignments.fetch', 'intune', null, 'Assignment fetch', false, 60),
'assignments.restore' => new CanonicalOperationType('assignments.restore', 'intune', null, 'Assignment restore', false, 60),
'ops.reconcile_adapter_runs' => new CanonicalOperationType('ops.reconcile_adapter_runs', 'platform_foundation', null, 'Reconcile adapter runs', false, 120),
'directory.role_definitions.sync' => new CanonicalOperationType('directory.role_definitions.sync', 'entra', null, 'Role definitions sync'),
'restore_run.delete' => new CanonicalOperationType('restore_run.delete', 'intune', null, 'Delete restore runs'),
'restore_run.restore' => new CanonicalOperationType('restore_run.restore', 'intune', null, 'Restore restore runs'),
'restore_run.force_delete' => new CanonicalOperationType('restore_run.force_delete', 'intune', null, 'Force delete restore runs'),
'tenant.sync' => new CanonicalOperationType('tenant.sync', 'platform_foundation', null, 'Tenant sync'),
'policy_version.prune' => new CanonicalOperationType('policy_version.prune', 'intune', null, 'Prune policy versions'),
'policy_version.restore' => new CanonicalOperationType('policy_version.restore', 'intune', null, 'Restore policy versions'),
'policy_version.force_delete' => new CanonicalOperationType('policy_version.force_delete', 'intune', null, 'Delete policy versions'),
'alerts.evaluate' => new CanonicalOperationType('alerts.evaluate', 'platform_foundation', null, 'Alerts evaluation', false, 120),
'alerts.deliver' => new CanonicalOperationType('alerts.deliver', 'platform_foundation', null, 'Alerts delivery', false, 120),
'baseline.capture' => new CanonicalOperationType('baseline.capture', 'platform_foundation', 'baseline_snapshot', 'Baseline capture', true, 120),
'baseline.compare' => new CanonicalOperationType('baseline.compare', 'platform_foundation', null, 'Baseline compare', true, 120),
'permission.posture.check' => new CanonicalOperationType('permission.posture.check', 'platform_foundation', null, 'Permission posture check', false, 30),
'entra.admin_roles.scan' => new CanonicalOperationType('entra.admin_roles.scan', 'entra', null, 'Entra admin roles scan', false, 60),
'tenant.review_pack.generate' => new CanonicalOperationType('tenant.review_pack.generate', 'platform_foundation', 'review_pack', 'Review pack generation', true, 60),
'tenant.review.compose' => new CanonicalOperationType('tenant.review.compose', 'platform_foundation', 'tenant_review', 'Review composition', true, 60),
'tenant.evidence.snapshot.generate' => new CanonicalOperationType('tenant.evidence.snapshot.generate', 'platform_foundation', 'evidence_snapshot', 'Evidence snapshot generation', true, 120),
'rbac.health_check' => new CanonicalOperationType('rbac.health_check', 'intune', null, 'RBAC health check', false, 30),
'findings.lifecycle.backfill' => new CanonicalOperationType('findings.lifecycle.backfill', 'platform_foundation', null, 'Findings lifecycle backfill', false, 300),
];
}
/**
* @return list<OperationTypeAlias>
*/
private static function operationAliases(): array
{
return [
new OperationTypeAlias('policy.sync', 'policy.sync', 'canonical', true),
new OperationTypeAlias('policy.sync_one', 'policy.sync', 'legacy_alias', true, 'Legacy single-policy sync values resolve to the canonical policy.sync operation.', 'Prefer policy.sync on platform-owned read paths.'),
new OperationTypeAlias('policy.capture_snapshot', 'policy.snapshot', 'canonical', true),
new OperationTypeAlias('policy.delete', 'policy.delete', 'canonical', true),
new OperationTypeAlias('policy.unignore', 'policy.restore', 'legacy_alias', true, 'Legacy policy.unignore values resolve to policy.restore for operator-facing wording.', 'Prefer policy.restore on new platform-owned read models.'),
new OperationTypeAlias('policy.export', 'policy.export', 'canonical', true),
new OperationTypeAlias('provider.connection.check', 'provider.connection.check', 'canonical', true),
new OperationTypeAlias('inventory_sync', 'inventory.sync', 'legacy_alias', true, 'Legacy inventory_sync storage values resolve to the canonical inventory.sync operation.', 'Preserve stored values during rollout while showing inventory.sync semantics on read paths.'),
new OperationTypeAlias('provider.inventory.sync', 'inventory.sync', 'legacy_alias', false, 'Provider-prefixed historical inventory sync values share the same operator meaning as inventory sync.', 'Avoid emitting provider.inventory.sync on new platform-owned surfaces.'),
new OperationTypeAlias('compliance.snapshot', 'compliance.snapshot', 'canonical', true),
new OperationTypeAlias('provider.compliance.snapshot', 'compliance.snapshot', 'legacy_alias', false, 'Provider-prefixed compliance snapshot values resolve to the canonical compliance.snapshot operation.', 'Avoid emitting provider.compliance.snapshot on new platform-owned surfaces.'),
new OperationTypeAlias('entra_group_sync', 'directory.groups.sync', 'legacy_alias', true, 'Historical entra_group_sync values resolve to directory.groups.sync.', 'Prefer directory.groups.sync on new platform-owned read models.'),
new OperationTypeAlias('backup_set.add_policies', 'backup_set.update', 'canonical', true),
new OperationTypeAlias('backup_set.remove_policies', 'backup_set.update', 'legacy_alias', true, 'Removal and addition both resolve to the same backup-set update operator meaning.', 'Use backup_set.update for canonical reporting buckets.'),
new OperationTypeAlias('backup_set.delete', 'backup_set.archive', 'canonical', true),
new OperationTypeAlias('backup_set.restore', 'backup_set.restore', 'canonical', true),
new OperationTypeAlias('backup_set.force_delete', 'backup_set.delete', 'legacy_alias', true, 'Force-delete wording is normalized to the canonical delete label.', 'Use backup_set.delete for new platform-owned summaries.'),
new OperationTypeAlias('backup_schedule_run', 'backup.schedule.execute', 'legacy_alias', true, 'Historical backup_schedule_run values resolve to backup.schedule.execute.', 'Prefer backup.schedule.execute on canonical read paths.'),
new OperationTypeAlias('backup_schedule_retention', 'backup.schedule.retention', 'legacy_alias', true, 'Legacy backup schedule retention values resolve to backup.schedule.retention.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
new OperationTypeAlias('backup_schedule_purge', 'backup.schedule.purge', 'legacy_alias', true, 'Legacy backup schedule purge values resolve to backup.schedule.purge.', 'Prefer dotted canonical backup schedule naming on new read paths.'),
new OperationTypeAlias('restore.execute', 'restore.execute', 'canonical', true),
new OperationTypeAlias('assignments.fetch', 'assignments.fetch', 'canonical', true),
new OperationTypeAlias('assignments.restore', 'assignments.restore', 'canonical', true),
new OperationTypeAlias('ops.reconcile_adapter_runs', 'ops.reconcile_adapter_runs', 'canonical', true),
new OperationTypeAlias('directory_role_definitions.sync', 'directory.role_definitions.sync', 'legacy_alias', true, 'Legacy directory_role_definitions.sync values resolve to directory.role_definitions.sync.', 'Prefer dotted role-definition naming on new read paths.'),
new OperationTypeAlias('restore_run.delete', 'restore_run.delete', 'canonical', true),
new OperationTypeAlias('restore_run.restore', 'restore_run.restore', 'canonical', true),
new OperationTypeAlias('restore_run.force_delete', 'restore_run.force_delete', 'canonical', true),
new OperationTypeAlias('tenant.sync', 'tenant.sync', 'canonical', true),
new OperationTypeAlias('policy_version.prune', 'policy_version.prune', 'canonical', true),
new OperationTypeAlias('policy_version.restore', 'policy_version.restore', 'canonical', true),
new OperationTypeAlias('policy_version.force_delete', 'policy_version.force_delete', 'canonical', true),
new OperationTypeAlias('alerts.evaluate', 'alerts.evaluate', 'canonical', true),
new OperationTypeAlias('alerts.deliver', 'alerts.deliver', 'canonical', true),
new OperationTypeAlias('baseline_capture', 'baseline.capture', 'legacy_alias', true, 'Historical baseline_capture values resolve to baseline.capture.', 'Prefer baseline.capture on canonical read paths.'),
new OperationTypeAlias('baseline_compare', 'baseline.compare', 'legacy_alias', true, 'Historical baseline_compare values resolve to baseline.compare.', 'Prefer baseline.compare on canonical read paths.'),
new OperationTypeAlias('permission_posture_check', 'permission.posture.check', 'legacy_alias', true, 'Historical permission_posture_check values resolve to permission.posture.check.', 'Prefer dotted permission posture naming on new read paths.'),
new OperationTypeAlias('entra.admin_roles.scan', 'entra.admin_roles.scan', 'canonical', true),
new OperationTypeAlias('tenant.review_pack.generate', 'tenant.review_pack.generate', 'canonical', true),
new OperationTypeAlias('tenant.review.compose', 'tenant.review.compose', 'canonical', true),
new OperationTypeAlias('tenant.evidence.snapshot.generate', 'tenant.evidence.snapshot.generate', 'canonical', true),
new OperationTypeAlias('rbac.health_check', 'rbac.health_check', 'canonical', true),
new OperationTypeAlias('findings.lifecycle.backfill', 'findings.lifecycle.backfill', 'canonical', true),
];
} }
} }

View File

@ -27,4 +27,26 @@ public static function values(): array
{ {
return array_map(static fn (self $case): string => $case->value, self::cases()); return array_map(static fn (self $case): string => $case->value, self::cases());
} }
public function canonicalCode(): string
{
return match ($this) {
self::BaselineCapture => 'baseline.capture',
self::BaselineCompare => 'baseline.compare',
self::InventorySync => 'inventory.sync',
self::PolicySync, self::PolicySyncOne => 'policy.sync',
self::DirectoryGroupsSync => 'directory.groups.sync',
self::BackupScheduleExecute => 'backup.schedule.execute',
self::BackupScheduleRetention => 'backup.schedule.retention',
self::BackupSchedulePurge => 'backup.schedule.purge',
self::DirectoryRoleDefinitionsSync => 'directory.role_definitions.sync',
self::RestoreExecute => 'restore.execute',
self::EntraAdminRolesScan => 'entra.admin_roles.scan',
self::ReviewPackGenerate => 'tenant.review_pack.generate',
self::TenantReviewCompose => 'tenant.review.compose',
self::EvidenceSnapshotGenerate => 'tenant.evidence.snapshot.generate',
self::RbacHealthCheck => 'rbac.health_check',
default => $this->value,
};
}
} }

View File

@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Support;
use InvalidArgumentException;
final readonly class OperationTypeAlias
{
public function __construct(
public string $rawValue,
public string $canonicalCode,
public string $aliasStatus,
public bool $writeAllowed,
public ?string $deprecationNote = null,
public ?string $retirementPath = null,
) {
if (trim($this->rawValue) === '' || trim($this->canonicalCode) === '') {
throw new InvalidArgumentException('Operation type aliases require a raw value and canonical code.');
}
}
/**
* @return array{
* raw_value: string,
* canonical_code: string,
* alias_status: string,
* write_allowed: bool,
* deprecation_note: ?string,
* retirement_path: ?string
* }
*/
public function toArray(): array
{
return [
'raw_value' => $this->rawValue,
'canonical_code' => $this->canonicalCode,
'alias_status' => $this->aliasStatus,
'write_allowed' => $this->writeAllowed,
'deprecation_note' => $this->deprecationNote,
'retirement_path' => $this->retirementPath,
];
}
public function canonicalName(): string
{
return $this->canonicalCode;
}
/**
* @return array{
* raw_value: string,
* canonical_name: string,
* alias_status: string,
* write_allowed: bool,
* deprecation_note: ?string,
* retirement_path: ?string
* }
*/
public function retirementMetadata(): array
{
return [
'raw_value' => $this->rawValue,
'canonical_name' => $this->canonicalName(),
'alias_status' => $this->aliasStatus,
'write_allowed' => $this->writeAllowed,
'deprecation_note' => $this->deprecationNote,
'retirement_path' => $this->retirementPath,
];
}
}

View File

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Support;
final readonly class OperationTypeResolution
{
/**
* @param list<OperationTypeAlias> $aliasesConsidered
*/
public function __construct(
public string $rawValue,
public CanonicalOperationType $canonical,
public array $aliasesConsidered,
public string $aliasStatus,
public bool $wasLegacyAlias,
) {}
/**
* @return array{
* raw_value: string,
* canonical: array{
* canonical_code: string,
* domain_key: ?string,
* artifact_family: ?string,
* display_label: string,
* supports_operator_explanation: bool,
* expected_duration_seconds: ?int
* },
* aliases_considered: list<array{
* raw_value: string,
* canonical_code: string,
* alias_status: string,
* write_allowed: bool,
* deprecation_note: ?string,
* retirement_path: ?string
* }>,
* alias_status: string,
* was_legacy_alias: bool
* }
*/
public function toArray(): array
{
return [
'raw_value' => $this->rawValue,
'canonical' => $this->canonical->toArray(),
'aliases_considered' => array_map(
static fn (OperationTypeAlias $alias): array => $alias->toArray(),
$this->aliasesConsidered,
),
'alias_status' => $this->aliasStatus,
'was_legacy_alias' => $this->wasLegacyAlias,
];
}
}

View File

@ -5,6 +5,8 @@
namespace App\Support\Operations; namespace App\Support\Operations;
use App\Support\ReasonTranslation\NextStepOption; use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\PlatformReasonFamily;
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope; use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
enum ExecutionDenialReasonCode: string enum ExecutionDenialReasonCode: string
@ -125,6 +127,12 @@ public function toReasonResolutionEnvelope(string $surface = 'detail', array $co
nextSteps: $this->nextSteps(), nextSteps: $this->nextSteps(),
showNoActionNeeded: false, showNoActionNeeded: false,
diagnosticCodeLabel: $this->value, diagnosticCodeLabel: $this->value,
reasonOwnership: new ReasonOwnershipDescriptor(
ownerLayer: 'platform_core',
ownerNamespace: 'execution_denial',
reasonCode: $this->value,
platformReasonFamily: PlatformReasonFamily::Authorization,
),
); );
} }
} }

View File

@ -5,6 +5,8 @@
namespace App\Support\Operations; namespace App\Support\Operations;
use App\Support\ReasonTranslation\NextStepOption; use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\PlatformReasonFamily;
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope; use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
enum LifecycleReconciliationReason: string enum LifecycleReconciliationReason: string
@ -78,6 +80,12 @@ public function toReasonResolutionEnvelope(string $surface = 'detail', array $co
nextSteps: $this->nextSteps(), nextSteps: $this->nextSteps(),
showNoActionNeeded: false, showNoActionNeeded: false,
diagnosticCodeLabel: $this->value, diagnosticCodeLabel: $this->value,
reasonOwnership: new ReasonOwnershipDescriptor(
ownerLayer: 'platform_core',
ownerNamespace: 'operation_lifecycle',
reasonCode: $this->value,
platformReasonFamily: PlatformReasonFamily::Execution,
),
); );
} }
} }

View File

@ -2,6 +2,10 @@
namespace App\Support\Providers; namespace App\Support\Providers;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\ReasonTranslation\PlatformReasonFamily;
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
final class ProviderReasonCodes final class ProviderReasonCodes
{ {
public const string ProviderConnectionMissing = 'provider_connection_missing'; public const string ProviderConnectionMissing = 'provider_connection_missing';
@ -92,4 +96,65 @@ public static function isKnown(string $reasonCode): bool
{ {
return in_array($reasonCode, self::all(), true) || str_starts_with($reasonCode, 'ext.'); return in_array($reasonCode, self::all(), true) || str_starts_with($reasonCode, 'ext.');
} }
public static function registryKey(): string
{
return 'provider_reason_codes';
}
/**
* @return list<string>
*/
public static function canonicalNouns(): array
{
return ['reason_code'];
}
public static function ownerLayer(string $reasonCode): string
{
return PlatformVocabularyGlossary::OWNER_PROVIDER_OWNED;
}
public static function boundaryClassification(string $reasonCode): string
{
return PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC;
}
public static function ownerNamespace(string $reasonCode): string
{
return str_starts_with($reasonCode, 'intune_rbac.')
? 'provider.intune_rbac'
: 'provider.microsoft_graph';
}
public static function platformReasonFamily(string $reasonCode): PlatformReasonFamily
{
return match ($reasonCode) {
self::ProviderPermissionMissing,
self::ProviderPermissionDenied,
self::IntuneRbacPermissionMissing => PlatformReasonFamily::Authorization,
self::NetworkUnreachable,
self::RateLimited,
self::ProviderPermissionRefreshFailed,
self::ProviderAuthFailed => PlatformReasonFamily::Availability,
self::ProviderConnectionTypeInvalid,
self::TenantTargetMismatch,
self::ProviderConnectionReviewRequired => PlatformReasonFamily::Compatibility,
default => PlatformReasonFamily::Prerequisite,
};
}
public static function ownershipDescriptor(string $reasonCode): ?ReasonOwnershipDescriptor
{
if (! self::isKnown($reasonCode)) {
return null;
}
return new ReasonOwnershipDescriptor(
ownerLayer: self::ownerLayer($reasonCode),
ownerNamespace: self::ownerNamespace($reasonCode),
reasonCode: $reasonCode,
platformReasonFamily: self::platformReasonFamily($reasonCode),
);
}
} }

View File

@ -248,6 +248,7 @@ private function envelope(
nextSteps: $nextSteps, nextSteps: $nextSteps,
showNoActionNeeded: false, showNoActionNeeded: false,
diagnosticCodeLabel: $reasonCode, diagnosticCodeLabel: $reasonCode,
reasonOwnership: ProviderReasonCodes::ownershipDescriptor($reasonCode),
); );
} }

View File

@ -2,7 +2,10 @@
namespace App\Support; namespace App\Support;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\ReasonTranslation\NextStepOption; use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\PlatformReasonFamily;
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope; use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
enum RbacReason: string enum RbacReason: string
@ -60,6 +63,26 @@ public function actionability(): string
}; };
} }
public function ownerLayer(): string
{
return PlatformVocabularyGlossary::OWNER_DOMAIN_OWNED;
}
public function ownerNamespace(): string
{
return 'rbac.intune';
}
public function platformReasonFamily(): PlatformReasonFamily
{
return PlatformReasonFamily::Authorization;
}
public function boundaryClassification(): string
{
return PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC;
}
/** /**
* @return array<int, NextStepOption> * @return array<int, NextStepOption>
*/ */
@ -92,6 +115,12 @@ public function toReasonResolutionEnvelope(string $surface = 'detail', array $co
nextSteps: $this->nextSteps(), nextSteps: $this->nextSteps(),
showNoActionNeeded: $this->actionability() === 'non_actionable', showNoActionNeeded: $this->actionability() === 'non_actionable',
diagnosticCodeLabel: $this->value, diagnosticCodeLabel: $this->value,
reasonOwnership: new ReasonOwnershipDescriptor(
ownerLayer: $this->ownerLayer(),
ownerNamespace: $this->ownerNamespace(),
reasonCode: $this->value,
platformReasonFamily: $this->platformReasonFamily(),
),
); );
} }
} }

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Support\ReasonTranslation;
enum PlatformReasonFamily: string
{
case Authorization = 'authorization';
case Prerequisite = 'prerequisite';
case Compatibility = 'compatibility';
case Coverage = 'coverage';
case Availability = 'availability';
case Execution = 'execution';
public function label(): string
{
return match ($this) {
self::Authorization => 'Authorization',
self::Prerequisite => 'Prerequisite',
self::Compatibility => 'Compatibility',
self::Coverage => 'Coverage',
self::Availability => 'Availability',
self::Execution => 'Execution',
};
}
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Support\ReasonTranslation;
final readonly class ReasonOwnershipDescriptor
{
public function __construct(
public string $ownerLayer,
public string $ownerNamespace,
public string $reasonCode,
public PlatformReasonFamily $platformReasonFamily,
) {}
/**
* @param array<string, mixed> $data
*/
public static function fromArray(array $data): ?self
{
$family = is_string($data['platform_reason_family'] ?? null)
? PlatformReasonFamily::tryFrom((string) $data['platform_reason_family'])
: null;
if (! $family instanceof PlatformReasonFamily) {
return null;
}
$ownerLayer = is_string($data['owner_layer'] ?? null) ? trim((string) $data['owner_layer']) : '';
$ownerNamespace = is_string($data['owner_namespace'] ?? null) ? trim((string) $data['owner_namespace']) : '';
$reasonCode = is_string($data['reason_code'] ?? null) ? trim((string) $data['reason_code']) : '';
if ($ownerLayer === '' || $ownerNamespace === '' || $reasonCode === '') {
return null;
}
return new self(
ownerLayer: $ownerLayer,
ownerNamespace: $ownerNamespace,
reasonCode: $reasonCode,
platformReasonFamily: $family,
);
}
/**
* @return array{
* owner_layer: string,
* owner_namespace: string,
* reason_code: string,
* platform_reason_family: string
* }
*/
public function toArray(): array
{
return [
'owner_layer' => $this->ownerLayer,
'owner_namespace' => $this->ownerNamespace,
'reason_code' => $this->reasonCode,
'platform_reason_family' => $this->platformReasonFamily->value,
];
}
}

View File

@ -13,6 +13,7 @@
use App\Support\Providers\ProviderReasonTranslator; use App\Support\Providers\ProviderReasonTranslator;
use App\Support\RbacReason; use App\Support\RbacReason;
use App\Support\Tenants\TenantOperabilityReasonCode; use App\Support\Tenants\TenantOperabilityReasonCode;
use Illuminate\Support\Str;
final class ReasonPresenter final class ReasonPresenter
{ {
@ -209,6 +210,93 @@ public function trustImpact(?ReasonResolutionEnvelope $envelope): ?string
return $envelope?->trustImpact; return $envelope?->trustImpact;
} }
public function ownerLayer(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->ownerLayer();
}
public function ownerNamespace(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->ownerNamespace();
}
public function platformReasonFamily(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->platformReasonFamily();
}
public function platformReasonFamilyLabel(?ReasonResolutionEnvelope $envelope): ?string
{
return $envelope?->platformReasonFamilyEnum()?->label();
}
public function ownerLabel(?ReasonResolutionEnvelope $envelope): ?string
{
$ownerNamespace = $envelope?->ownerNamespace();
if (! is_string($ownerNamespace) || trim($ownerNamespace) === '') {
return null;
}
return match (true) {
str_starts_with($ownerNamespace, 'provider.') => 'Provider-owned detail',
str_starts_with($ownerNamespace, 'governance.') => 'Governance detail',
$ownerNamespace === 'rbac.intune' => 'Intune RBAC detail',
$ownerNamespace === 'tenant_operability',
$ownerNamespace === 'execution_denial',
$ownerNamespace === 'operation_lifecycle',
$ownerNamespace === 'reason_translation.fallback' => 'Platform core',
default => match ($envelope?->ownerLayer()) {
'provider_owned' => 'Provider-owned detail',
'domain_owned' => 'Domain-owned detail',
'platform_core' => 'Platform core',
'compatibility_alias' => 'Compatibility alias',
'compatibility_only' => 'Compatibility-only detail',
default => Str::of((string) $envelope?->ownerLayer())->replace('_', ' ')->headline()->toString(),
},
};
}
/**
* @return array{
* owner_label: string,
* owner_layer: string,
* owner_namespace: string,
* boundary_classification: ?string,
* boundary_label: ?string,
* family: string,
* family_label: string,
* diagnostic_code: string
* }|null
*/
public function semantics(?ReasonResolutionEnvelope $envelope): ?array
{
if (! $envelope instanceof ReasonResolutionEnvelope) {
return null;
}
$boundary = $this->reasonTranslator->boundaryClassificationForEnvelope($envelope);
$family = $envelope->platformReasonFamilyEnum();
$ownerLabel = $this->ownerLabel($envelope);
if (! is_string($ownerLabel) || $family === null) {
return null;
}
return [
'owner_label' => $ownerLabel,
'owner_layer' => (string) $envelope->ownerLayer(),
'owner_namespace' => (string) $envelope->ownerNamespace(),
'boundary_classification' => $boundary,
'boundary_label' => is_string($boundary)
? Str::of($boundary)->replace('_', ' ')->headline()->toString()
: null,
'family' => $family->value,
'family_label' => $family->label(),
'diagnostic_code' => $envelope->diagnosticCode(),
];
}
public function absencePattern(?ReasonResolutionEnvelope $envelope): ?string public function absencePattern(?ReasonResolutionEnvelope $envelope): ?string
{ {
return $envelope?->absencePattern; return $envelope?->absencePattern;

View File

@ -22,6 +22,7 @@ public function __construct(
public ?string $diagnosticCodeLabel = null, public ?string $diagnosticCodeLabel = null,
public string $trustImpact = TrustworthinessLevel::LimitedConfidence->value, public string $trustImpact = TrustworthinessLevel::LimitedConfidence->value,
public ?string $absencePattern = null, public ?string $absencePattern = null,
public ?ReasonOwnershipDescriptor $reasonOwnership = null,
) { ) {
if (trim($this->internalCode) === '') { if (trim($this->internalCode) === '') {
throw new InvalidArgumentException('Reason envelopes must preserve an internal code.'); throw new InvalidArgumentException('Reason envelopes must preserve an internal code.');
@ -97,6 +98,14 @@ public static function fromArray(array $data): ?self
$absencePattern = is_string($data['absence_pattern'] ?? null) $absencePattern = is_string($data['absence_pattern'] ?? null)
? trim((string) $data['absence_pattern']) ? trim((string) $data['absence_pattern'])
: (is_string($data['absencePattern'] ?? null) ? trim((string) $data['absencePattern']) : null); : (is_string($data['absencePattern'] ?? null) ? trim((string) $data['absencePattern']) : null);
$reasonOwnership = is_array($data['reason_owner'] ?? null)
? ReasonOwnershipDescriptor::fromArray($data['reason_owner'])
: (is_array($data['reasonOwnership'] ?? null) ? ReasonOwnershipDescriptor::fromArray($data['reasonOwnership']) : ReasonOwnershipDescriptor::fromArray([
'owner_layer' => $data['owner_layer'] ?? $data['ownerLayer'] ?? null,
'owner_namespace' => $data['owner_namespace'] ?? $data['ownerNamespace'] ?? null,
'reason_code' => $data['reason_code'] ?? $internalCode,
'platform_reason_family' => $data['platform_reason_family'] ?? $data['platformReasonFamily'] ?? null,
]));
if ($internalCode === '' || $operatorLabel === '' || $shortExplanation === '' || $actionability === '') { if ($internalCode === '' || $operatorLabel === '' || $shortExplanation === '' || $actionability === '') {
return null; return null;
@ -112,6 +121,7 @@ public static function fromArray(array $data): ?self
diagnosticCodeLabel: $diagnosticCodeLabel !== '' ? $diagnosticCodeLabel : null, diagnosticCodeLabel: $diagnosticCodeLabel !== '' ? $diagnosticCodeLabel : null,
trustImpact: $trustImpact !== '' ? $trustImpact : TrustworthinessLevel::LimitedConfidence->value, trustImpact: $trustImpact !== '' ? $trustImpact : TrustworthinessLevel::LimitedConfidence->value,
absencePattern: $absencePattern !== '' ? $absencePattern : null, absencePattern: $absencePattern !== '' ? $absencePattern : null,
reasonOwnership: $reasonOwnership,
); );
} }
@ -130,6 +140,23 @@ public function withNextSteps(array $nextSteps): self
diagnosticCodeLabel: $this->diagnosticCodeLabel, diagnosticCodeLabel: $this->diagnosticCodeLabel,
trustImpact: $this->trustImpact, trustImpact: $this->trustImpact,
absencePattern: $this->absencePattern, absencePattern: $this->absencePattern,
reasonOwnership: $this->reasonOwnership,
);
}
public function withReasonOwnership(?ReasonOwnershipDescriptor $reasonOwnership): self
{
return new self(
internalCode: $this->internalCode,
operatorLabel: $this->operatorLabel,
shortExplanation: $this->shortExplanation,
actionability: $this->actionability,
nextSteps: $this->nextSteps,
showNoActionNeeded: $this->showNoActionNeeded,
diagnosticCodeLabel: $this->diagnosticCodeLabel,
trustImpact: $this->trustImpact,
absencePattern: $this->absencePattern,
reasonOwnership: $reasonOwnership,
); );
} }
@ -181,6 +208,26 @@ public function diagnosticCode(): string
: $this->internalCode; : $this->internalCode;
} }
public function ownerLayer(): ?string
{
return $this->reasonOwnership?->ownerLayer;
}
public function ownerNamespace(): ?string
{
return $this->reasonOwnership?->ownerNamespace;
}
public function platformReasonFamily(): ?string
{
return $this->reasonOwnership?->platformReasonFamily->value;
}
public function platformReasonFamilyEnum(): ?PlatformReasonFamily
{
return $this->reasonOwnership?->platformReasonFamily;
}
/** /**
* @return array<int, array{label: string, url: string}> * @return array<int, array{label: string, url: string}>
*/ */
@ -209,9 +256,18 @@ public function toLegacyNextSteps(): array
* scope: string * scope: string
* }>, * }>,
* show_no_action_needed: bool, * show_no_action_needed: bool,
* diagnostic_code_label: string * diagnostic_code_label: string,
* trust_impact: string, * trust_impact: string,
* absence_pattern: ?string * absence_pattern: ?string,
* reason_owner: ?array{
* owner_layer: string,
* owner_namespace: string,
* reason_code: string,
* platform_reason_family: string
* },
* owner_layer: ?string,
* owner_namespace: ?string,
* platform_reason_family: ?string
* } * }
*/ */
public function toArray(): array public function toArray(): array
@ -229,6 +285,10 @@ public function toArray(): array
'diagnostic_code_label' => $this->diagnosticCode(), 'diagnostic_code_label' => $this->diagnosticCode(),
'trust_impact' => $this->trustImpact, 'trust_impact' => $this->trustImpact,
'absence_pattern' => $this->absencePattern, 'absence_pattern' => $this->absencePattern,
'reason_owner' => $this->reasonOwnership?->toArray(),
'owner_layer' => $this->ownerLayer(),
'owner_namespace' => $this->ownerNamespace(),
'platform_reason_family' => $this->platformReasonFamily(),
]; ];
} }
} }

View File

@ -6,6 +6,7 @@
use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\Operations\ExecutionDenialReasonCode; use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Operations\LifecycleReconciliationReason; use App\Support\Operations\LifecycleReconciliationReason;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
@ -27,6 +28,7 @@ final class ReasonTranslator
public function __construct( public function __construct(
private readonly ProviderReasonTranslator $providerReasonTranslator, private readonly ProviderReasonTranslator $providerReasonTranslator,
private readonly FallbackReasonTranslator $fallbackReasonTranslator, private readonly FallbackReasonTranslator $fallbackReasonTranslator,
private readonly PlatformVocabularyGlossary $glossary,
) {} ) {}
/** /**
@ -44,7 +46,7 @@ public function translate(
return null; return null;
} }
return match (true) { $envelope = match (true) {
$artifactKey === ProviderReasonTranslator::ARTIFACT_KEY, $artifactKey === ProviderReasonTranslator::ARTIFACT_KEY,
$artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context), $artifactKey === null && $this->providerReasonTranslator->canTranslate($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode), $artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => $this->translateBaselineCompareReason($reasonCode),
@ -62,6 +64,36 @@ public function translate(
$artifactKey === null && ProviderReasonCodes::isKnown($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context), $artifactKey === null && ProviderReasonCodes::isKnown($reasonCode) => $this->providerReasonTranslator->translate($reasonCode, $surface, $context),
default => $this->fallbackTranslate($reasonCode, $artifactKey, $surface, $context), default => $this->fallbackTranslate($reasonCode, $artifactKey, $surface, $context),
}; };
return $this->withOwnership($envelope, $reasonCode, $artifactKey);
}
/**
* @param array<string, mixed> $context
*/
public function boundaryClassification(
?string $reasonCode,
?string $artifactKey = null,
string $surface = 'detail',
array $context = [],
): ?string {
return $this->boundaryClassificationForEnvelope(
$this->translate($reasonCode, $artifactKey, $surface, $context),
);
}
public function boundaryClassificationForEnvelope(?ReasonResolutionEnvelope $envelope): ?string
{
return $this->boundaryClassificationForNamespace($envelope?->ownerNamespace());
}
public function boundaryClassificationForNamespace(?string $ownerNamespace): ?string
{
if (! is_string($ownerNamespace) || trim($ownerNamespace) === '') {
return null;
}
return $this->glossary->classifyReasonNamespace($ownerNamespace);
} }
/** /**
@ -305,4 +337,101 @@ private function translateBaselineCompareReason(string $reasonCode): ReasonResol
absencePattern: $enum->absencePattern(), absencePattern: $enum->absencePattern(),
); );
} }
private function withOwnership(
?ReasonResolutionEnvelope $envelope,
string $reasonCode,
?string $artifactKey,
): ?ReasonResolutionEnvelope {
if (! $envelope instanceof ReasonResolutionEnvelope) {
return null;
}
if ($envelope->reasonOwnership instanceof ReasonOwnershipDescriptor) {
return $envelope;
}
$ownership = match (true) {
$artifactKey === ProviderReasonTranslator::ARTIFACT_KEY,
$artifactKey === null && ProviderReasonCodes::isKnown($reasonCode) => ProviderReasonCodes::ownershipDescriptor($reasonCode),
$artifactKey === self::RBAC_ARTIFACT,
$artifactKey === null && RbacReason::tryFrom($reasonCode) instanceof RbacReason => new ReasonOwnershipDescriptor(
ownerLayer: 'domain_owned',
ownerNamespace: 'rbac.intune',
reasonCode: $reasonCode,
platformReasonFamily: PlatformReasonFamily::Authorization,
),
$artifactKey === self::TENANT_OPERABILITY_ARTIFACT,
$artifactKey === null && TenantOperabilityReasonCode::tryFrom($reasonCode) instanceof TenantOperabilityReasonCode => new ReasonOwnershipDescriptor(
ownerLayer: 'platform_core',
ownerNamespace: 'tenant_operability',
reasonCode: $reasonCode,
platformReasonFamily: PlatformReasonFamily::Availability,
),
$artifactKey === self::EXECUTION_DENIAL_ARTIFACT,
$artifactKey === null && ExecutionDenialReasonCode::tryFrom($reasonCode) instanceof ExecutionDenialReasonCode => new ReasonOwnershipDescriptor(
ownerLayer: 'platform_core',
ownerNamespace: 'execution_denial',
reasonCode: $reasonCode,
platformReasonFamily: PlatformReasonFamily::Authorization,
),
$artifactKey === null && LifecycleReconciliationReason::tryFrom($reasonCode) instanceof LifecycleReconciliationReason => new ReasonOwnershipDescriptor(
ownerLayer: 'platform_core',
ownerNamespace: 'operation_lifecycle',
reasonCode: $reasonCode,
platformReasonFamily: PlatformReasonFamily::Execution,
),
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode,
$artifactKey === null && BaselineCompareReasonCode::tryFrom($reasonCode) instanceof BaselineCompareReasonCode => new ReasonOwnershipDescriptor(
ownerLayer: 'domain_owned',
ownerNamespace: 'governance.baseline_compare',
reasonCode: $reasonCode,
platformReasonFamily: $this->baselineCompareFamily($reasonCode),
),
$artifactKey === self::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT && BaselineReasonCodes::isKnown($reasonCode),
$artifactKey === null && BaselineReasonCodes::isKnown($reasonCode) => new ReasonOwnershipDescriptor(
ownerLayer: 'domain_owned',
ownerNamespace: 'governance.artifact_truth',
reasonCode: $reasonCode,
platformReasonFamily: $this->baselineReasonFamily($reasonCode),
),
default => new ReasonOwnershipDescriptor(
ownerLayer: 'platform_core',
ownerNamespace: 'reason_translation.fallback',
reasonCode: $reasonCode,
platformReasonFamily: PlatformReasonFamily::Compatibility,
),
};
return $envelope->withReasonOwnership($ownership);
}
private function baselineCompareFamily(string $reasonCode): PlatformReasonFamily
{
return match (BaselineCompareReasonCode::tryFrom($reasonCode)) {
BaselineCompareReasonCode::CoverageUnproven,
BaselineCompareReasonCode::EvidenceCaptureIncomplete,
BaselineCompareReasonCode::UnsupportedSubjects,
BaselineCompareReasonCode::AmbiguousSubjects,
BaselineCompareReasonCode::NoSubjectsInScope,
BaselineCompareReasonCode::NoDriftDetected => PlatformReasonFamily::Coverage,
BaselineCompareReasonCode::StrategyFailed => PlatformReasonFamily::Execution,
BaselineCompareReasonCode::RolloutDisabled => PlatformReasonFamily::Compatibility,
BaselineCompareReasonCode::OverdueFindingsRemain,
BaselineCompareReasonCode::GovernanceExpiring,
BaselineCompareReasonCode::GovernanceLapsed => PlatformReasonFamily::Prerequisite,
default => PlatformReasonFamily::Compatibility,
};
}
private function baselineReasonFamily(string $reasonCode): PlatformReasonFamily
{
return match ($reasonCode) {
BaselineReasonCodes::COMPARE_MIXED_SCOPE,
BaselineReasonCodes::CAPTURE_ROLLOUT_DISABLED,
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => PlatformReasonFamily::Compatibility,
BaselineReasonCodes::SNAPSHOT_CAPTURE_FAILED => PlatformReasonFamily::Execution,
default => PlatformReasonFamily::Prerequisite,
};
}
} }

View File

@ -4,7 +4,10 @@
namespace App\Support\Tenants; namespace App\Support\Tenants;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\ReasonTranslation\NextStepOption; use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\PlatformReasonFamily;
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope; use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
enum TenantOperabilityReasonCode: string enum TenantOperabilityReasonCode: string
@ -61,6 +64,26 @@ public function actionability(): string
}; };
} }
public function ownerLayer(): string
{
return PlatformVocabularyGlossary::OWNER_PLATFORM_CORE;
}
public function ownerNamespace(): string
{
return 'tenant_operability';
}
public function platformReasonFamily(): PlatformReasonFamily
{
return PlatformReasonFamily::Availability;
}
public function boundaryClassification(): string
{
return PlatformVocabularyGlossary::BOUNDARY_PLATFORM_CORE;
}
/** /**
* @return array<int, NextStepOption> * @return array<int, NextStepOption>
*/ */
@ -102,6 +125,12 @@ public function toReasonResolutionEnvelope(string $surface = 'detail', array $co
nextSteps: $this->nextSteps(), nextSteps: $this->nextSteps(),
showNoActionNeeded: $this->actionability() === 'non_actionable', showNoActionNeeded: $this->actionability() === 'non_actionable',
diagnosticCodeLabel: $this->value, diagnosticCodeLabel: $this->value,
reasonOwnership: new ReasonOwnershipDescriptor(
ownerLayer: $this->ownerLayer(),
ownerNamespace: $this->ownerNamespace(),
reasonCode: $this->value,
platformReasonFamily: $this->platformReasonFamily(),
),
); );
} }
} }

View File

@ -5,6 +5,8 @@
namespace App\Support\Ui\GovernanceArtifactTruth; namespace App\Support\Ui\GovernanceArtifactTruth;
use App\Support\ReasonTranslation\NextStepOption; use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\PlatformReasonFamily;
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope; use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel; use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
@ -22,6 +24,9 @@ public function __construct(
public string $trustImpact, public string $trustImpact,
public ?string $absencePattern, public ?string $absencePattern,
public array $nextSteps = [], public array $nextSteps = [],
public ?string $ownerLayer = null,
public ?string $ownerNamespace = null,
public ?string $platformReasonFamily = null,
) {} ) {}
public static function fromReasonResolutionEnvelope( public static function fromReasonResolutionEnvelope(
@ -44,11 +49,32 @@ public static function fromReasonResolutionEnvelope(
static fn (NextStepOption $nextStep): string => $nextStep->label, static fn (NextStepOption $nextStep): string => $nextStep->label,
$reason->nextSteps, $reason->nextSteps,
)), )),
ownerLayer: $reason->ownerLayer(),
ownerNamespace: $reason->ownerNamespace(),
platformReasonFamily: $reason->platformReasonFamily(),
); );
} }
public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
{ {
$reasonOwnership = null;
$family = is_string($this->platformReasonFamily)
? PlatformReasonFamily::tryFrom($this->platformReasonFamily)
: null;
if (is_string($this->ownerLayer)
&& trim($this->ownerLayer) !== ''
&& is_string($this->ownerNamespace)
&& trim($this->ownerNamespace) !== ''
&& $family instanceof PlatformReasonFamily) {
$reasonOwnership = new ReasonOwnershipDescriptor(
ownerLayer: trim($this->ownerLayer),
ownerNamespace: trim($this->ownerNamespace),
reasonCode: $this->reasonCode ?? 'artifact_truth_reason',
platformReasonFamily: $family,
);
}
return new ReasonResolutionEnvelope( return new ReasonResolutionEnvelope(
internalCode: $this->reasonCode ?? 'artifact_truth_reason', internalCode: $this->reasonCode ?? 'artifact_truth_reason',
operatorLabel: $this->operatorLabel ?? 'Operator attention required', operatorLabel: $this->operatorLabel ?? 'Operator attention required',
@ -61,6 +87,7 @@ public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
diagnosticCodeLabel: $this->diagnosticCode, diagnosticCodeLabel: $this->diagnosticCode,
trustImpact: $this->trustImpact !== '' ? $this->trustImpact : TrustworthinessLevel::LimitedConfidence->value, trustImpact: $this->trustImpact !== '' ? $this->trustImpact : TrustworthinessLevel::LimitedConfidence->value,
absencePattern: $this->absencePattern, absencePattern: $this->absencePattern,
reasonOwnership: $reasonOwnership,
); );
} }
@ -73,7 +100,10 @@ public function toReasonResolutionEnvelope(): ReasonResolutionEnvelope
* diagnosticCode: ?string, * diagnosticCode: ?string,
* trustImpact: string, * trustImpact: string,
* absencePattern: ?string, * absencePattern: ?string,
* nextSteps: array<int, string> * nextSteps: array<int, string>,
* ownerLayer: ?string,
* ownerNamespace: ?string,
* platformReasonFamily: ?string
* } * }
*/ */
public function toArray(): array public function toArray(): array
@ -87,6 +117,9 @@ public function toArray(): array
'trustImpact' => $this->trustImpact, 'trustImpact' => $this->trustImpact,
'absencePattern' => $this->absencePattern, 'absencePattern' => $this->absencePattern,
'nextSteps' => $this->nextSteps, 'nextSteps' => $this->nextSteps,
'ownerLayer' => $this->ownerLayer,
'ownerNamespace' => $this->ownerNamespace,
'platformReasonFamily' => $this->platformReasonFamily,
]; ];
} }
} }

View File

@ -597,6 +597,248 @@
], ],
], ],
'platform_vocabulary' => [
'terms' => [
'governed_subject' => [
'term_key' => 'governed_subject',
'canonical_label' => 'Governed subject',
'canonical_description' => 'The platform-facing noun for a governed object across compare, snapshot, evidence, and review surfaces.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['compare', 'snapshot', 'evidence', 'review', 'reporting'],
'legacy_aliases' => ['policy_type'],
'alias_retirement_path' => 'Retire false-universal policy_type wording from platform-owned summaries and descriptors while preserving Intune-owned storage.',
'forbidden_platform_aliases' => ['policy_type'],
],
'domain_key' => [
'term_key' => 'domain_key',
'canonical_label' => 'Governance domain',
'canonical_description' => 'The canonical domain discriminator for cross-domain and platform-near contracts.',
'boundary_classification' => 'cross_domain_governance',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['governance', 'compare', 'snapshot', 'reporting'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'subject_class' => [
'term_key' => 'subject_class',
'canonical_label' => 'Subject class',
'canonical_description' => 'The canonical subject-class discriminator for governed-subject contracts.',
'boundary_classification' => 'cross_domain_governance',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['governance', 'compare', 'snapshot'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'subject_type_key' => [
'term_key' => 'subject_type_key',
'canonical_label' => 'Governed subject key',
'canonical_description' => 'The domain-owned subject-family key used by platform-near descriptors.',
'boundary_classification' => 'cross_domain_governance',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['governance', 'compare', 'snapshot', 'evidence'],
'legacy_aliases' => ['policy_type'],
'alias_retirement_path' => 'Prefer subject_type_key on platform-near payloads and keep policy_type only where the owning model is Intune-specific.',
'forbidden_platform_aliases' => ['policy_type'],
],
'subject_type_label' => [
'term_key' => 'subject_type_label',
'canonical_label' => 'Governed subject label',
'canonical_description' => 'The operator-facing label for a governed subject family.',
'boundary_classification' => 'cross_domain_governance',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['snapshot', 'evidence', 'compare', 'review'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'resource_type' => [
'term_key' => 'resource_type',
'canonical_label' => 'Resource type',
'canonical_description' => 'Optional resource-shaped noun for platform-facing summaries when a governed subject also needs a resource family label.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['reporting', 'review'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'operation_type' => [
'term_key' => 'operation_type',
'canonical_label' => 'Operation type',
'canonical_description' => 'The canonical platform operation identifier shown on monitoring and launch surfaces.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['monitoring', 'reporting', 'launch_surfaces'],
'legacy_aliases' => ['type'],
'alias_retirement_path' => 'Expose canonical operation_type on read models while operation_runs.type remains a compatibility storage seam during rollout.',
'forbidden_platform_aliases' => [],
],
'platform_reason_family' => [
'term_key' => 'platform_reason_family',
'canonical_label' => 'Platform reason family',
'canonical_description' => 'The cross-domain platform family that explains the top-level cause category without erasing domain ownership.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['reason_translation', 'monitoring', 'review', 'reporting'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'reason_owner.owner_namespace' => [
'term_key' => 'reason_owner.owner_namespace',
'canonical_label' => 'Reason owner namespace',
'canonical_description' => 'The explicit namespace that marks whether a translated reason is provider-owned, governance-owned, access-owned, or runtime-owned.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['reason_translation', 'review', 'reporting'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'reason_code' => [
'term_key' => 'reason_code',
'canonical_label' => 'Reason code',
'canonical_description' => 'The original domain-owned or provider-owned reason identifier preserved for diagnostics and translation lookup.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['reason_translation', 'diagnostics'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'registry_key' => [
'term_key' => 'registry_key',
'canonical_label' => 'Registry key',
'canonical_description' => 'The stable identifier for a contributor-facing registry or catalog.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['governance', 'contributor_guidance'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'boundary_classification' => [
'term_key' => 'boundary_classification',
'canonical_label' => 'Boundary classification',
'canonical_description' => 'The explicit classification that marks a term or registry as platform_core, cross_domain_governance, or intune_specific.',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'allowed_contexts' => ['governance', 'contributor_guidance'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => [],
],
'policy_type' => [
'term_key' => 'policy_type',
'canonical_label' => 'Intune policy type',
'canonical_description' => 'The Intune-specific discriminator that remains valid on adapter-owned or Intune-owned models but must not be treated as a universal platform noun.',
'boundary_classification' => 'intune_specific',
'owner_layer' => 'domain_owned',
'allowed_contexts' => ['intune_adapter', 'intune_inventory', 'intune_backup'],
'legacy_aliases' => [],
'alias_retirement_path' => null,
'forbidden_platform_aliases' => ['governed_subject'],
],
],
'reason_namespaces' => [
'tenant_operability' => [
'owner_namespace' => 'tenant_operability',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'compatibility_notes' => 'Tenant operability reasons are platform-core guardrails for workspace and tenant context.',
],
'execution_denial' => [
'owner_namespace' => 'execution_denial',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'compatibility_notes' => 'Execution denial reasons remain platform-core run-legitimacy semantics.',
],
'operation_lifecycle' => [
'owner_namespace' => 'operation_lifecycle',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'compatibility_notes' => 'Lifecycle reconciliation reasons remain platform-core monitoring semantics.',
],
'governance.baseline_compare' => [
'owner_namespace' => 'governance.baseline_compare',
'boundary_classification' => 'cross_domain_governance',
'owner_layer' => 'domain_owned',
'compatibility_notes' => 'Baseline-compare reason codes are governance-owned details translated through the platform boundary.',
],
'governance.artifact_truth' => [
'owner_namespace' => 'governance.artifact_truth',
'boundary_classification' => 'cross_domain_governance',
'owner_layer' => 'domain_owned',
'compatibility_notes' => 'Artifact-truth reason codes remain governance-owned and are surfaced through platform-safe summaries.',
],
'provider.microsoft_graph' => [
'owner_namespace' => 'provider.microsoft_graph',
'boundary_classification' => 'intune_specific',
'owner_layer' => 'provider_owned',
'compatibility_notes' => 'Microsoft Graph provider reasons remain provider-owned and Intune-specific.',
],
'provider.intune_rbac' => [
'owner_namespace' => 'provider.intune_rbac',
'boundary_classification' => 'intune_specific',
'owner_layer' => 'provider_owned',
'compatibility_notes' => 'Provider-owned Intune RBAC reasons remain Intune-specific.',
],
'rbac.intune' => [
'owner_namespace' => 'rbac.intune',
'boundary_classification' => 'intune_specific',
'owner_layer' => 'domain_owned',
'compatibility_notes' => 'RBAC detail remains Intune-specific domain context.',
],
'reason_translation.fallback' => [
'owner_namespace' => 'reason_translation.fallback',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'compatibility_notes' => 'Fallback translation remains a platform-core compatibility seam until a domain owner is known.',
],
],
'registries' => [
'governance_subject_taxonomy_registry' => [
'registry_key' => 'governance_subject_taxonomy_registry',
'boundary_classification' => 'cross_domain_governance',
'owner_layer' => 'platform_core',
'source_class_or_file' => App\Support\Governance\GovernanceSubjectTaxonomyRegistry::class,
'canonical_nouns' => ['domain_key', 'subject_class', 'subject_type_key', 'subject_type_label'],
'allowed_consumers' => ['baseline_scope', 'compare', 'snapshot', 'review'],
'compatibility_notes' => 'Provides the governed-subject source of truth, resolves legacy policy-type payloads, and preserves inactive or future-domain entries without exposing them as active operator choices.',
],
'operation_catalog' => [
'registry_key' => 'operation_catalog',
'boundary_classification' => 'platform_core',
'owner_layer' => 'platform_core',
'source_class_or_file' => App\Support\OperationCatalog::class,
'canonical_nouns' => ['operation_type'],
'allowed_consumers' => ['monitoring', 'reporting', 'launch_surfaces', 'audit'],
'compatibility_notes' => 'Resolves canonical operation meaning from historical storage values without treating every stored raw string as equally canonical.',
],
'provider_reason_codes' => [
'registry_key' => 'provider_reason_codes',
'boundary_classification' => 'intune_specific',
'owner_layer' => 'provider_owned',
'source_class_or_file' => App\Support\Providers\ProviderReasonCodes::class,
'canonical_nouns' => ['reason_code'],
'allowed_consumers' => ['reason_translation'],
'compatibility_notes' => 'Provider-owned reason codes remain namespaced domain details and become platform-safe only through the reason-translation boundary.',
],
'inventory_policy_type_catalog' => [
'registry_key' => 'inventory_policy_type_catalog',
'boundary_classification' => 'intune_specific',
'owner_layer' => 'domain_owned',
'source_class_or_file' => App\Support\Inventory\InventoryPolicyTypeMeta::class,
'canonical_nouns' => ['policy_type'],
'allowed_consumers' => ['intune_adapter', 'inventory', 'backup'],
'compatibility_notes' => 'The supported Intune policy-type list is not a universal platform registry and must be wrapped before reuse on platform-near summaries.',
],
],
],
'hardening' => [ 'hardening' => [
'intune_write_gate' => [ 'intune_write_gate' => [
'enabled' => (bool) env('TENANTPILOT_INTUNE_WRITE_GATE_ENABLED', true), 'enabled' => (bool) env('TENANTPILOT_INTUNE_WRITE_GATE_ENABLED', true),

View File

@ -30,10 +30,10 @@
'badge_evidence_gaps' => 'Evidence gaps: :count', 'badge_evidence_gaps' => 'Evidence gaps: :count',
'evidence_gaps_tooltip' => 'Top gaps: :summary', 'evidence_gaps_tooltip' => 'Top gaps: :summary',
'evidence_gap_details_heading' => 'Evidence gap details', 'evidence_gap_details_heading' => 'Evidence gap details',
'evidence_gap_details_description' => 'Search recorded gap subjects by reason, policy type, subject class, outcome, next action, or subject key before falling back to raw diagnostics.', 'evidence_gap_details_description' => 'Search recorded gap subjects by reason, governed subject, subject class, outcome, next action, or subject key before falling back to raw diagnostics.',
'evidence_gap_search_label' => 'Search gap details', 'evidence_gap_search_label' => 'Search gap details',
'evidence_gap_search_placeholder' => 'Search by reason, type, class, outcome, action, or subject key', 'evidence_gap_search_placeholder' => 'Search by reason, type, class, outcome, action, or subject key',
'evidence_gap_search_help' => 'Filter matches across reason, policy type, subject class, outcome, next action, and subject key.', 'evidence_gap_search_help' => 'Filter matches across reason, governed subject, subject class, outcome, next action, and subject key.',
'evidence_gap_bucket_help_ambiguous_match' => 'Multiple inventory records matched the same policy subject — inspect the mapping to confirm the correct pairing.', 'evidence_gap_bucket_help_ambiguous_match' => 'Multiple inventory records matched the same policy subject — inspect the mapping to confirm the correct pairing.',
'evidence_gap_bucket_help_policy_record_missing' => 'The expected policy record was not found in the baseline snapshot — verify the policy still exists in the tenant.', 'evidence_gap_bucket_help_policy_record_missing' => 'The expected policy record was not found in the baseline snapshot — verify the policy still exists in the tenant.',
'evidence_gap_bucket_help_inventory_record_missing' => 'No inventory record could be matched for these subjects — confirm the inventory sync is current.', 'evidence_gap_bucket_help_inventory_record_missing' => 'No inventory record could be matched for these subjects — confirm the inventory sync is current.',
@ -57,7 +57,7 @@
'evidence_gap_legacy_body' => 'This run still uses the retired broad reason shape. Regenerate the run or purge stale local development payloads before treating the gap details as operator-safe.', 'evidence_gap_legacy_body' => 'This run still uses the retired broad reason shape. Regenerate the run or purge stale local development payloads before treating the gap details as operator-safe.',
'evidence_gap_diagnostics_heading' => 'Baseline compare evidence', 'evidence_gap_diagnostics_heading' => 'Baseline compare evidence',
'evidence_gap_diagnostics_description' => 'Raw diagnostics remain available for support and deep troubleshooting after the operator summary and searchable detail.', 'evidence_gap_diagnostics_description' => 'Raw diagnostics remain available for support and deep troubleshooting after the operator summary and searchable detail.',
'evidence_gap_policy_type' => 'Policy type', 'evidence_gap_policy_type' => 'Governed subject',
'evidence_gap_subject_class' => 'Subject class', 'evidence_gap_subject_class' => 'Subject class',
'evidence_gap_outcome' => 'Outcome', 'evidence_gap_outcome' => 'Outcome',
'evidence_gap_next_action' => 'Next action', 'evidence_gap_next_action' => 'Next action',

View File

@ -1,85 +1,6 @@
@php /** @var callable $getState */ @endphp
<div class="space-y-4"> <div class="space-y-4">
<form method="GET" class="flex items-center gap-2"> <livewire:inventory-item-dependency-edges-table
<label for="direction" class="text-sm text-gray-600">Direction</label> :inventory-item-id="(int) $getRecord()->getKey()"
<select id="direction" name="direction" class="fi-input fi-select"> :key="'inventory-item-dependency-edges-'.$getRecord()->getKey()"
<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
</div> </div>

View File

@ -33,7 +33,7 @@
@endphp @endphp
<x-filament::section <x-filament::section
:heading="$group['label'] ?? ($group['policyType'] ?? 'Policy type')" :heading="$group['subjectDescriptor']['display_label'] ?? ($group['label'] ?? ($group['policyType'] ?? 'Governed subject'))"
:description="$group['coverageHint'] ?? null" :description="$group['coverageHint'] ?? null"
collapsible collapsible
:collapsed="(bool) ($group['initiallyCollapsed'] ?? true)" :collapsed="(bool) ($group['initiallyCollapsed'] ?? true)"
@ -84,7 +84,7 @@
{{ $item['label'] ?? 'Snapshot item' }} {{ $item['label'] ?? 'Snapshot item' }}
</div> </div>
<div class="text-xs text-gray-500 dark:text-gray-400"> <div class="text-xs text-gray-500 dark:text-gray-400">
{{ $item['typeLabel'] ?? 'Policy type' }} {{ $item['typeLabel'] ?? 'Governed subject family' }}
</div> </div>
</div> </div>

View File

@ -22,14 +22,14 @@
<div class="space-y-3"> <div class="space-y-3">
@if ($rows === []) @if ($rows === [])
<div class="rounded-lg border border-dashed border-gray-300 px-4 py-6 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-300"> <div class="rounded-lg border border-dashed border-gray-300 px-4 py-6 text-sm text-gray-600 dark:border-gray-700 dark:text-gray-300">
No captured policy types are available in this snapshot. No captured governed subjects are available in this snapshot.
</div> </div>
@else @else
<div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700"> <div class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 text-left text-sm dark:divide-gray-700"> <table class="min-w-full divide-y divide-gray-200 text-left text-sm dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-900/50"> <thead class="bg-gray-50 dark:bg-gray-900/50">
<tr class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400"> <tr class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
<th class="px-4 py-3">Policy type</th> <th class="px-4 py-3">Governed subject</th>
<th class="px-4 py-3">Items</th> <th class="px-4 py-3">Items</th>
<th class="px-4 py-3">Fidelity</th> <th class="px-4 py-3">Fidelity</th>
<th class="px-4 py-3">Coverage state</th> <th class="px-4 py-3">Coverage state</th>
@ -47,7 +47,7 @@
<tr class="align-top"> <tr class="align-top">
<td class="px-4 py-3 font-medium text-gray-900 dark:text-white"> <td class="px-4 py-3 font-medium text-gray-900 dark:text-white">
{{ $row['label'] ?? ($row['policyType'] ?? 'Policy type') }} {{ $row['governedSubjectLabel'] ?? ($row['label'] ?? ($row['policyType'] ?? 'Governed subject')) }}
</td> </td>
<td class="px-4 py-3 text-gray-700 dark:text-gray-200"> <td class="px-4 py-3 text-gray-700 dark:text-gray-200">
{{ (int) ($row['itemCount'] ?? 0) }} {{ (int) ($row['itemCount'] ?? 0) }}

View File

@ -17,7 +17,7 @@
<div class="space-y-3"> <div class="space-y-3">
@foreach ($groupPayloads as $group) @foreach ($groupPayloads as $group)
@php @php
$label = $group['label'] ?? 'Policy type'; $label = $group['payload']['subject_descriptor']['display_label'] ?? ($group['label'] ?? 'Governed subject');
$payload = is_array($group['payload'] ?? null) ? $group['payload'] : []; $payload = is_array($group['payload'] ?? null) ? $group['payload'] : [];
@endphp @endphp

View File

@ -8,6 +8,7 @@
$contextLinks = is_array($state['context_links'] ?? null) ? $state['context_links'] : []; $contextLinks = is_array($state['context_links'] ?? null) ? $state['context_links'] : [];
$publishBlockers = is_array($state['publish_blockers'] ?? null) ? $state['publish_blockers'] : []; $publishBlockers = is_array($state['publish_blockers'] ?? null) ? $state['publish_blockers'] : [];
$operatorExplanation = is_array($state['operator_explanation'] ?? null) ? $state['operator_explanation'] : []; $operatorExplanation = is_array($state['operator_explanation'] ?? null) ? $state['operator_explanation'] : [];
$reasonSemantics = is_array($state['reason_semantics'] ?? null) ? $state['reason_semantics'] : [];
@endphp @endphp
<div class="space-y-4"> <div class="space-y-4">
@ -31,6 +32,20 @@
</div> </div>
@endif @endif
@if ($reasonSemantics !== [])
<dl class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Reason owner</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['owner_label'] ?? 'Platform core' }}</dd>
</div>
<div class="rounded-md border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-800 dark:bg-gray-950/60">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Platform reason family</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">{{ $reasonSemantics['family_label'] ?? 'Compatibility' }}</dd>
</div>
</dl>
@endif
<dl class="grid grid-cols-2 gap-3 xl:grid-cols-4"> <dl class="grid grid-cols-2 gap-3 xl:grid-cols-4">
@foreach ($metrics as $metric) @foreach ($metrics as $metric)
@php @php

View File

@ -8,6 +8,7 @@
$duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0); $duplicateNamePoliciesCountValue = (int) ($duplicateNamePoliciesCount ?? 0);
$duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0); $duplicateNameSubjectsCountValue = (int) ($duplicateNameSubjectsCount ?? 0);
$explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null; $explanation = is_array($operatorExplanation ?? null) ? $operatorExplanation : null;
$reasonSemantics = is_array($reasonSemantics ?? null) ? $reasonSemantics : null;
$summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null; $summary = is_array($summaryAssessment ?? null) ? $summaryAssessment : null;
$matrixNavigationContext = is_array($navigationContext ?? null) ? $navigationContext : null; $matrixNavigationContext = is_array($navigationContext ?? null) ? $navigationContext : null;
$arrivedFromCompareMatrix = str_starts_with((string) ($matrixNavigationContext['source_surface'] ?? ''), 'baseline_compare_matrix'); $arrivedFromCompareMatrix = str_starts_with((string) ($matrixNavigationContext['source_surface'] ?? ''), 'baseline_compare_matrix');
@ -117,6 +118,24 @@
@endif @endif
</div> </div>
@if ($reasonSemantics !== null)
<dl class="grid gap-3 md:grid-cols-2">
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Reason owner</dt>
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
{{ $reasonSemantics['owner_label'] ?? 'Platform core' }}
</dd>
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Platform reason family</dt>
<dd class="mt-1 text-sm font-medium text-gray-900 dark:text-white">
{{ $reasonSemantics['family_label'] ?? 'Compatibility' }}
</dd>
</div>
</dl>
@endif
<dl class="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> <dl class="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50"> <div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-800 dark:bg-gray-950/50">
<dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Execution outcome</dt> <dt class="text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Execution outcome</dt>

View File

@ -340,7 +340,7 @@
@if ($policyTypeOptions !== []) @if ($policyTypeOptions !== [])
<div class="mt-3 flex flex-wrap gap-2"> <div class="mt-3 flex flex-wrap gap-2">
<x-filament::badge color="gray" size="sm"> <x-filament::badge color="gray" size="sm">
{{ count($policyTypeOptions) }} searchable policy types {{ count($policyTypeOptions) }} searchable governed subjects
</x-filament::badge> </x-filament::badge>
@if ($hiddenAssignedTenantCount > 0) @if ($hiddenAssignedTenantCount > 0)
<x-filament::badge color="gray" size="sm"> <x-filament::badge color="gray" size="sm">
@ -507,7 +507,7 @@
{{ $result['displayName'] ?? $result['subjectKey'] ?? 'Subject' }} {{ $result['displayName'] ?? $result['subjectKey'] ?? 'Subject' }}
</div> </div>
<div class="text-sm text-gray-500 dark:text-gray-400"> <div class="text-sm text-gray-500 dark:text-gray-400">
{{ $result['policyType'] ?? 'Unknown policy type' }} {{ $result['governedSubjectLabel'] ?? ($result['policyType'] ?? 'Unknown governed subject') }}
</div> </div>
@if (filled($result['baselineExternalId'] ?? null)) @if (filled($result['baselineExternalId'] ?? null))
<div class="text-xs text-gray-500 dark:text-gray-400"> <div class="text-xs text-gray-500 dark:text-gray-400">
@ -715,7 +715,7 @@
{{ $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject' }} {{ $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject' }}
</div> </div>
<div class="text-xs text-gray-500 dark:text-gray-400"> <div class="text-xs text-gray-500 dark:text-gray-400">
{{ $subject['policyType'] ?? 'Unknown policy type' }} {{ $subject['governedSubjectLabel'] ?? ($subject['policyType'] ?? 'Unknown governed subject') }}
</div> </div>
@if (filled($subject['baselineExternalId'] ?? null)) @if (filled($subject['baselineExternalId'] ?? null))
<div class="text-xs text-gray-500 dark:text-gray-400"> <div class="text-xs text-gray-500 dark:text-gray-400">

View File

@ -1,59 +1,5 @@
<x-filament-panels::page> <x-filament-panels::page>
<div class="space-y-6"> <div class="space-y-6">
@if ($rows === []) {{ $this->table }}
<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
</div> </div>
</x-filament-panels::page> </x-filament-panels::page>

View File

@ -6,7 +6,7 @@
$tenant = $this->currentTenant(); $tenant = $this->currentTenant();
$vm = is_array($viewModel ?? null) ? $viewModel : []; $vm = $this->viewModel();
$overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : []; $overview = is_array($vm['overview'] ?? null) ? $vm['overview'] : [];
$counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : []; $counts = is_array($overview['counts'] ?? null) ? $overview['counts'] : [];
$featureImpacts = is_array($overview['feature_impacts'] ?? null) ? $overview['feature_impacts'] : []; $featureImpacts = is_array($overview['feature_impacts'] ?? null) ? $overview['feature_impacts'] : [];
@ -14,20 +14,6 @@
$filters = is_array($vm['filters'] ?? null) ? $vm['filters'] : []; $filters = is_array($vm['filters'] ?? null) ? $vm['filters'] : [];
$selectedFeatures = is_array($filters['features'] ?? null) ? $filters['features'] : []; $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; $overall = $overview['overall'] ?? null;
$overallSpec = $overall !== null ? BadgeRenderer::spec(BadgeDomain::VerificationReportOverall, $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); $selected = in_array($featureKey, $selectedFeatures, true);
@endphp @endphp
<button <div
type="button" 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' }}"
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="flex items-start justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div class="min-w-0"> <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') }} {{ $isBlocked ? 'Blocked' : ($missingCount > 0 ? 'At risk' : 'OK') }}
</x-filament::badge> </x-filament::badge>
</div> </div>
</button> </div>
@endforeach @endforeach
</div> </div>
@if ($selectedFeatures !== [])
<div>
<x-filament::button color="gray" size="sm" wire:click="clearFeatureFilter">
Clear feature filter
</x-filament::button>
</div>
@endif
@endif @endif
<div <div
@ -475,182 +451,14 @@ class="group rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-800
</div> </div>
@else @else
<div class="space-y-6"> <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="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="flex flex-wrap items-start justify-between gap-3"> <div class="font-semibold text-gray-950 dark:text-white">Native permission matrix</div>
<div>
<div class="text-sm font-semibold text-gray-950 dark:text-white">Filters</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-300"> <div class="mt-1 text-xs text-gray-600 dark:text-gray-300">
Search doesnt affect copy actions. Feature filters do. Search doesnt affect copy actions. Feature filters do.
</div> </div>
</div> </div>
<x-filament::button color="gray" size="sm" wire:click="resetFilters"> {{ $this->table }}
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
</div> </div>
@endif @endif
</div> </div>

View File

@ -11,6 +11,8 @@
/** @var bool $canManage */ /** @var bool $canManage */
/** @var ?string $downloadUrl */ /** @var ?string $downloadUrl */
/** @var ?string $failedReason */ /** @var ?string $failedReason */
/** @var ?string $failedReasonDetail */
/** @var ?array<string, mixed> $failedReasonSemantics */
/** @var ?string $reviewUrl */ /** @var ?string $reviewUrl */
$badgeSpec = $statusEnum ? BadgeCatalog::spec(BadgeDomain::ReviewPackStatus, $statusEnum->value) : null; $badgeSpec = $statusEnum ? BadgeCatalog::spec(BadgeDomain::ReviewPackStatus, $statusEnum->value) : null;
@ -133,11 +135,25 @@
</div> </div>
@if ($failedReason) @if ($failedReason)
<div class="text-sm text-danger-600 dark:text-danger-400"> <div class="text-sm font-medium text-danger-600 dark:text-danger-400">
{{ $failedReason }} {{ $failedReason }}
</div> </div>
@endif @endif
@if ($failedReasonDetail)
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $failedReasonDetail }}
</div>
@endif
@if (is_array($failedReasonSemantics ?? null))
<div class="text-xs text-gray-500 dark:text-gray-400">
Reason owner: {{ $failedReasonSemantics['owner_label'] ?? 'Platform core' }}
&middot;
Platform reason family: {{ $failedReasonSemantics['family_label'] ?? 'Compatibility' }}
</div>
@endif
<div class="text-xs text-gray-400 dark:text-gray-500"> <div class="text-xs text-gray-400 dark:text-gray-500">
{{ $pack->updated_at?->diffForHumans() ?? '—' }} {{ $pack->updated_at?->diffForHumans() ?? '—' }}
</div> </div>

View File

@ -0,0 +1,3 @@
<div>
{{ $this->table }}
</div>

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\OperationCatalog;
it('keeps touched registry ownership metadata inside the allowed three-way boundary classification', function (): void {
$classifications = collect(app(PlatformVocabularyGlossary::class)->registries())
->map(static fn ($descriptor): string => $descriptor->boundaryClassification)
->unique()
->values()
->all();
expect($classifications)->toEqualCanonicalizing([
PlatformVocabularyGlossary::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
PlatformVocabularyGlossary::BOUNDARY_PLATFORM_CORE,
PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC,
]);
});
it('guards the false-universal policy_type alias behind explicit context-aware vocabulary helpers', function (): void {
$glossary = app(PlatformVocabularyGlossary::class);
expect($glossary->term('policy_type')?->boundaryClassification)->toBe(PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC)
->and($glossary->resolveAlias('policy_type', 'compare')?->termKey)->toBe('governed_subject')
->and(OperationCatalog::canonicalCode('baseline_capture'))->toBe('baseline.capture');
});

View File

@ -2,6 +2,8 @@
declare(strict_types=1); declare(strict_types=1);
use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\Operations\ExecutionDenialReasonCode; use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\Providers\ProviderReasonCodes; use App\Support\Providers\ProviderReasonCodes;
use App\Support\RbacReason; use App\Support\RbacReason;
@ -32,3 +34,24 @@
->and(TenantOperabilityReasonCode::TenantAlreadyArchived->toReasonResolutionEnvelope()->guidanceText())->toBe('No action needed.') ->and(TenantOperabilityReasonCode::TenantAlreadyArchived->toReasonResolutionEnvelope()->guidanceText())->toBe('No action needed.')
->and(RbacReason::ManualAssignmentRequired->toReasonResolutionEnvelope()->operatorLabel)->toBe('Manual role assignment required'); ->and(RbacReason::ManualAssignmentRequired->toReasonResolutionEnvelope()->operatorLabel)->toBe('Manual role assignment required');
}); });
it('keeps primary-surface reason ownership inside the allowed three-way boundary classification', function (): void {
$translator = app(ReasonTranslator::class);
$classifications = collect([
$translator->boundaryClassification(ExecutionDenialReasonCode::MissingCapability->value, ReasonTranslator::EXECUTION_DENIAL_ARTIFACT),
$translator->boundaryClassification(ProviderReasonCodes::ProviderConsentMissing),
$translator->boundaryClassification(TenantOperabilityReasonCode::RememberedContextStale->value, ReasonTranslator::TENANT_OPERABILITY_ARTIFACT),
$translator->boundaryClassification(RbacReason::ManualAssignmentRequired->value, ReasonTranslator::RBAC_ARTIFACT),
$translator->boundaryClassification(BaselineCompareReasonCode::CoverageUnproven->value, ReasonTranslator::GOVERNANCE_ARTIFACT_TRUTH_ARTIFACT),
])
->filter()
->unique()
->values()
->all();
expect($classifications)->toEqualCanonicalizing([
PlatformVocabularyGlossary::BOUNDARY_PLATFORM_CORE,
PlatformVocabularyGlossary::BOUNDARY_CROSS_DOMAIN_GOVERNANCE,
PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC,
]);
});

View File

@ -42,7 +42,9 @@
->assertSuccessful() ->assertSuccessful()
->assertSee('Permission required') ->assertSee('Permission required')
->assertSee('The initiating actor no longer has the capability required for this queued run.') ->assertSee('The initiating actor no longer has the capability required for this queued run.')
->assertSee('Review workspace or tenant access before retrying.'); ->assertSee('Review workspace or tenant access before retrying.')
->assertDontSee('execution_denial')
->assertDontSee('platform_core');
}); });
it('returns not found before any translated guidance can leak to non-members', function (): void { it('returns not found before any translated guidance can leak to non-members', function (): void {

View File

@ -138,6 +138,13 @@
$opService, $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() $finding = Finding::query()
->where('tenant_id', (int) $tenant->getKey()) ->where('tenant_id', (int) $tenant->getKey())
->where('subject_external_id', (string) $policy->external_id) ->where('subject_external_id', (string) $policy->external_id)

View File

@ -130,6 +130,9 @@
$run->refresh(); $run->refresh();
expect($run->status)->toBe('completed'); expect($run->status)->toBe('completed');
expect($run->outcome)->toBe('succeeded'); 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 : []; $context = is_array($run->context) ? $run->context : [];
$countsByChangeType = $context['findings']['counts_by_change_type'] ?? null; $countsByChangeType = $context['findings']['counts_by_change_type'] ?? null;

View File

@ -123,6 +123,8 @@
$run->refresh(); $run->refresh();
expect(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.policy_not_found'))->toBeNull() 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.policy_record_missing'))->toBe(1)
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.foundation_not_policy_backed'))->toBe(1); ->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.foundation_not_policy_backed'))->toBe(1);

View File

@ -215,7 +215,7 @@
->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['reasonSummary'] ?? null)->toBe('Ambiguous Match') ->and($cellsByTenant[(int) $ambiguousTenant->getKey()]['reasonSummary'] ?? null)->toBe('Ambiguous Match')
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['state'] ?? null)->toBe('not_compared') ->and($cellsByTenant[(int) $notComparedTenant->getKey()]['state'] ?? null)->toBe('not_compared')
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['attentionLevel'] ?? null)->toBe('refresh_recommended') ->and($cellsByTenant[(int) $notComparedTenant->getKey()]['attentionLevel'] ?? null)->toBe('refresh_recommended')
->and($cellsByTenant[(int) $notComparedTenant->getKey()]['reasonSummary'] ?? null)->toContain('Policy type coverage was not proven') ->and($cellsByTenant[(int) $notComparedTenant->getKey()]['reasonSummary'] ?? null)->toContain('Governed subject coverage was not proven')
->and($cellsByTenant[(int) $staleTenant->getKey()]['state'] ?? null)->toBe('stale_result') ->and($cellsByTenant[(int) $staleTenant->getKey()]['state'] ?? null)->toBe('stale_result')
->and($cellsByTenant[(int) $staleTenant->getKey()]['freshnessState'] ?? null)->toBe('stale') ->and($cellsByTenant[(int) $staleTenant->getKey()]['freshnessState'] ?? null)->toBe('stale')
->and($cellsByTenant[(int) $staleTenant->getKey()]['trustLevel'] ?? null)->toBe('limited_confidence') ->and($cellsByTenant[(int) $staleTenant->getKey()]['trustLevel'] ?? null)->toBe('limited_confidence')

View File

@ -85,7 +85,9 @@
); );
$compareRun->refresh(); $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); expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::NoSubjectsInScope->value);
}); });
@ -200,7 +202,10 @@
$compareRun->refresh(); $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, 'result.findings_total'))->toBe(0);
expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::NoDriftDetected->value); expect(data_get($compareRun->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::NoDriftDetected->value);
}); });

View File

@ -10,6 +10,8 @@
use App\Services\Baselines\BaselineCaptureService; use App\Services\Baselines\BaselineCaptureService;
use App\Services\Baselines\BaselineCompareService; use App\Services\Baselines\BaselineCompareService;
use App\Support\Baselines\BaselineCaptureMode; use App\Support\Baselines\BaselineCaptureMode;
use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Baselines\BaselineSupportCapabilityGuard;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Bus;
@ -85,7 +87,7 @@ function appendBrokenFoundationSupportConfig(): void
Bus::assertDispatched(CompareBaselineToTenantJob::class); 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(); Bus::fake();
appendBrokenFoundationSupportConfig(); appendBrokenFoundationSupportConfig();
@ -102,10 +104,13 @@ function appendBrokenFoundationSupportConfig(): void
$result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user); $result = app(BaselineCaptureService::class)->startCapture($profile, $tenant, $user);
expect($result['ok'])->toBeTrue(); $scope = $profile->normalizedScope()->toEffectiveScopeContext(
app(BaselineSupportCapabilityGuard::class),
'capture',
);
$run = $result['run']; expect($result['ok'])->toBeFalse()
$scope = data_get($run->context, 'effective_scope'); ->and($result['reason_code'] ?? null)->toBe(BaselineReasonCodes::CAPTURE_UNSUPPORTED_SCOPE);
expect(data_get($scope, 'truthful_types'))->toBe(['deviceConfiguration', 'roleScopeTag']) expect(data_get($scope, 'truthful_types'))->toBe(['deviceConfiguration', 'roleScopeTag'])
->and(data_get($scope, 'limited_types'))->toBe(['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.brokenFoundation.support_mode'))->toBe('invalid_support_config')
->and(data_get($scope, 'capabilities.unknownFoundation.support_mode'))->toBeNull(); ->and(data_get($scope, 'capabilities.unknownFoundation.support_mode'))->toBeNull();
Bus::assertDispatched(CaptureBaselineSnapshotJob::class); Bus::assertNotDispatched(CaptureBaselineSnapshotJob::class);
}); });

View File

@ -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 public function all(): array
{ {
return array_values(array_merge($this->inner->all(), [ return array_values(array_merge(parent::all(), [
new GovernanceSubjectType( new GovernanceSubjectType(
domainKey: GovernanceDomainKey::Entra, domainKey: GovernanceDomainKey::Entra,
subjectClass: GovernanceSubjectClass::Control, 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);
}
} }

View File

@ -2,6 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Pages\Monitoring\EvidenceOverview;
use App\Filament\Resources\EvidenceSnapshotResource; use App\Filament\Resources\EvidenceSnapshotResource;
use App\Models\EvidenceSnapshot; use App\Models\EvidenceSnapshot;
use App\Models\Tenant; use App\Models\Tenant;
@ -10,6 +11,7 @@
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures; use Tests\Feature\Concerns\BuildsGovernanceArtifactTruthFixtures;
uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class); uses(RefreshDatabase::class, BuildsGovernanceArtifactTruthFixtures::class);
@ -122,3 +124,56 @@
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $staleSnapshot], tenant: $staleTenant), false) ->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $staleSnapshot], tenant: $staleTenant), false)
->assertSee(EvidenceSnapshotResource::getUrl('view', ['record' => $freshSnapshot], tenant: $freshTenant), 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(),
]);
});

View File

@ -80,7 +80,7 @@ function baselineCompareEvidenceGapBuckets(): array
expect($table->isSearchable())->toBeTrue(); expect($table->isSearchable())->toBeTrue();
expect($table->getDefaultSortColumn())->toBe('reason_label'); expect($table->getDefaultSortColumn())->toBe('reason_label');
expect($table->getColumn('reason_label')?->isSearchable())->toBeTrue(); expect($table->getColumn('reason_label')?->isSearchable())->toBeTrue();
expect($table->getColumn('policy_type')?->isSearchable())->toBeTrue(); expect($table->getColumn('governed_subject_label')?->isSearchable())->toBeTrue();
expect($table->getColumn('subject_class_label')?->isSearchable())->toBeTrue(); expect($table->getColumn('subject_class_label')?->isSearchable())->toBeTrue();
expect($table->getColumn('resolution_outcome_label')?->isSearchable())->toBeTrue(); expect($table->getColumn('resolution_outcome_label')?->isSearchable())->toBeTrue();
expect($table->getColumn('operator_action_category_label')?->isSearchable())->toBeTrue(); expect($table->getColumn('operator_action_category_label')?->isSearchable())->toBeTrue();
@ -90,7 +90,7 @@ function baselineCompareEvidenceGapBuckets(): array
->assertSee('WiFi-Corp-Profile') ->assertSee('WiFi-Corp-Profile')
->assertSee('Deleted-Policy-ABC') ->assertSee('Deleted-Policy-ABC')
->assertSee('Reason') ->assertSee('Reason')
->assertSee('Policy type') ->assertSee('Governed subject')
->assertSee('Subject class') ->assertSee('Subject class')
->assertSee('Outcome') ->assertSee('Outcome')
->assertSee('Next action') ->assertSee('Next action')
@ -119,6 +119,7 @@ function baselineCompareEvidenceGapBuckets(): array
->assertSee('Retired-Compliance-Policy') ->assertSee('Retired-Compliance-Policy')
->assertDontSee('VPN-Always-On') ->assertDontSee('VPN-Always-On')
->filterTable('policy_type', 'deviceCompliancePolicy') ->filterTable('policy_type', 'deviceCompliancePolicy')
->assertSee('Device Compliance')
->assertSee('Retired-Compliance-Policy') ->assertSee('Retired-Compliance-Policy')
->assertDontSee('Deleted-Policy-ABC') ->assertDontSee('Deleted-Policy-ABC')
->filterTable('operator_action_category', 'run_policy_sync_or_backup') ->filterTable('operator_action_category', 'run_policy_sync_or_backup')

View File

@ -9,6 +9,7 @@
use App\Models\OperationRun; use App\Models\OperationRun;
use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineCompareStats; use App\Support\Baselines\BaselineCompareStats;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Ui\OperatorExplanation\ExplanationFamily; use App\Support\Ui\OperatorExplanation\ExplanationFamily;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Livewire\Livewire; use Livewire\Livewire;
@ -74,14 +75,22 @@
$stats = BaselineCompareStats::forTenant($tenant); $stats = BaselineCompareStats::forTenant($tenant);
$explanation = $stats->operatorExplanation(); $explanation = $stats->operatorExplanation();
$summary = $stats->summaryAssessment(); $summary = $stats->summaryAssessment();
$reasonSemantics = app(ReasonPresenter::class)->semantics(
app(ReasonPresenter::class)->forArtifactTruth(BaselineCompareReasonCode::CoverageUnproven->value, 'artifact_truth'),
);
expect($explanation->family)->toBe(ExplanationFamily::SuppressedOutput); expect($explanation->family)->toBe(ExplanationFamily::SuppressedOutput);
expect($reasonSemantics)->not->toBeNull();
Livewire::actingAs($user) Livewire::actingAs($user)
->test(BaselineCompareLanding::class) ->test(BaselineCompareLanding::class)
->assertSee($summary->headline) ->assertSee($summary->headline)
->assertSee($explanation->trustworthinessLabel()) ->assertSee($explanation->trustworthinessLabel())
->assertSee($summary->nextActionLabel()) ->assertSee($summary->nextActionLabel())
->assertSee('Reason owner')
->assertSee($reasonSemantics['owner_label'])
->assertSee('Platform reason family')
->assertSee($reasonSemantics['family_label'])
->assertSee('Findings shown') ->assertSee('Findings shown')
->assertSee('Evidence gaps'); ->assertSee('Evidence gaps');
}); });

View File

@ -48,7 +48,7 @@
$this->actingAs($user) $this->actingAs($user)
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin')) ->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
->assertOk() ->assertOk()
->assertSeeInOrder(['Artifact truth', 'Coverage summary', 'Captured policy types', 'Technical detail']) ->assertSeeInOrder(['Artifact truth', 'Coverage summary', 'Captured governed subjects', 'Technical detail'])
->assertSee('Reference only') ->assertSee('Reference only')
->assertSee('Inventory metadata') ->assertSee('Inventory metadata')
->assertSee('Metadata-only evidence was captured for this item.') ->assertSee('Metadata-only evidence was captured for this item.')
@ -111,7 +111,7 @@ public function render(BaselineSnapshotItem $item): RenderedSnapshotItem
->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin')) ->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
->assertOk() ->assertOk()
->assertSee('Technical detail') ->assertSee('Technical detail')
->assertSee('Structured rendering failed for this policy type. Fallback metadata is shown instead.') ->assertSee('Structured rendering failed for this governed subject family. Fallback metadata is shown instead.')
->assertSee('Bitlocker Require') ->assertSee('Bitlocker Require')
->assertSee('A fallback renderer is being used for this item.'); ->assertSee('A fallback renderer is being used for this item.');
}); });

View File

@ -8,7 +8,7 @@
use App\Models\BaselineSnapshot; use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem; use App\Models\BaselineSnapshotItem;
it('renders the baseline snapshot detail page as summary-first with grouped policy browsing', function (): void { it('renders the baseline snapshot detail page as summary-first with grouped governed-subject browsing', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly');
$profile = BaselineProfile::factory()->active()->create([ $profile = BaselineProfile::factory()->active()->create([
@ -93,13 +93,14 @@
->assertSee('Capture timing') ->assertSee('Capture timing')
->assertSee('Related context') ->assertSee('Related context')
->assertSee(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'), false) ->assertSee(BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'), false)
->assertSeeInOrder(['Security Baseline', 'Coverage summary', 'Captured policy types', 'Technical detail']) ->assertSeeInOrder(['Security Baseline', 'Coverage summary', 'Captured governed subjects', 'Technical detail'])
->assertSee('Security Reader') ->assertSee('Security Reader')
->assertSee('Bitlocker Require') ->assertSee('Bitlocker Require')
->assertSee('Mystery Policy') ->assertSee('Mystery Policy')
->assertSee('Intune RBAC Role Definition') ->assertSee('Intune RBAC Role Definition')
->assertSee('Device Compliance') ->assertSee('Device Compliance')
->assertSee('Mystery Policy Type') ->assertSee('Mystery Policy Type')
->assertSee('Governed subject')
->assertDontSee('Intune RBAC Role Definition References'); ->assertDontSee('Intune RBAC Role Definition References');
$this->actingAs($user) $this->actingAs($user)

View File

@ -77,7 +77,7 @@
$this->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin')) $this->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'))
->assertOk() ->assertOk()
->assertSeeInOrder(['Security Baseline', 'Captured policy types', 'Technical detail']); ->assertSeeInOrder(['Security Baseline', 'Captured governed subjects', 'Technical detail']);
$this->get(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant)) $this->get(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant))
->assertOk() ->assertOk()

View File

@ -40,6 +40,8 @@
Livewire::actingAs($user) Livewire::actingAs($user)
->test(EvidenceOverview::class) ->test(EvidenceOverview::class)
->assertCountTableRecords(1)
->assertCanSeeTableRecords([(string) $snapshot->getKey()])
->assertSee($tenant->name) ->assertSee($tenant->name)
->assertSee('Artifact truth'); ->assertSee('Artifact truth');

View File

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

View File

@ -9,8 +9,10 @@
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload; use App\Support\Inventory\InventoryCoverage as InventoryCoveragePayload;
use App\Support\Operations\ExecutionDenialReasonCode;
use App\Support\OperationRunOutcome; use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus; use App\Support\OperationRunStatus;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Filament\Facades\Filament; use Filament\Facades\Filament;
use Illuminate\Testing\TestResponse; use Illuminate\Testing\TestResponse;
@ -303,6 +305,47 @@ function baselineCompareGapContext(array $overrides = []): array
->assertSee('Adapter reconciler'); ->assertSee('Adapter reconciler');
}); });
it('renders explicit reason-owner and platform-family semantics for blocked runs', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
Filament::setTenant(null, true);
$run = OperationRun::factory()->create([
'workspace_id' => (int) $tenant->workspace_id,
'tenant_id' => (int) $tenant->getKey(),
'type' => 'inventory_sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Blocked->value,
'context' => [
'reason_code' => ExecutionDenialReasonCode::MissingCapability->value,
'execution_legitimacy' => [
'reason_code' => ExecutionDenialReasonCode::MissingCapability->value,
],
],
'failure_summary' => [[
'code' => 'operation.blocked',
'reason_code' => ExecutionDenialReasonCode::MissingCapability->value,
'message' => 'Operation blocked because the initiating actor no longer has the required capability.',
]],
]);
$reasonSemantics = app(ReasonPresenter::class)->semantics(
app(ReasonPresenter::class)->forOperationRun($run, 'run_detail'),
);
expect($reasonSemantics)->not->toBeNull();
$this->actingAs($user)
->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(route('admin.operations.view', ['run' => (int) $run->getKey()]))
->assertOk()
->assertSee('Explanation semantics')
->assertSee('Reason owner')
->assertSee($reasonSemantics['owner_label'])
->assertSee('Platform reason family')
->assertSee($reasonSemantics['family_label']);
});
it('renders evidence gap details section for baseline compare runs with gap subjects', function (): void { it('renders evidence gap details section for baseline compare runs with gap subjects', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner'); [$user, $tenant] = createUserWithTenant(role: 'owner');
@ -340,7 +383,7 @@ function baselineCompareGapContext(array $overrides = []): array
->assertSee('2 affected') ->assertSee('2 affected')
->assertSee('WiFi-Corp-Profile') ->assertSee('WiFi-Corp-Profile')
->assertSee('Deleted-Policy-ABC') ->assertSee('Deleted-Policy-ABC')
->assertSee('Policy type') ->assertSee('Governed subject')
->assertSee('Subject class') ->assertSee('Subject class')
->assertSee('Outcome') ->assertSee('Outcome')
->assertSee('Next action') ->assertSee('Next action')

View File

@ -160,11 +160,49 @@ function operationRunFilterIndicatorLabels($component): array
expect($filter)->not->toBeNull(); expect($filter)->not->toBeNull();
expect($filter?->getOptions())->toBe([ expect($filter?->getOptions())->toBe([
'inventory_sync' => 'Inventory sync', 'inventory.sync' => 'Inventory sync',
'policy.sync' => 'Policy sync', 'policy.sync' => 'Policy sync',
]); ]);
}); });
it('filters legacy and provider-prefixed inventory runs through one canonical operation selection', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$legacyRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'inventory_sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
$providerPrefixedRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'provider.inventory.sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
$otherRun = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'policy.sync',
'status' => OperationRunStatus::Completed->value,
'outcome' => OperationRunOutcome::Succeeded->value,
]);
Filament::setTenant($tenant, true);
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
Livewire::actingAs($user)
->test(Operations::class)
->filterTable('type', 'inventory.sync')
->assertCanSeeTableRecords([$legacyRun, $providerPrefixedRun])
->assertCanNotSeeTableRecords([$otherRun]);
});
it('clears tenant-sensitive persisted filters when the canonical tenant context changes', function (): void { it('clears tenant-sensitive persisted filters when the canonical tenant context changes', function (): void {
$tenantA = Tenant::factory()->create(); $tenantA = Tenant::factory()->create();
[$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner'); [$user, $tenantA] = createUserWithTenant(tenant: $tenantA, role: 'owner');

View File

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

View File

@ -29,7 +29,9 @@
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php', 'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php', 'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php',
'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php', 'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php',
'app/Filament/Pages/TenantRequiredPermissions.php',
'app/Filament/Pages/InventoryCoverage.php', 'app/Filament/Pages/InventoryCoverage.php',
'app/Filament/Pages/Monitoring/EvidenceOverview.php',
'app/Filament/System/Pages/Directory/Tenants.php', 'app/Filament/System/Pages/Directory/Tenants.php',
'app/Filament/System/Pages/Directory/Workspaces.php', 'app/Filament/System/Pages/Directory/Workspaces.php',
'app/Filament/System/Pages/Ops/Runs.php', 'app/Filament/System/Pages/Ops/Runs.php',
@ -39,6 +41,7 @@
'app/Filament/System/Pages/RepairWorkspaceOwners.php', 'app/Filament/System/Pages/RepairWorkspaceOwners.php',
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php', 'app/Filament/Widgets/Dashboard/RecentDriftFindings.php',
'app/Filament/Widgets/Dashboard/RecentOperations.php', 'app/Filament/Widgets/Dashboard/RecentOperations.php',
'app/Livewire/InventoryItemDependencyEdgesTable.php',
'app/Livewire/BackupSetPolicyPickerTable.php', 'app/Livewire/BackupSetPolicyPickerTable.php',
'app/Livewire/EntraGroupCachePickerTable.php', 'app/Livewire/EntraGroupCachePickerTable.php',
'app/Livewire/SettingsCatalogSettingsTable.php', 'app/Livewire/SettingsCatalogSettingsTable.php',
@ -81,7 +84,9 @@
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php' => ['->emptyStateHeading('], 'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php' => ['->emptyStateHeading('],
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php' => ['->emptyStateHeading('], 'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php' => ['->emptyStateHeading('],
'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.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/InventoryCoverage.php' => ['->emptyStateHeading('],
'app/Filament/Pages/Monitoring/EvidenceOverview.php' => ['->emptyStateHeading('],
'app/Filament/System/Pages/Directory/Tenants.php' => ['->emptyStateHeading('], 'app/Filament/System/Pages/Directory/Tenants.php' => ['->emptyStateHeading('],
'app/Filament/System/Pages/Directory/Workspaces.php' => ['->emptyStateHeading('], 'app/Filament/System/Pages/Directory/Workspaces.php' => ['->emptyStateHeading('],
'app/Filament/System/Pages/Ops/Runs.php' => ['->emptyStateHeading('], 'app/Filament/System/Pages/Ops/Runs.php' => ['->emptyStateHeading('],
@ -91,6 +96,7 @@
'app/Filament/System/Pages/RepairWorkspaceOwners.php' => ['->emptyStateHeading('], 'app/Filament/System/Pages/RepairWorkspaceOwners.php' => ['->emptyStateHeading('],
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php' => ['->emptyStateHeading('], 'app/Filament/Widgets/Dashboard/RecentDriftFindings.php' => ['->emptyStateHeading('],
'app/Filament/Widgets/Dashboard/RecentOperations.php' => ['->emptyStateHeading('], 'app/Filament/Widgets/Dashboard/RecentOperations.php' => ['->emptyStateHeading('],
'app/Livewire/InventoryItemDependencyEdgesTable.php' => ['->emptyStateHeading('],
'app/Livewire/BackupSetPolicyPickerTable.php' => ['->emptyStateHeading('], 'app/Livewire/BackupSetPolicyPickerTable.php' => ['->emptyStateHeading('],
'app/Livewire/EntraGroupCachePickerTable.php' => ['->emptyStateHeading('], 'app/Livewire/EntraGroupCachePickerTable.php' => ['->emptyStateHeading('],
'app/Livewire/SettingsCatalogSettingsTable.php' => ['->emptyStateHeading('], 'app/Livewire/SettingsCatalogSettingsTable.php' => ['->emptyStateHeading('],
@ -134,6 +140,8 @@
'app/Filament/Resources/EntraGroupResource.php', 'app/Filament/Resources/EntraGroupResource.php',
'app/Filament/Resources/OperationRunResource.php', 'app/Filament/Resources/OperationRunResource.php',
'app/Filament/Resources/BaselineSnapshotResource.php', 'app/Filament/Resources/BaselineSnapshotResource.php',
'app/Filament/Pages/TenantRequiredPermissions.php',
'app/Filament/Pages/Monitoring/EvidenceOverview.php',
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php', 'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
]; ];
@ -310,7 +318,9 @@
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php', 'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php', 'app/Filament/Resources/PolicyResource/RelationManagers/VersionsRelationManager.php',
'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php', 'app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleOperationRunsRelationManager.php',
'app/Filament/Pages/TenantRequiredPermissions.php',
'app/Filament/Pages/InventoryCoverage.php', 'app/Filament/Pages/InventoryCoverage.php',
'app/Filament/Pages/Monitoring/EvidenceOverview.php',
'app/Filament/System/Pages/Directory/Tenants.php', 'app/Filament/System/Pages/Directory/Tenants.php',
'app/Filament/System/Pages/Directory/Workspaces.php', 'app/Filament/System/Pages/Directory/Workspaces.php',
'app/Filament/System/Pages/Ops/Runs.php', 'app/Filament/System/Pages/Ops/Runs.php',
@ -320,6 +330,7 @@
'app/Filament/System/Pages/RepairWorkspaceOwners.php', 'app/Filament/System/Pages/RepairWorkspaceOwners.php',
'app/Filament/Widgets/Dashboard/RecentDriftFindings.php', 'app/Filament/Widgets/Dashboard/RecentDriftFindings.php',
'app/Filament/Widgets/Dashboard/RecentOperations.php', 'app/Filament/Widgets/Dashboard/RecentOperations.php',
'app/Livewire/InventoryItemDependencyEdgesTable.php',
'app/Livewire/BackupSetPolicyPickerTable.php', 'app/Livewire/BackupSetPolicyPickerTable.php',
'app/Livewire/EntraGroupCachePickerTable.php', 'app/Livewire/EntraGroupCachePickerTable.php',
'app/Livewire/SettingsCatalogSettingsTable.php', 'app/Livewire/SettingsCatalogSettingsTable.php',
@ -337,6 +348,85 @@
expect($missing)->toBeEmpty('Missing pagination profile helper usage: '.implode(', ', $missing)); 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 { it('keeps tenant-registry recovery triage columns, filters, and query hydration explicit', function (): void {
$patternByPath = [ $patternByPath = [
'app/Filament/Resources/TenantResource.php' => [ 'app/Filament/Resources/TenantResource.php' => [

View File

@ -6,6 +6,10 @@
$compareJob = file_get_contents(base_path('app/Jobs/CompareBaselineToTenantJob.php')); $compareJob = file_get_contents(base_path('app/Jobs/CompareBaselineToTenantJob.php'));
expect($compareJob)->toBeString(); expect($compareJob)->toBeString();
expect($compareJob)->toContain('CurrentStateHashResolver'); 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(');
expect($compareJob)->not->toContain('::fingerprint('); expect($compareJob)->not->toContain('::fingerprint(');

View File

@ -7,6 +7,24 @@
'PolicyNormalizer', 'PolicyNormalizer',
'VersionDiff', 'VersionDiff',
'flattenForDiff', 'flattenForDiff',
'computeDrift(',
'effectiveBaselineHash(',
'resolveBaselinePolicyVersionId(',
'selectSummaryKind(',
'buildDriftEvidenceContract(',
'buildRoleDefinitionEvidencePayload(',
'resolveRoleDefinitionVersion(',
'fallbackRoleDefinitionNormalized(',
'roleDefinitionChangedKeys(',
'roleDefinitionPermissionKeys(',
'resolveRoleDefinitionDiff(',
'severityForRoleDefinitionDiff(',
'BaselinePolicyVersionResolver',
'DriftHasher',
'SettingsNormalizer',
'AssignmentsNormalizer',
'ScopeTagsNormalizer',
'IntuneRoleDefinitionNormalizer',
]; ];
$captureForbiddenTokens = [ $captureForbiddenTokens = [
@ -20,6 +38,9 @@
$compareJob = file_get_contents(base_path('app/Jobs/CompareBaselineToTenantJob.php')); $compareJob = file_get_contents(base_path('app/Jobs/CompareBaselineToTenantJob.php'));
expect($compareJob)->toBeString(); expect($compareJob)->toBeString();
expect($compareJob)->toContain('CurrentStateHashResolver'); expect($compareJob)->toContain('CurrentStateHashResolver');
expect($compareJob)->toContain('compareStrategyRegistry->select(');
expect($compareJob)->toContain('compareStrategyRegistry->resolve(');
expect($compareJob)->toContain('$strategy->compare(');
foreach ($compareForbiddenTokens as $token) { foreach ($compareForbiddenTokens as $token) {
expect($compareJob)->not->toContain($token); expect($compareJob)->not->toContain($token);

View File

@ -41,7 +41,7 @@
->assertSee('Last known: Ghost Target'); ->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(); [$user, $tenant] = createUserWithTenant();
$this->actingAs($user); $this->actingAs($user);
@ -51,34 +51,48 @@
'external_id' => (string) Str::uuid(), 'external_id' => (string) Str::uuid(),
]); ]);
$inboundSource = InventoryItem::factory()->create([
'tenant_id' => $tenant->getKey(),
'external_id' => (string) Str::uuid(),
'display_name' => 'Inbound Source',
]);
// Outbound only // Outbound only
InventoryLink::factory()->create([ InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item', 'source_type' => 'inventory_item',
'source_id' => $item->external_id, 'source_id' => $item->external_id,
'target_type' => 'foundation_object', 'target_type' => 'missing',
'target_id' => (string) Str::uuid(), 'target_id' => null,
'relationship_type' => 'assigned_to', 'relationship_type' => 'assigned_to',
'metadata' => [
'last_known_name' => 'Assigned Target',
],
]); ]);
// Inbound only // Inbound only
InventoryLink::factory()->create([ InventoryLink::factory()->create([
'tenant_id' => $tenant->getKey(), 'tenant_id' => $tenant->getKey(),
'source_type' => 'inventory_item', 'source_type' => 'inventory_item',
'source_id' => (string) Str::uuid(), 'source_id' => $inboundSource->external_id,
'target_type' => 'inventory_item', 'target_type' => 'inventory_item',
'target_id' => $item->external_id, 'target_id' => $item->external_id,
'relationship_type' => 'depends_on', 'relationship_type' => 'depends_on',
]); ]);
$urlOutbound = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound'; $url = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=outbound';
$this->get($urlOutbound)->assertOk()->assertDontSee('No dependencies found');
$urlInbound = InventoryItemResource::getUrl('view', ['record' => $item], panel: 'admin').'?tenant='.(string) $tenant->external_id.'&direction=inbound'; $this->get($url)
$this->get($urlInbound)->assertOk()->assertDontSee('No dependencies found'); ->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(); [$user, $tenant] = createUserWithTenant();
$this->actingAs($user); $this->actingAs($user);
@ -115,7 +129,7 @@
$this->get($url) $this->get($url)
->assertOk() ->assertOk()
->assertSee('Scoped Target') ->assertSee('Scoped Target')
->assertDontSee('Assigned Target'); ->assertSee('Assigned Target');
}); });
it('does not show edges from other tenants (tenant isolation)', function () { it('does not show edges from other tenants (tenant isolation)', function () {

View File

@ -61,3 +61,21 @@
->assertSee('Back to Operations') ->assertSee('Back to Operations')
->assertDontSee('← Back to Archived Tenant'); ->assertDontSee('← Back to Archived Tenant');
}); });
it('resolves legacy operation values to canonical operation metadata on read paths', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->actingAs($user);
$run = OperationRun::factory()->for($tenant)->create([
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'backup_schedule_run',
]);
expect($run->canonicalOperationType())->toBe('backup.schedule.execute');
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id])
->get(OperationRunLinks::tenantlessView($run))
->assertOk()
->assertSee('Backup schedule run')
->assertDontSee('backup_schedule_run');
});

View File

@ -2,12 +2,14 @@
declare(strict_types=1); declare(strict_types=1);
use App\Filament\Pages\TenantRequiredPermissions;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\TenantPermission; use App\Models\TenantPermission;
use App\Models\User; use App\Models\User;
use App\Models\Workspace; use App\Models\Workspace;
use App\Models\WorkspaceMembership; use App\Models\WorkspaceMembership;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire;
it('keeps the route tenant authoritative when tenant-like query values are present', function (): void { it('keeps the route tenant authoritative when tenant-like query values are present', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'readonly'); [$user, $tenant] = createUserWithTenant(role: 'readonly');
@ -54,7 +56,7 @@
$response $response
->assertSee($tenant->getFilamentName()) ->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 { 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') ->get('/admin/tenants/'.$tenant->external_id.'/required-permissions')
->assertNotFound(); ->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();
});

View File

@ -4,6 +4,7 @@
use App\Support\Baselines\BaselineCompareReasonCode; use App\Support\Baselines\BaselineCompareReasonCode;
use App\Support\Baselines\BaselineReasonCodes; use App\Support\Baselines\BaselineReasonCodes;
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\ReasonTranslation\ReasonPresenter; use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Ui\OperatorExplanation\TrustworthinessLevel; use App\Support\Ui\OperatorExplanation\TrustworthinessLevel;
@ -40,3 +41,15 @@
'missing_input', 'missing_input',
], ],
]); ]);
it('keeps governance reason ownership and family semantics explicit', function (): void {
$presenter = app(ReasonPresenter::class);
$semantics = $presenter->semantics(
$presenter->forArtifactTruth(BaselineCompareReasonCode::CoverageUnproven->value, 'artifact_truth'),
);
expect($semantics)->not->toBeNull()
->and($semantics['owner_label'] ?? null)->toBe('Governance detail')
->and($semantics['family_label'] ?? null)->toBe('Coverage')
->and($semantics['boundary_classification'] ?? null)->toBe(PlatformVocabularyGlossary::BOUNDARY_CROSS_DOMAIN_GOVERNANCE);
});

View File

@ -11,6 +11,8 @@
use App\Models\Tenant; use App\Models\Tenant;
use App\Services\Evidence\EvidenceSnapshotService; use App\Services\Evidence\EvidenceSnapshotService;
use App\Support\Evidence\EvidenceSnapshotStatus; use App\Support\Evidence\EvidenceSnapshotStatus;
use App\Support\Providers\ProviderReasonCodes;
use App\Support\ReasonTranslation\ReasonPresenter;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Livewire\Livewire; use Livewire\Livewire;
@ -184,6 +186,51 @@ function seedWidgetReviewPackSnapshot(Tenant $tenant): EvidenceSnapshot
->assertDontSee('wire:poll.10s', escape: false); ->assertDontSee('wire:poll.10s', escape: false);
}); });
it('renders translated reason semantics for failed review-pack runs when available', function (): void {
$tenant = Tenant::factory()->create();
[$user, $tenant] = createUserWithTenant(tenant: $tenant, role: 'owner');
$run = OperationRun::factory()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'type' => 'tenant.review_pack.generate',
'status' => 'completed',
'outcome' => 'failed',
'context' => [
'reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
],
'failure_summary' => [[
'code' => 'operation.failed',
'reason_code' => ProviderReasonCodes::ProviderPermissionMissing,
'message' => 'The provider app is missing a required permission.',
]],
]);
ReviewPack::factory()->failed()->create([
'tenant_id' => (int) $tenant->getKey(),
'workspace_id' => (int) $tenant->workspace_id,
'initiated_by_user_id' => (int) $user->getKey(),
'operation_run_id' => (int) $run->getKey(),
]);
$reasonSemantics = app(ReasonPresenter::class)->semantics(
app(ReasonPresenter::class)->forOperationRun($run, 'review_pack_widget'),
);
$reasonEnvelope = app(ReasonPresenter::class)->forOperationRun($run, 'review_pack_widget');
expect($reasonEnvelope)->not->toBeNull()
->and($reasonSemantics)->not->toBeNull();
setTenantPanelContext($tenant);
Livewire::actingAs($user)
->test(TenantReviewPackCard::class, ['record' => $tenant])
->assertSee($reasonEnvelope->operatorLabel)
->assertSee($reasonEnvelope->shortExplanation)
->assertSee($reasonSemantics['owner_label'])
->assertSee($reasonSemantics['family_label']);
});
// ─── Expired State ─────────────────────────────────────────── // ─── Expired State ───────────────────────────────────────────
it('shows generate action for an expired pack', function (): void { it('shows generate action for an expired pack', function (): void {

View File

@ -5,6 +5,7 @@
use App\Filament\Pages\Reviews\ReviewRegister; use App\Filament\Pages\Reviews\ReviewRegister;
use App\Filament\Resources\TenantReviewResource; use App\Filament\Resources\TenantReviewResource;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\ReasonTranslation\ReasonPresenter;
use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter; use App\Support\Ui\GovernanceArtifactTruth\ArtifactTruthPresenter;
use App\Support\Workspaces\WorkspaceContext; use App\Support\Workspaces\WorkspaceContext;
use Livewire\Livewire; use Livewire\Livewire;
@ -32,6 +33,9 @@
$truth = app(ArtifactTruthPresenter::class)->forTenantReview($review); $truth = app(ArtifactTruthPresenter::class)->forTenantReview($review);
$explanation = $truth->operatorExplanation; $explanation = $truth->operatorExplanation;
$reasonSemantics = app(ReasonPresenter::class)->semantics($truth->reason?->toReasonResolutionEnvelope());
expect($reasonSemantics)->not->toBeNull();
setTenantPanelContext($tenant); setTenantPanelContext($tenant);
@ -39,7 +43,11 @@
->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant)) ->get(TenantReviewResource::tenantScopedUrl('view', ['record' => $review], $tenant))
->assertOk() ->assertOk()
->assertSee($explanation?->headline ?? '') ->assertSee($explanation?->headline ?? '')
->assertSee($explanation?->nextActionText ?? ''); ->assertSee($explanation?->nextActionText ?? '')
->assertSee('Reason owner')
->assertSee($reasonSemantics['owner_label'])
->assertSee('Platform reason family')
->assertSee($reasonSemantics['family_label']);
setAdminPanelContext(); setAdminPanelContext();
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id); session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);

View File

@ -43,3 +43,11 @@
static fn ($subjectType): bool => $subjectType->domainKey === GovernanceDomainKey::Entra, static fn ($subjectType): bool => $subjectType->domainKey === GovernanceDomainKey::Entra,
))->toBeFalse(); ))->toBeFalse();
}); });
it('finds subject types by subject key across legacy buckets and exposes contributor ownership metadata', function (): void {
$registry = app(GovernanceSubjectTaxonomyRegistry::class);
expect($registry->findBySubjectTypeKey('deviceConfiguration')?->domainKey)->toBe(GovernanceDomainKey::Intune)
->and($registry->findBySubjectTypeKey('assignmentFilter', 'foundation_types')?->subjectClass)->toBe(GovernanceSubjectClass::ConfigurationResource)
->and($registry->ownershipDescriptor()->canonicalNouns)->toBe(['domain_key', 'subject_class', 'subject_type_key', 'subject_type_label']);
});

View File

@ -108,6 +108,7 @@
->and(data_get($rendered, 'snapshot.overallFidelity'))->toBe('partial') ->and(data_get($rendered, 'snapshot.overallFidelity'))->toBe('partial')
->and(data_get($rendered, 'snapshot.overallGapCount'))->toBe(1) ->and(data_get($rendered, 'snapshot.overallGapCount'))->toBe(1)
->and($rendered['summaryRows'])->toHaveCount(3) ->and($rendered['summaryRows'])->toHaveCount(3)
->and(data_get($rendered, 'summaryRows.0.subjectDescriptor.platform_noun'))->toBe('Governed subject')
->and(collect($rendered['groups'])->pluck('label')->all()) ->and(collect($rendered['groups'])->pluck('label')->all())
->toContain('Intune RBAC Role Definition', 'Device Compliance', 'Mystery Policy Type') ->toContain('Intune RBAC Role Definition', 'Device Compliance', 'Mystery Policy Type')
->and(data_get($rendered, 'technicalDetail.defaultCollapsed'))->toBeTrue(); ->and(data_get($rendered, 'technicalDetail.defaultCollapsed'))->toBeTrue();

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
use App\Support\Governance\GovernanceDomainKey;
use App\Support\Governance\GovernanceSubjectClass;
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
it('normalizes legacy policy_type payloads into governed-subject descriptors', function (): void {
$result = app(PlatformSubjectDescriptorNormalizer::class)->fromArray([
'policy_type' => 'deviceConfiguration',
], 'baseline_compare');
expect($result->usedLegacyAlias)->toBeTrue()
->and($result->descriptor->domainKey)->toBe(GovernanceDomainKey::Intune->value)
->and($result->descriptor->subjectClass)->toBe(GovernanceSubjectClass::Policy->value)
->and($result->descriptor->subjectTypeKey)->toBe('deviceConfiguration')
->and($result->descriptor->platformNoun)->toBe('Governed subject');
});
it('builds governed-subject descriptors for canonical baseline scope entries', function (): void {
$descriptors = app(PlatformSubjectDescriptorNormalizer::class)->descriptorsForScopeEntries([
[
'domain_key' => GovernanceDomainKey::PlatformFoundation->value,
'subject_class' => GovernanceSubjectClass::ConfigurationResource->value,
'subject_type_keys' => ['assignmentFilter'],
'filters' => [],
],
]);
expect($descriptors)->toHaveCount(1)
->and(data_get($descriptors, '0.descriptor.subject_type_key'))->toBe('assignmentFilter')
->and(data_get($descriptors, '0.descriptor.domain_key'))->toBe(GovernanceDomainKey::PlatformFoundation->value)
->and(data_get($descriptors, '0.source_surface'))->toBe('baseline_scope');
});
it('falls back to compatibility-only descriptors for unknown subject types', function (): void {
$result = app(PlatformSubjectDescriptorNormalizer::class)->fromArray([
'policy_type' => 'mysterySubject',
], 'snapshot');
expect($result->usedLegacyAlias)->toBeTrue()
->and($result->descriptor->subjectTypeKey)->toBe('mysterySubject')
->and($result->warnings)->not->toBeEmpty();
});

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use App\Support\Governance\PlatformVocabularyGlossary;
it('publishes canonical term inventory with boundary classifications and retirement metadata', function (): void {
$glossary = app(PlatformVocabularyGlossary::class);
expect($glossary->canonicalTerms())
->toContain('governed_subject', 'operation_type', 'policy_type')
->and($glossary->term('governed_subject')?->boundaryClassification)->toBe(PlatformVocabularyGlossary::BOUNDARY_PLATFORM_CORE)
->and($glossary->term('governed_subject')?->legacyAliases)->toContain('policy_type')
->and($glossary->term('governed_subject')?->aliasRetirementPath)->toContain('policy_type');
});
it('prefers exact Intune-specific terms while still resolving platform aliases by context', function (): void {
$glossary = app(PlatformVocabularyGlossary::class);
expect($glossary->term('policy_type')?->boundaryClassification)->toBe(PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC)
->and($glossary->resolveAlias('policy_type', 'compare')?->termKey)->toBe('governed_subject')
->and($glossary->resolveAlias('policy_type', 'intune_adapter'))->toBeNull();
});
it('publishes contributor-facing registry, alias, and reason-namespace inventories', function (): void {
$glossary = app(PlatformVocabularyGlossary::class);
$termInventory = $glossary->termInventory();
$aliasInventory = $glossary->aliasRetirementInventory();
$registryInventory = $glossary->registryInventory();
$reasonNamespaceInventory = $glossary->reasonNamespaceInventory();
expect($termInventory['operation_type']['boundary_classification'] ?? null)->toBe(PlatformVocabularyGlossary::BOUNDARY_PLATFORM_CORE)
->and($aliasInventory['governed_subject']['canonical_name'] ?? null)->toBe('governed_subject')
->and($aliasInventory['governed_subject']['legacy_aliases'] ?? [])->toContain('policy_type')
->and($registryInventory['operation_catalog']['canonical_nouns'] ?? [])->toContain('operation_type')
->and($reasonNamespaceInventory['governance.baseline_compare']['boundary_classification'] ?? null)->toBe(PlatformVocabularyGlossary::BOUNDARY_CROSS_DOMAIN_GOVERNANCE)
->and($reasonNamespaceInventory['rbac.intune']['boundary_classification'] ?? null)->toBe(PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC);
});

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
use App\Support\Governance\PlatformVocabularyGlossary;
it('resolves registry ownership descriptors from glossary metadata', function (): void {
$glossary = app(PlatformVocabularyGlossary::class);
$descriptor = $glossary->registry('governance_subject_taxonomy_registry');
expect($descriptor)->not->toBeNull()
->and($descriptor?->boundaryClassification)->toBe(PlatformVocabularyGlossary::BOUNDARY_CROSS_DOMAIN_GOVERNANCE)
->and($descriptor?->ownerLayer)->toBe(PlatformVocabularyGlossary::OWNER_PLATFORM_CORE)
->and($descriptor?->sourceClassOrFile)->toBe(GovernanceSubjectTaxonomyRegistry::class)
->and($descriptor?->canonicalNouns)->toBe(['domain_key', 'subject_class', 'subject_type_key', 'subject_type_label']);
});
it('exposes contributor-facing ownership metadata from the taxonomy registry seam', function (): void {
$descriptor = app(GovernanceSubjectTaxonomyRegistry::class)->ownershipDescriptor();
expect($descriptor->registryKey)->toBe('governance_subject_taxonomy_registry')
->and($descriptor->allowedConsumers)->toContain('compare', 'snapshot', 'review')
->and($descriptor->compatibilityNotes)->toContain('legacy policy-type payloads');
});

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use App\Support\OperationCatalog;
use App\Support\OperationRunType;
it('resolves legacy operation aliases to a canonical operator meaning', function (): void {
$resolution = OperationCatalog::resolve('inventory_sync');
expect($resolution->canonical->canonicalCode)->toBe('inventory.sync')
->and($resolution->canonical->displayLabel)->toBe('Inventory sync')
->and($resolution->aliasStatus)->toBe('legacy_alias')
->and($resolution->wasLegacyAlias)->toBeTrue()
->and(array_map(static fn ($alias): string => $alias->rawValue, $resolution->aliasesConsidered))
->toContain('inventory_sync', 'provider.inventory.sync');
});
it('deduplicates filter options by canonical operation code while preserving raw aliases for queries', function (): void {
expect(OperationCatalog::filterOptions(['inventory_sync', 'provider.inventory.sync', 'policy.sync']))->toBe([
'inventory.sync' => 'Inventory sync',
'policy.sync' => 'Policy sync',
])->and(OperationCatalog::rawValuesForCanonical('inventory.sync'))
->toContain('inventory_sync', 'provider.inventory.sync');
});
it('maps enum-backed storage values to canonical operation codes', function (): void {
expect(OperationRunType::BaselineCompare->canonicalCode())->toBe('baseline.compare')
->and(OperationRunType::DirectoryGroupsSync->canonicalCode())->toBe('directory.groups.sync');
});
it('publishes contributor-facing operation inventories and alias retirement metadata', function (): void {
$descriptor = OperationCatalog::ownershipDescriptor();
$canonicalInventory = OperationCatalog::canonicalInventory();
$aliasInventory = OperationCatalog::aliasInventory();
expect($descriptor->registryKey)->toBe('operation_catalog')
->and($descriptor->boundaryClassification)->toBe('platform_core')
->and($canonicalInventory['baseline.compare']['display_label'] ?? null)->toBe('Baseline compare')
->and($aliasInventory['baseline_compare']['canonical_name'] ?? null)->toBe('baseline.compare')
->and($aliasInventory['baseline_compare']['retirement_path'] ?? null)->toContain('baseline.compare');
});

View File

@ -29,6 +29,10 @@
expect($envelope)->not->toBeNull() expect($envelope)->not->toBeNull()
->and($envelope?->operatorLabel)->toBe('Dedicated credentials required') ->and($envelope?->operatorLabel)->toBe('Dedicated credentials required')
->and($envelope?->shortExplanation)->toContain('dedicated credentials are configured') ->and($envelope?->shortExplanation)->toContain('dedicated credentials are configured')
->and($envelope?->ownerLayer())->toBe('provider_owned')
->and($envelope?->ownerNamespace())->toBe('provider.microsoft_graph')
->and($envelope?->platformReasonFamily())->toBe('prerequisite')
->and(ProviderReasonCodes::boundaryClassification(ProviderReasonCodes::DedicatedCredentialMissing))->toBe('intune_specific')
->and($envelope?->toLegacyNextSteps()[0]['label'] ?? null)->toBe('Manage dedicated connection') ->and($envelope?->toLegacyNextSteps()[0]['label'] ?? null)->toBe('Manage dedicated connection')
->and($envelope?->toLegacyNextSteps()[0]['url'] ?? null)->toContain('/provider-connections/'); ->and($envelope?->toLegacyNextSteps()[0]['url'] ?? null)->toContain('/provider-connections/');
}); });
@ -39,5 +43,6 @@
expect($envelope)->not->toBeNull() expect($envelope)->not->toBeNull()
->and($envelope?->operatorLabel)->toBe('Provider configuration needs review') ->and($envelope?->operatorLabel)->toBe('Provider configuration needs review')
->and($envelope?->diagnosticCode())->toBe('ext.multiple_defaults_detected') ->and($envelope?->diagnosticCode())->toBe('ext.multiple_defaults_detected')
->and($envelope?->ownerLayer())->toBe('provider_owned')
->and($envelope?->guidanceText())->toBe('Next step: Review the provider connection before retrying.'); ->and($envelope?->guidanceText())->toBe('Next step: Review the provider connection before retrying.');
}); });

View File

@ -2,6 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\RbacReason; use App\Support\RbacReason;
it('translates manual RBAC assignment reasons into operator guidance', function (): void { it('translates manual RBAC assignment reasons into operator guidance', function (): void {
@ -10,6 +11,10 @@
expect($envelope->operatorLabel)->toBe('Manual role assignment required') expect($envelope->operatorLabel)->toBe('Manual role assignment required')
->and($envelope->actionability)->toBe('prerequisite_missing') ->and($envelope->actionability)->toBe('prerequisite_missing')
->and($envelope->shortExplanation)->toContain('manual Intune RBAC role assignment') ->and($envelope->shortExplanation)->toContain('manual Intune RBAC role assignment')
->and($envelope->ownerLayer())->toBe('domain_owned')
->and($envelope->ownerNamespace())->toBe('rbac.intune')
->and($envelope->platformReasonFamily())->toBe('authorization')
->and(RbacReason::ManualAssignmentRequired->boundaryClassification())->toBe(PlatformVocabularyGlossary::BOUNDARY_INTUNE_SPECIFIC)
->and($envelope->guidanceText())->toBe('Next step: Complete the Intune role assignment manually, then refresh RBAC status.'); ->and($envelope->guidanceText())->toBe('Next step: Complete the Intune role assignment manually, then refresh RBAC status.');
}); });
@ -18,5 +23,6 @@
expect($envelope->actionability)->toBe('non_actionable') expect($envelope->actionability)->toBe('non_actionable')
->and($envelope->operatorLabel)->toBe('RBAC API unsupported') ->and($envelope->operatorLabel)->toBe('RBAC API unsupported')
->and($envelope->ownerNamespace())->toBe('rbac.intune')
->and($envelope->guidanceText())->toBe('No action needed.'); ->and($envelope->guidanceText())->toBe('No action needed.');
}); });

View File

@ -4,6 +4,8 @@
use App\Support\ReasonTranslation\FallbackReasonTranslator; use App\Support\ReasonTranslation\FallbackReasonTranslator;
use App\Support\ReasonTranslation\NextStepOption; use App\Support\ReasonTranslation\NextStepOption;
use App\Support\ReasonTranslation\PlatformReasonFamily;
use App\Support\ReasonTranslation\ReasonOwnershipDescriptor;
use App\Support\ReasonTranslation\ReasonResolutionEnvelope; use App\Support\ReasonTranslation\ReasonResolutionEnvelope;
it('renders body lines and legacy next steps from the shared envelope', function (): void { it('renders body lines and legacy next steps from the shared envelope', function (): void {
@ -38,3 +40,25 @@
->and($envelope?->shortExplanation)->toContain('transient dependency issue') ->and($envelope?->shortExplanation)->toContain('transient dependency issue')
->and($envelope?->guidanceText())->toBe('Next step: Retry after the dependency recovers.'); ->and($envelope?->guidanceText())->toBe('Next step: Retry after the dependency recovers.');
}); });
it('round-trips explicit reason ownership and platform-family metadata', function (): void {
$envelope = new ReasonResolutionEnvelope(
internalCode: 'provider_consent_missing',
operatorLabel: 'Admin consent required',
shortExplanation: 'The provider connection cannot continue until admin consent is granted.',
actionability: 'prerequisite_missing',
reasonOwnership: new ReasonOwnershipDescriptor(
ownerLayer: 'provider_owned',
ownerNamespace: 'provider.microsoft_graph',
reasonCode: 'provider_consent_missing',
platformReasonFamily: PlatformReasonFamily::Prerequisite,
),
);
$restored = ReasonResolutionEnvelope::fromArray($envelope->toArray());
expect($restored)->not->toBeNull()
->and($restored?->ownerLayer())->toBe('provider_owned')
->and($restored?->ownerNamespace())->toBe('provider.microsoft_graph')
->and($restored?->platformReasonFamily())->toBe(PlatformReasonFamily::Prerequisite->value);
});

View File

@ -2,6 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
use App\Support\Governance\PlatformVocabularyGlossary;
use App\Support\Tenants\TenantOperabilityReasonCode; use App\Support\Tenants\TenantOperabilityReasonCode;
it('marks already archived tenant states as non-actionable while preserving diagnostics', function (): void { it('marks already archived tenant states as non-actionable while preserving diagnostics', function (): void {
@ -18,5 +19,6 @@
expect($envelope->operatorLabel)->toBe('Permission required') expect($envelope->operatorLabel)->toBe('Permission required')
->and($envelope->shortExplanation)->toContain('missing the capability') ->and($envelope->shortExplanation)->toContain('missing the capability')
->and(TenantOperabilityReasonCode::MissingCapability->boundaryClassification())->toBe(PlatformVocabularyGlossary::BOUNDARY_PLATFORM_CORE)
->and($envelope->guidanceText())->toBe('Next step: Ask a tenant Owner to grant the required capability.'); ->and($envelope->guidanceText())->toBe('Next step: Ask a tenant Owner to grant the required capability.');
}); });

Some files were not shown because too many files have changed in this diff Show More