Compare commits
3 Commits
204-platfo
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 4699f13a72 | |||
| bb72a54e84 | |||
| ad16eee591 |
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -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 -->
|
||||||
|
|||||||
@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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'] ?? ''));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function snapshotTruth(EvidenceSnapshot $snapshot, bool $fresh = false): ArtifactTruthEnvelope
|
return $descending ? ($comparison * -1) : $comparison;
|
||||||
{
|
});
|
||||||
$presenter = app(ArtifactTruthPresenter::class);
|
|
||||||
|
|
||||||
return $fresh
|
return collect($records);
|
||||||
? $presenter->forEvidenceSnapshotFresh($snapshot)
|
}
|
||||||
: $presenter->forEvidenceSnapshot($snapshot);
|
|
||||||
|
/**
|
||||||
|
* @param Collection<string, array<string, mixed>> $rows
|
||||||
|
*/
|
||||||
|
private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
return new LengthAwarePaginator(
|
||||||
|
items: $rows->forPage($page, $recordsPerPage),
|
||||||
|
total: $rows->count(),
|
||||||
|
perPage: $recordsPerPage,
|
||||||
|
currentPage: $page,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function seedTableStateFromQuery(): void
|
||||||
|
{
|
||||||
|
$query = request()->query();
|
||||||
|
|
||||||
|
if (array_key_exists('search', $query)) {
|
||||||
|
$this->tableSearch = trim((string) request()->query('search', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! array_key_exists('tenant_id', $query)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenantFilter = $this->normalizeTenantFilter(request()->query('tenant_id'));
|
||||||
|
|
||||||
|
if ($tenantFilter === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->tableFilters = [
|
||||||
|
'tenant_id' => ['value' => (string) $tenantFilter],
|
||||||
|
];
|
||||||
|
$this->tableDeferredFilters = $this->tableFilters;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeTenantFilter(mixed $value): ?int
|
||||||
|
{
|
||||||
|
if (! is_numeric($value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$requestedTenantId = (int) $value;
|
||||||
|
$allowedTenantIds = collect($this->accessibleTenants())
|
||||||
|
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return in_array($requestedTenantId, $allowedTenantIds, true)
|
||||||
|
? $requestedTenantId
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasActiveOverviewFilters(): bool
|
||||||
|
{
|
||||||
|
return filled(data_get($this->tableFilters, 'tenant_id.value'))
|
||||||
|
|| trim((string) $this->tableSearch) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function workspaceId(): int
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
throw new AuthenticationException;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) app(WorkspaceContext::class)
|
||||||
|
->currentWorkspaceForMemberOrFail($user, request())
|
||||||
|
->getKey();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,16 +10,30 @@
|
|||||||
use App\Models\User;
|
use App\Models\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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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'] : [],
|
||||||
|
|||||||
@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
264
apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php
Normal file
264
apps/platform/app/Livewire/InventoryItemDependencyEdgesTable.php
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Filament\Resources\InventoryItemResource;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Inventory\DependencyQueryService;
|
||||||
|
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
|
||||||
|
use App\Support\Enums\RelationshipType;
|
||||||
|
use App\Support\Filament\TablePaginationProfiles;
|
||||||
|
use Filament\Facades\Filament;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Filament\Tables\TableComponent;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
class InventoryItemDependencyEdgesTable extends TableComponent
|
||||||
|
{
|
||||||
|
public int $inventoryItemId;
|
||||||
|
|
||||||
|
private ?InventoryItem $cachedInventoryItem = null;
|
||||||
|
|
||||||
|
public function mount(int $inventoryItemId): void
|
||||||
|
{
|
||||||
|
$this->inventoryItemId = $inventoryItemId;
|
||||||
|
|
||||||
|
$this->resolveInventoryItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->queryStringIdentifier('inventoryItemDependencyEdges'.Str::studly((string) $this->inventoryItemId))
|
||||||
|
->defaultSort('relationship_label')
|
||||||
|
->defaultPaginationPageOption(10)
|
||||||
|
->paginated(TablePaginationProfiles::picker())
|
||||||
|
->striped()
|
||||||
|
->deferLoading(! app()->runningUnitTests())
|
||||||
|
->records(function (
|
||||||
|
?string $sortColumn,
|
||||||
|
?string $sortDirection,
|
||||||
|
?string $search,
|
||||||
|
array $filters,
|
||||||
|
int $page,
|
||||||
|
int $recordsPerPage
|
||||||
|
): LengthAwarePaginator {
|
||||||
|
$rows = $this->dependencyRows(
|
||||||
|
direction: (string) ($filters['direction']['value'] ?? 'all'),
|
||||||
|
relationshipType: $this->normalizeRelationshipType($filters['relationship_type']['value'] ?? null),
|
||||||
|
);
|
||||||
|
|
||||||
|
$rows = $this->sortRows($rows, $sortColumn, $sortDirection);
|
||||||
|
|
||||||
|
return $this->paginateRows($rows, $page, $recordsPerPage);
|
||||||
|
})
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('direction')
|
||||||
|
->label('Direction')
|
||||||
|
->default('all')
|
||||||
|
->options([
|
||||||
|
'all' => 'All',
|
||||||
|
'inbound' => 'Inbound',
|
||||||
|
'outbound' => 'Outbound',
|
||||||
|
]),
|
||||||
|
SelectFilter::make('relationship_type')
|
||||||
|
->label('Relationship')
|
||||||
|
->options([
|
||||||
|
'all' => 'All',
|
||||||
|
...RelationshipType::options(),
|
||||||
|
])
|
||||||
|
->default('all')
|
||||||
|
->searchable(),
|
||||||
|
])
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('relationship_label')
|
||||||
|
->label('Relationship')
|
||||||
|
->badge()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('target_label')
|
||||||
|
->label('Target')
|
||||||
|
->badge()
|
||||||
|
->url(fn (array $record): ?string => is_string($record['target_url'] ?? null) ? $record['target_url'] : null)
|
||||||
|
->tooltip(fn (array $record): ?string => is_string($record['target_tooltip'] ?? null) ? $record['target_tooltip'] : null)
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('missing_state')
|
||||||
|
->label('Status')
|
||||||
|
->badge()
|
||||||
|
->placeholder('—')
|
||||||
|
->color(fn (?string $state): string => $state === 'Missing' ? 'danger' : 'gray')
|
||||||
|
->icon(fn (?string $state): ?string => $state === 'Missing' ? 'heroicon-m-exclamation-triangle' : null)
|
||||||
|
->description(fn (array $record): ?string => is_string($record['missing_hint'] ?? null) ? $record['missing_hint'] : null)
|
||||||
|
->wrap(),
|
||||||
|
])
|
||||||
|
->actions([])
|
||||||
|
->bulkActions([])
|
||||||
|
->emptyStateHeading('No dependencies found')
|
||||||
|
->emptyStateDescription('Change direction or relationship filters to review a different dependency scope.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(): View
|
||||||
|
{
|
||||||
|
return view('livewire.inventory-item-dependency-edges-table');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<string, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function dependencyRows(string $direction, ?string $relationshipType): Collection
|
||||||
|
{
|
||||||
|
$inventoryItem = $this->resolveInventoryItem();
|
||||||
|
$tenant = $this->resolveCurrentTenant();
|
||||||
|
$service = app(DependencyQueryService::class);
|
||||||
|
$resolver = app(DependencyTargetResolver::class);
|
||||||
|
|
||||||
|
$edges = collect();
|
||||||
|
|
||||||
|
if ($direction === 'inbound' || $direction === 'all') {
|
||||||
|
$edges = $edges->merge($service->getInboundEdges($inventoryItem, $relationshipType));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($direction === 'outbound' || $direction === 'all') {
|
||||||
|
$edges = $edges->merge($service->getOutboundEdges($inventoryItem, $relationshipType));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolver->attachRenderedTargets($edges->take(100), $tenant)
|
||||||
|
->map(function (array $edge): array {
|
||||||
|
$targetId = $edge['target_id'] ?? null;
|
||||||
|
$renderedTarget = is_array($edge['rendered_target'] ?? null) ? $edge['rendered_target'] : [];
|
||||||
|
$badgeText = is_string($renderedTarget['badge_text'] ?? null) ? $renderedTarget['badge_text'] : null;
|
||||||
|
$linkUrl = is_string($renderedTarget['link_url'] ?? null) ? $renderedTarget['link_url'] : null;
|
||||||
|
$lastKnownName = is_string(data_get($edge, 'metadata.last_known_name')) ? data_get($edge, 'metadata.last_known_name') : null;
|
||||||
|
$isMissing = ($edge['target_type'] ?? null) === 'missing';
|
||||||
|
|
||||||
|
$missingHint = null;
|
||||||
|
|
||||||
|
if ($isMissing) {
|
||||||
|
$missingHint = 'Missing target';
|
||||||
|
|
||||||
|
if (filled($lastKnownName)) {
|
||||||
|
$missingHint .= ". Last known: {$lastKnownName}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawRef = data_get($edge, 'metadata.raw_ref');
|
||||||
|
$encodedRef = $rawRef !== null ? json_encode($rawRef) : null;
|
||||||
|
|
||||||
|
if (is_string($encodedRef) && $encodedRef !== '') {
|
||||||
|
$missingHint .= '. Ref: '.Str::limit($encodedRef, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$fallbackLabel = null;
|
||||||
|
|
||||||
|
if (filled($lastKnownName)) {
|
||||||
|
$fallbackLabel = $lastKnownName;
|
||||||
|
} elseif (is_string($targetId) && $targetId !== '') {
|
||||||
|
$fallbackLabel = 'ID: '.substr($targetId, 0, 6).'…';
|
||||||
|
} else {
|
||||||
|
$fallbackLabel = 'External reference';
|
||||||
|
}
|
||||||
|
|
||||||
|
$relationshipType = (string) ($edge['relationship_type'] ?? 'unknown');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (string) ($edge['id'] ?? Str::uuid()->toString()),
|
||||||
|
'relationship_label' => RelationshipType::options()[$relationshipType] ?? Str::headline($relationshipType),
|
||||||
|
'target_label' => $badgeText ?? $fallbackLabel,
|
||||||
|
'target_url' => $linkUrl,
|
||||||
|
'target_tooltip' => is_string($targetId) ? $targetId : null,
|
||||||
|
'missing_state' => $isMissing ? 'Missing' : null,
|
||||||
|
'missing_hint' => $missingHint,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->mapWithKeys(fn (array $row): array => [$row['id'] => $row]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<string, array<string, mixed>> $rows
|
||||||
|
* @return Collection<string, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function sortRows(Collection $rows, ?string $sortColumn, ?string $sortDirection): Collection
|
||||||
|
{
|
||||||
|
$sortColumn = in_array($sortColumn, ['relationship_label', 'target_label', 'missing_state'], true)
|
||||||
|
? $sortColumn
|
||||||
|
: 'relationship_label';
|
||||||
|
$descending = Str::lower((string) ($sortDirection ?? 'asc')) === 'desc';
|
||||||
|
|
||||||
|
$records = $rows->all();
|
||||||
|
|
||||||
|
uasort($records, static function (array $left, array $right) use ($sortColumn, $descending): int {
|
||||||
|
$comparison = strnatcasecmp(
|
||||||
|
(string) ($left[$sortColumn] ?? ''),
|
||||||
|
(string) ($right[$sortColumn] ?? ''),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($comparison === 0) {
|
||||||
|
$comparison = strnatcasecmp(
|
||||||
|
(string) ($left['target_label'] ?? ''),
|
||||||
|
(string) ($right['target_label'] ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $descending ? ($comparison * -1) : $comparison;
|
||||||
|
});
|
||||||
|
|
||||||
|
return collect($records);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<string, array<string, mixed>> $rows
|
||||||
|
*/
|
||||||
|
private function paginateRows(Collection $rows, int $page, int $recordsPerPage): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
return new LengthAwarePaginator(
|
||||||
|
items: $rows->forPage($page, $recordsPerPage),
|
||||||
|
total: $rows->count(),
|
||||||
|
perPage: $recordsPerPage,
|
||||||
|
currentPage: $page,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveInventoryItem(): InventoryItem
|
||||||
|
{
|
||||||
|
if ($this->cachedInventoryItem instanceof InventoryItem) {
|
||||||
|
return $this->cachedInventoryItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inventoryItem = InventoryItem::query()->findOrFail($this->inventoryItemId);
|
||||||
|
$tenant = $this->resolveCurrentTenant();
|
||||||
|
|
||||||
|
if ((int) $inventoryItem->tenant_id !== (int) $tenant->getKey() || ! InventoryItemResource::canView($inventoryItem)) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->cachedInventoryItem = $inventoryItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveCurrentTenant(): Tenant
|
||||||
|
{
|
||||||
|
$tenant = Filament::getTenant();
|
||||||
|
|
||||||
|
if (! $tenant instanceof Tenant) {
|
||||||
|
throw new NotFoundHttpException;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeRelationshipType(mixed $value): ?string
|
||||||
|
{
|
||||||
|
if (! is_string($value) || $value === '' || $value === 'all') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return RelationshipType::tryFrom($value)?->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)) {
|
||||||
|
|||||||
@ -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.',
|
||||||
|
|||||||
@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 === '') {
|
||||||
|
|||||||
@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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)
|
||||||
|
|||||||
39
apps/platform/app/Support/CanonicalOperationType.php
Normal file
39
apps/platform/app/Support/CanonicalOperationType.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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.',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
110
apps/platform/app/Support/Governance/PlatformVocabularyTerm.php
Normal file
110
apps/platform/app/Support/Governance/PlatformVocabularyTerm.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
apps/platform/app/Support/OperationTypeAlias.php
Normal file
72
apps/platform/app/Support/OperationTypeAlias.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
56
apps/platform/app/Support/OperationTypeResolution.php
Normal file
56
apps/platform/app/Support/OperationTypeResolution.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -248,6 +248,7 @@ private function envelope(
|
|||||||
nextSteps: $nextSteps,
|
nextSteps: $nextSteps,
|
||||||
showNoActionNeeded: false,
|
showNoActionNeeded: false,
|
||||||
diagnosticCodeLabel: $reasonCode,
|
diagnosticCodeLabel: $reasonCode,
|
||||||
|
reasonOwnership: ProviderReasonCodes::ownershipDescriptor($reasonCode),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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) }}
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 doesn’t affect copy actions. Feature filters do.
|
Search doesn’t 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>
|
||||||
|
|||||||
@ -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' }}
|
||||||
|
·
|
||||||
|
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>
|
||||||
|
|||||||
@ -0,0 +1,3 @@
|
|||||||
|
<div>
|
||||||
|
{{ $this->table }}
|
||||||
|
</div>
|
||||||
@ -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');
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -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(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@ -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')
|
||||||
|
|||||||
@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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.');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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');
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
@ -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')
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -0,0 +1,126 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Filament\Pages\TenantRequiredPermissions;
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Models\TenantPermission;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
function seedTenantRequiredPermissionsFixture(Tenant $tenant): void
|
||||||
|
{
|
||||||
|
config()->set('intune_permissions.permissions', [
|
||||||
|
[
|
||||||
|
'key' => 'DeviceManagementApps.Read.All',
|
||||||
|
'type' => 'application',
|
||||||
|
'description' => 'Backup application permission',
|
||||||
|
'features' => ['backup'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'Group.Read.All',
|
||||||
|
'type' => 'delegated',
|
||||||
|
'description' => 'Backup delegated permission',
|
||||||
|
'features' => ['backup'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => 'Reports.Read.All',
|
||||||
|
'type' => 'application',
|
||||||
|
'description' => 'Reporting permission',
|
||||||
|
'features' => ['reporting'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
config()->set('entra_permissions.permissions', []);
|
||||||
|
|
||||||
|
TenantPermission::query()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'permission_key' => 'Group.Read.All',
|
||||||
|
'status' => 'missing',
|
||||||
|
'details' => ['source' => 'fixture'],
|
||||||
|
'last_checked_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
TenantPermission::query()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'permission_key' => 'Reports.Read.All',
|
||||||
|
'status' => 'granted',
|
||||||
|
'details' => ['source' => 'fixture'],
|
||||||
|
'last_checked_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tenantRequiredPermissionsComponent(User $user, Tenant $tenant, array $query = [])
|
||||||
|
{
|
||||||
|
test()->actingAs($user);
|
||||||
|
setAdminPanelContext();
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
$query = array_merge([
|
||||||
|
'tenant' => (string) $tenant->external_id,
|
||||||
|
], $query);
|
||||||
|
|
||||||
|
return Livewire::withQueryParams($query)->test(TenantRequiredPermissions::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('uses native table filters and search while keeping summary state aligned with visible rows', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
seedTenantRequiredPermissionsFixture($tenant);
|
||||||
|
|
||||||
|
$component = tenantRequiredPermissionsComponent($user, $tenant)
|
||||||
|
->assertTableFilterExists('status')
|
||||||
|
->assertTableFilterExists('type')
|
||||||
|
->assertTableFilterExists('features')
|
||||||
|
->assertCanSeeTableRecords([
|
||||||
|
'DeviceManagementApps.Read.All',
|
||||||
|
'Group.Read.All',
|
||||||
|
])
|
||||||
|
->assertCanNotSeeTableRecords(['Reports.Read.All'])
|
||||||
|
->assertSee('Missing application permissions')
|
||||||
|
->assertSee('Guidance');
|
||||||
|
|
||||||
|
$component
|
||||||
|
->filterTable('status', 'present')
|
||||||
|
->filterTable('type', 'application')
|
||||||
|
->searchTable('Reports')
|
||||||
|
->assertCountTableRecords(1)
|
||||||
|
->assertCanSeeTableRecords(['Reports.Read.All'])
|
||||||
|
->assertCanNotSeeTableRecords([
|
||||||
|
'DeviceManagementApps.Read.All',
|
||||||
|
'Group.Read.All',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$viewModel = $component->instance()->viewModel();
|
||||||
|
|
||||||
|
expect($viewModel['overview']['counts'])->toBe([
|
||||||
|
'missing_application' => 0,
|
||||||
|
'missing_delegated' => 0,
|
||||||
|
'present' => 1,
|
||||||
|
'error' => 0,
|
||||||
|
])
|
||||||
|
->and(array_column($viewModel['permissions'], 'key'))->toBe(['Reports.Read.All'])
|
||||||
|
->and($viewModel['copy']['application'])->toBe('DeviceManagementApps.Read.All');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps copy payloads feature-scoped and shows the native no-matches state', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'readonly');
|
||||||
|
seedTenantRequiredPermissionsFixture($tenant);
|
||||||
|
|
||||||
|
$component = tenantRequiredPermissionsComponent($user, $tenant)
|
||||||
|
->set('tableFilters.features.values', ['backup'])
|
||||||
|
->assertSet('tableFilters.features.values', ['backup']);
|
||||||
|
|
||||||
|
$viewModel = $component->instance()->viewModel();
|
||||||
|
|
||||||
|
expect($viewModel['copy']['application'])->toBe('DeviceManagementApps.Read.All')
|
||||||
|
->and($viewModel['copy']['delegated'])->toBe('Group.Read.All');
|
||||||
|
|
||||||
|
$component
|
||||||
|
->searchTable('no-such-permission')
|
||||||
|
->assertCountTableRecords(0)
|
||||||
|
->assertSee('No matches')
|
||||||
|
->assertTableEmptyStateActionsExistInOrder(['clear_filters']);
|
||||||
|
});
|
||||||
@ -29,7 +29,9 @@
|
|||||||
'app/Filament/Resources/BackupSetResource/RelationManagers/BackupItemsRelationManager.php',
|
'app/Filament/Resources/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' => [
|
||||||
|
|||||||
@ -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(');
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 () {
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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']);
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
});
|
||||||
@ -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');
|
||||||
|
});
|
||||||
@ -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');
|
||||||
|
});
|
||||||
@ -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.');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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.');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@ -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
Loading…
Reference in New Issue
Block a user