Compare commits
2 Commits
203-baseli
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| ad16eee591 | |||
| d644265d30 |
8
.github/agents/copilot-instructions.md
vendored
8
.github/agents/copilot-instructions.md
vendored
@ -180,6 +180,10 @@ ## Active Technologies
|
|||||||
- PostgreSQL through existing tenant-owned and workspace-context models (`InventoryItem`, `InventoryLink`, `TenantPermission`, `EvidenceSnapshot`, `TenantReview`); no schema change planned (196-hard-filament-nativity-cleanup)
|
- PostgreSQL through existing tenant-owned and workspace-context models (`InventoryItem`, `InventoryLink`, `TenantPermission`, `EvidenceSnapshot`, `TenantReview`); no schema change planned (196-hard-filament-nativity-cleanup)
|
||||||
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail, existing `BaselineScope`, `InventoryPolicyTypeMeta`, `BaselineSupportCapabilityGuard`, `BaselineCaptureService`, and `BaselineCompareService` (202-governance-subject-taxonomy)
|
- PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail, existing `BaselineScope`, `InventoryPolicyTypeMeta`, `BaselineSupportCapabilityGuard`, `BaselineCaptureService`, and `BaselineCompareService` (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)
|
- 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)
|
||||||
|
- 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 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -214,8 +218,8 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 204-platform-core-vocabulary-hardening: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `GovernanceSubjectTaxonomyRegistry`, `BaselineScope`, `CompareStrategyRegistry`, `OperationCatalog`, `OperationRunType`, `ReasonTranslator`, `ReasonResolutionEnvelope`, `ProviderReasonTranslator`, and current Filament monitoring or review surfaces
|
||||||
|
- 203-baseline-compare-strategy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Laravel Sail, existing `BaselineCompareService`, `CompareBaselineToTenantJob`, `SubjectResolver`, `CurrentStateHashResolver`, `DriftHasher`, `BaselineCompareSummaryAssessor`, and finding lifecycle services
|
||||||
- 202-governance-subject-taxonomy: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail, existing `BaselineScope`, `InventoryPolicyTypeMeta`, `BaselineSupportCapabilityGuard`, `BaselineCaptureService`, and `BaselineCompareService`
|
- 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
|
|
||||||
- 195-action-surface-closure: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, existing `ActionSurfaceDiscovery`, `ActionSurfaceValidator`, `ActionSurfaceExemptions`, `GovernanceActionCatalog`, `UiEnforcement`, `WorkspaceContext`, and existing system/onboarding/auth helpers
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
<!-- MANUAL ADDITIONS END -->
|
<!-- MANUAL ADDITIONS END -->
|
||||||
|
|||||||
8
.github/skills/giteaflow/SKILL.md
vendored
Normal file
8
.github/skills/giteaflow/SKILL.md
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
name: giteaflow
|
||||||
|
description: Describe what this skill does and when to use it. Include keywords that help agents identify relevant tasks.
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- Tip: Use /create-skill in chat to generate content with agent assistance -->
|
||||||
|
|
||||||
|
comit all changes, push to remote, and create a pull request against dev with gitea mcp
|
||||||
@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -381,17 +387,23 @@ private function compareNowAction(): Action
|
|||||||
|
|
||||||
if (! ($result['ok'] ?? false)) {
|
if (! ($result['ok'] ?? false)) {
|
||||||
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
||||||
|
$translation = is_array($result['reason_translation'] ?? null) ? $result['reason_translation'] : [];
|
||||||
|
|
||||||
$message = match ($reasonCode) {
|
$message = is_string($translation['short_explanation'] ?? null) && trim((string) $translation['short_explanation']) !== ''
|
||||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
? trim((string) $translation['short_explanation'])
|
||||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'The assigned baseline profile is not active.',
|
: match ($reasonCode) {
|
||||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
||||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'No complete baseline snapshot is currently available for compare.',
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'The assigned baseline profile is not active.',
|
||||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'No complete baseline snapshot is currently available for compare.',
|
||||||
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is current. Compare uses the latest complete baseline only.',
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
||||||
default => 'Reason: '.$reasonCode,
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
||||||
};
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete baseline snapshot is current. Compare uses the latest complete baseline only.',
|
||||||
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'The assigned baseline scope is invalid and must be reviewed before compare can start.',
|
||||||
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'The selected governed subjects are not supported by any compare strategy.',
|
||||||
|
\App\Support\Baselines\BaselineReasonCodes::COMPARE_MIXED_SCOPE => 'The selected governed subjects span multiple compare strategy families and must be narrowed before compare can start.',
|
||||||
|
default => 'Reason: '.$reasonCode,
|
||||||
|
};
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Cannot start comparison')
|
->title('Cannot start comparison')
|
||||||
|
|||||||
@ -14,6 +14,8 @@
|
|||||||
use App\Services\Baselines\BaselineCompareService;
|
use App\Services\Baselines\BaselineCompareService;
|
||||||
use App\Support\Auth\Capabilities;
|
use App\Support\Auth\Capabilities;
|
||||||
use App\Support\Baselines\BaselineCompareMatrixBuilder;
|
use App\Support\Baselines\BaselineCompareMatrixBuilder;
|
||||||
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
|
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||||
use App\Support\Navigation\CanonicalNavigationContext;
|
use App\Support\Navigation\CanonicalNavigationContext;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
@ -128,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',
|
||||||
@ -246,7 +248,22 @@ protected function getHeaderActions(): array
|
|||||||
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
->requireCapability(Capabilities::WORKSPACE_BASELINES_MANAGE)
|
||||||
->preserveDisabled()
|
->preserveDisabled()
|
||||||
->tooltip('You need workspace baseline manage access to compare the visible assigned set.')
|
->tooltip('You need workspace baseline manage access to compare the visible assigned set.')
|
||||||
->apply();
|
->apply()
|
||||||
|
->tooltip(function (): ?string {
|
||||||
|
$user = auth()->user();
|
||||||
|
$workspace = $this->workspace();
|
||||||
|
|
||||||
|
if ($user instanceof User && $workspace instanceof Workspace) {
|
||||||
|
/** @var WorkspaceCapabilityResolver $resolver */
|
||||||
|
$resolver = app(WorkspaceCapabilityResolver::class);
|
||||||
|
|
||||||
|
if ($resolver->isMember($user, $workspace) && ! $resolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_MANAGE)) {
|
||||||
|
return 'You need workspace baseline manage access to compare the visible assigned set.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->compareAssignedTenantsDisabledReason();
|
||||||
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
Action::make('backToBaselineProfile')
|
Action::make('backToBaselineProfile')
|
||||||
@ -409,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 !== []) {
|
||||||
@ -435,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) {
|
||||||
@ -616,9 +633,41 @@ private function compareAssignedTenantsDisabledReason(): ?string
|
|||||||
return 'No visible assigned tenants are available for compare.';
|
return 'No visible assigned tenants are available for compare.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $this->compareStartReasonMessage($this->compareAssignedTenantsReasonCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compareAssignedTenantsReasonCode(): ?string
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$scope = $this->getRecord()->normalizedScope();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return BaselineReasonCodes::COMPARE_INVALID_SCOPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$selection = app(CompareStrategyRegistry::class)->select($scope);
|
||||||
|
|
||||||
|
if ($selection->isMixed()) {
|
||||||
|
return BaselineReasonCodes::COMPARE_MIXED_SCOPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $selection->isSupported()) {
|
||||||
|
return BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE;
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function compareStartReasonMessage(?string $reasonCode): ?string
|
||||||
|
{
|
||||||
|
return match ($reasonCode) {
|
||||||
|
BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'The assigned baseline scope is invalid and must be reviewed before comparing assigned tenants.',
|
||||||
|
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'The selected governed subjects are not supported by any compare strategy yet.',
|
||||||
|
BaselineReasonCodes::COMPARE_MIXED_SCOPE => 'The selected governed subjects span multiple compare strategy families and must be narrowed before comparing assigned tenants.',
|
||||||
|
'tenant_sync_required' => 'You need tenant sync access for each visible tenant before compare can start.',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private function compareAssignedTenants(): void
|
private function compareAssignedTenants(): void
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -639,6 +688,15 @@ private function compareAssignedTenants(): void
|
|||||||
(int) $result['visibleAssignedTenantCount'],
|
(int) $result['visibleAssignedTenantCount'],
|
||||||
(int) $result['visibleAssignedTenantCount'] === 1 ? '' : 's',
|
(int) $result['visibleAssignedTenantCount'] === 1 ? '' : 's',
|
||||||
);
|
);
|
||||||
|
$blockedReasonCodes = collect($result['targets'])
|
||||||
|
->where('launchState', 'blocked')
|
||||||
|
->pluck('reasonCode')
|
||||||
|
->filter(static fn (mixed $reasonCode): bool => is_string($reasonCode) && trim($reasonCode) !== '')
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
$blockedReasonMessage = $blockedReasonCodes->count() === 1
|
||||||
|
? $this->compareStartReasonMessage((string) $blockedReasonCodes->first())
|
||||||
|
: null;
|
||||||
|
|
||||||
if ((int) $result['queuedCount'] > 0 || (int) $result['alreadyQueuedCount'] > 0) {
|
if ((int) $result['queuedCount'] > 0 || (int) $result['alreadyQueuedCount'] > 0) {
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
@ -661,7 +719,7 @@ private function compareAssignedTenants(): void
|
|||||||
} else {
|
} else {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('No baseline compares were started')
|
->title('No baseline compares were started')
|
||||||
->body($summary)
|
->body($blockedReasonMessage !== null ? $blockedReasonMessage.' '.$summary : $summary)
|
||||||
->warning()
|
->warning()
|
||||||
->send();
|
->send();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,7 @@
|
|||||||
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
use App\Support\Baselines\BaselineFullContentRolloutGate;
|
||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
|
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||||
use App\Support\Filament\FilterOptionCatalog;
|
use App\Support\Filament\FilterOptionCatalog;
|
||||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||||
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
use App\Support\Navigation\CrossResourceNavigationMatrix;
|
||||||
@ -807,6 +808,7 @@ private static function compareReadinessLabel(BaselineProfile $profile): string
|
|||||||
{
|
{
|
||||||
return match (self::compareAvailabilityReason($profile)) {
|
return match (self::compareAvailabilityReason($profile)) {
|
||||||
BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'Invalid scope',
|
BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'Invalid scope',
|
||||||
|
BaselineReasonCodes::COMPARE_MIXED_SCOPE => 'Mixed strategy scope',
|
||||||
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Unsupported governed subjects',
|
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Unsupported governed subjects',
|
||||||
default => self::compareAvailabilityEnvelope($profile)?->operatorLabel ?? 'Ready',
|
default => self::compareAvailabilityEnvelope($profile)?->operatorLabel ?? 'Ready',
|
||||||
};
|
};
|
||||||
@ -818,6 +820,7 @@ private static function compareReadinessColor(BaselineProfile $profile): string
|
|||||||
null => 'success',
|
null => 'success',
|
||||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'gray',
|
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'gray',
|
||||||
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
|
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
|
||||||
|
BaselineReasonCodes::COMPARE_MIXED_SCOPE,
|
||||||
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'danger',
|
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'danger',
|
||||||
default => 'warning',
|
default => 'warning',
|
||||||
};
|
};
|
||||||
@ -829,6 +832,7 @@ private static function compareReadinessIcon(BaselineProfile $profile): ?string
|
|||||||
null => 'heroicon-m-check-badge',
|
null => 'heroicon-m-check-badge',
|
||||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'heroicon-m-pause-circle',
|
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'heroicon-m-pause-circle',
|
||||||
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
|
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
|
||||||
|
BaselineReasonCodes::COMPARE_MIXED_SCOPE,
|
||||||
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'heroicon-m-no-symbol',
|
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'heroicon-m-no-symbol',
|
||||||
default => 'heroicon-m-exclamation-triangle',
|
default => 'heroicon-m-exclamation-triangle',
|
||||||
};
|
};
|
||||||
@ -838,6 +842,7 @@ private static function profileNextStep(BaselineProfile $profile): string
|
|||||||
{
|
{
|
||||||
return match (self::compareAvailabilityReason($profile)) {
|
return match (self::compareAvailabilityReason($profile)) {
|
||||||
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
|
BaselineReasonCodes::COMPARE_INVALID_SCOPE,
|
||||||
|
BaselineReasonCodes::COMPARE_MIXED_SCOPE,
|
||||||
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Review the governed subject selection before starting compare.',
|
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'Review the governed subject selection before starting compare.',
|
||||||
default => self::compareAvailabilityEnvelope($profile)?->guidanceText() ?? 'No action needed.',
|
default => self::compareAvailabilityEnvelope($profile)?->guidanceText() ?? 'No action needed.',
|
||||||
};
|
};
|
||||||
@ -873,7 +878,13 @@ private static function compareAvailabilityReason(BaselineProfile $profile): ?st
|
|||||||
return BaselineReasonCodes::COMPARE_INVALID_SCOPE;
|
return BaselineReasonCodes::COMPARE_INVALID_SCOPE;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $scope->operationEligibility('compare')['ok']) {
|
$selection = app(CompareStrategyRegistry::class)->select($scope);
|
||||||
|
|
||||||
|
if ($selection->isMixed()) {
|
||||||
|
return BaselineReasonCodes::COMPARE_MIXED_SCOPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $selection->isSupported()) {
|
||||||
return BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE;
|
return BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -250,19 +250,23 @@ private function compareNowAction(): Action
|
|||||||
|
|
||||||
if (! ($result['ok'] ?? false)) {
|
if (! ($result['ok'] ?? false)) {
|
||||||
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
$reasonCode = is_string($result['reason_code'] ?? null) ? (string) $result['reason_code'] : 'unknown';
|
||||||
|
$translation = is_array($result['reason_translation'] ?? null) ? $result['reason_translation'] : [];
|
||||||
|
|
||||||
$message = match ($reasonCode) {
|
$message = is_string($translation['short_explanation'] ?? null) && trim((string) $translation['short_explanation']) !== ''
|
||||||
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
? trim((string) $translation['short_explanation'])
|
||||||
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
: match ($reasonCode) {
|
||||||
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
BaselineReasonCodes::COMPARE_ROLLOUT_DISABLED => 'Full-content baseline compare is currently disabled for controlled rollout.',
|
||||||
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'This baseline profile has no complete snapshot available for compare yet.',
|
BaselineReasonCodes::COMPARE_PROFILE_NOT_ACTIVE => 'This baseline profile is not active.',
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
BaselineReasonCodes::COMPARE_NO_ACTIVE_SNAPSHOT,
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
BaselineReasonCodes::COMPARE_NO_CONSUMABLE_SNAPSHOT => 'This baseline profile has no complete snapshot available for compare yet.',
|
||||||
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete snapshot is current. Compare uses the latest complete baseline only.',
|
BaselineReasonCodes::COMPARE_SNAPSHOT_BUILDING => 'The latest baseline capture is still building. Compare will be available after it completes.',
|
||||||
BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'This baseline profile has an invalid governed-subject scope. Review the baseline definition before comparing.',
|
BaselineReasonCodes::COMPARE_SNAPSHOT_INCOMPLETE => 'The latest baseline capture is incomplete. Capture a new baseline before comparing.',
|
||||||
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'This baseline profile includes governed subjects that are not currently supported for compare.',
|
BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED => 'A newer complete snapshot is current. Compare uses the latest complete baseline only.',
|
||||||
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
BaselineReasonCodes::COMPARE_INVALID_SCOPE => 'This baseline profile has an invalid governed-subject scope. Review the baseline definition before comparing.',
|
||||||
};
|
BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE => 'This baseline profile includes governed subjects that are not currently supported for compare.',
|
||||||
|
BaselineReasonCodes::COMPARE_MIXED_SCOPE => 'This baseline profile mixes governed subjects that require different compare strategies. Narrow the selection before comparing.',
|
||||||
|
default => 'Reason: '.str_replace('.', ' ', $reasonCode),
|
||||||
|
};
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Cannot start comparison')
|
->title('Cannot start comparison')
|
||||||
|
|||||||
@ -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(
|
||||||
@ -957,6 +989,49 @@ private static function baselineCompareFacts(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$strategy = data_get($context, 'baseline_compare.strategy');
|
||||||
|
if (is_array($strategy)) {
|
||||||
|
$strategyKey = is_string($strategy['key'] ?? null) && trim((string) $strategy['key']) !== ''
|
||||||
|
? trim((string) $strategy['key'])
|
||||||
|
: null;
|
||||||
|
$selectionState = is_string($strategy['selection_state'] ?? null) && trim((string) $strategy['selection_state']) !== ''
|
||||||
|
? trim((string) $strategy['selection_state'])
|
||||||
|
: null;
|
||||||
|
$operatorReason = is_string($strategy['operator_reason'] ?? null) && trim((string) $strategy['operator_reason']) !== ''
|
||||||
|
? trim((string) $strategy['operator_reason'])
|
||||||
|
: null;
|
||||||
|
$stateCounts = is_array($strategy['state_counts'] ?? null)
|
||||||
|
? array_filter(
|
||||||
|
array_map(static fn (mixed $count): int => (int) $count, $strategy['state_counts']),
|
||||||
|
static fn (int $count): bool => $count > 0,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if ($strategyKey !== null) {
|
||||||
|
$facts[] = $factory->keyFact(
|
||||||
|
'Compare strategy',
|
||||||
|
\Illuminate\Support\Str::of($strategyKey)->replace('_', ' ')->headline()->toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($selectionState !== null) {
|
||||||
|
$facts[] = $factory->keyFact(
|
||||||
|
'Strategy selection',
|
||||||
|
\Illuminate\Support\Str::of($selectionState)->replace('_', ' ')->headline()->toString(),
|
||||||
|
$operatorReason,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($stateCounts !== []) {
|
||||||
|
$facts[] = $factory->keyFact(
|
||||||
|
'Strategy subject states',
|
||||||
|
collect($stateCounts)
|
||||||
|
->map(static fn (int $count, string $state): string => \Illuminate\Support\Str::of($state)->replace('_', ' ')->headline()->append(' ', (string) $count)->toString())
|
||||||
|
->implode(', '),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ((int) ($gapSummary['count'] ?? 0) > 0) {
|
if ((int) ($gapSummary['count'] ?? 0) > 0) {
|
||||||
$facts[] = $factory->keyFact(
|
$facts[] = $factory->keyFact(
|
||||||
'Evidence gap detail',
|
'Evidence gap detail',
|
||||||
@ -1009,6 +1084,9 @@ private static function baselineCompareEvidencePayload(OperationRun $record): ar
|
|||||||
? (int) data_get($context, 'baseline_compare.evidence_gaps.count')
|
? (int) data_get($context, 'baseline_compare.evidence_gaps.count')
|
||||||
: null,
|
: null,
|
||||||
'resume_token' => data_get($context, 'baseline_compare.resume_token'),
|
'resume_token' => data_get($context, 'baseline_compare.resume_token'),
|
||||||
|
'strategy' => is_array(data_get($context, 'baseline_compare.strategy'))
|
||||||
|
? data_get($context, 'baseline_compare.strategy')
|
||||||
|
: null,
|
||||||
'evidence_capture' => is_array(data_get($context, 'baseline_compare.evidence_capture'))
|
'evidence_capture' => is_array(data_get($context, 'baseline_compare.evidence_capture'))
|
||||||
? data_get($context, 'baseline_compare.evidence_capture')
|
? data_get($context, 'baseline_compare.evidence_capture')
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@ -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,9 +167,24 @@ 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 : [];
|
||||||
$failedReason = (string) ($opContext['reason_code'] ?? 'Unknown error');
|
|
||||||
|
if ($failedReason === null) {
|
||||||
|
$failedReason = (string) ($opContext['reason_code'] ?? 'Unknown error');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,6 +42,13 @@
|
|||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
use App\Support\Baselines\BaselineSubjectKey;
|
use App\Support\Baselines\BaselineSubjectKey;
|
||||||
|
use App\Support\Baselines\Compare\CompareFindingCandidate;
|
||||||
|
use App\Support\Baselines\Compare\CompareOrchestrationContext;
|
||||||
|
use App\Support\Baselines\Compare\CompareState;
|
||||||
|
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||||
|
use App\Support\Baselines\Compare\CompareStrategySelection;
|
||||||
|
use App\Support\Baselines\Compare\CompareSubjectResult;
|
||||||
|
use App\Support\Baselines\Compare\StrategySelectionState;
|
||||||
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
use App\Support\Baselines\PolicyVersionCapturePurpose;
|
||||||
use App\Support\Baselines\SubjectResolver;
|
use App\Support\Baselines\SubjectResolver;
|
||||||
use App\Support\Inventory\InventoryCoverage;
|
use App\Support\Inventory\InventoryCoverage;
|
||||||
@ -102,6 +109,7 @@ public function handle(
|
|||||||
?BaselineContentCapturePhase $contentCapturePhase = null,
|
?BaselineContentCapturePhase $contentCapturePhase = null,
|
||||||
?BaselineFullContentRolloutGate $rolloutGate = null,
|
?BaselineFullContentRolloutGate $rolloutGate = null,
|
||||||
?ContentEvidenceProvider $contentEvidenceProvider = null,
|
?ContentEvidenceProvider $contentEvidenceProvider = null,
|
||||||
|
?CompareStrategyRegistry $compareStrategyRegistry = null,
|
||||||
): void {
|
): void {
|
||||||
$settingsResolver ??= app(SettingsResolver::class);
|
$settingsResolver ??= app(SettingsResolver::class);
|
||||||
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
$baselineAutoCloseService ??= app(BaselineAutoCloseService::class);
|
||||||
@ -111,6 +119,7 @@ public function handle(
|
|||||||
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
$contentCapturePhase ??= app(BaselineContentCapturePhase::class);
|
||||||
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
$rolloutGate ??= app(BaselineFullContentRolloutGate::class);
|
||||||
$contentEvidenceProvider ??= app(ContentEvidenceProvider::class);
|
$contentEvidenceProvider ??= app(ContentEvidenceProvider::class);
|
||||||
|
$compareStrategyRegistry ??= app(CompareStrategyRegistry::class);
|
||||||
|
|
||||||
if (! $this->operationRun instanceof OperationRun) {
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
$this->fail(new RuntimeException('OperationRun context is required for CompareBaselineToTenantJob.'));
|
$this->fail(new RuntimeException('OperationRun context is required for CompareBaselineToTenantJob.'));
|
||||||
@ -339,6 +348,44 @@ public function handle(
|
|||||||
$snapshot = $snapshotResolution['snapshot'];
|
$snapshot = $snapshotResolution['snapshot'];
|
||||||
$snapshotId = (int) $snapshot->getKey();
|
$snapshotId = (int) $snapshot->getKey();
|
||||||
|
|
||||||
|
$strategySelection = $compareStrategyRegistry->select($effectiveScope);
|
||||||
|
$context = $this->withCompareStrategySelection($context, $strategySelection);
|
||||||
|
$this->operationRun->update(['context' => $context]);
|
||||||
|
$this->operationRun->refresh();
|
||||||
|
|
||||||
|
if (! $strategySelection->isSupported()) {
|
||||||
|
$this->auditStarted(
|
||||||
|
auditLogger: $auditLogger,
|
||||||
|
tenant: $tenant,
|
||||||
|
profile: $profile,
|
||||||
|
initiator: $initiator,
|
||||||
|
captureMode: $captureMode,
|
||||||
|
subjectsTotal: 0,
|
||||||
|
effectiveScope: $effectiveScope,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->completeWithCoverageWarning(
|
||||||
|
operationRunService: $operationRunService,
|
||||||
|
auditLogger: $auditLogger,
|
||||||
|
tenant: $tenant,
|
||||||
|
profile: $profile,
|
||||||
|
initiator: $initiator,
|
||||||
|
inventorySyncRun: $inventorySyncRun,
|
||||||
|
coverageProof: true,
|
||||||
|
effectiveTypes: $effectiveTypes,
|
||||||
|
coveredTypes: $coveredTypes,
|
||||||
|
uncoveredTypes: $uncoveredTypes,
|
||||||
|
errorsRecorded: max(1, count($coveredTypes !== [] ? $coveredTypes : $effectiveTypes)),
|
||||||
|
captureMode: $captureMode,
|
||||||
|
reasonCode: BaselineCompareReasonCode::UnsupportedSubjects,
|
||||||
|
evidenceGapsByReason: [
|
||||||
|
$this->strategySelectionGapReason($strategySelection) => max(1, count($coveredTypes !== [] ? $coveredTypes : $effectiveTypes)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$since = $snapshot->captured_at instanceof \DateTimeInterface
|
$since = $snapshot->captured_at instanceof \DateTimeInterface
|
||||||
? CarbonImmutable::instance($snapshot->captured_at)
|
? CarbonImmutable::instance($snapshot->captured_at)
|
||||||
: null;
|
: null;
|
||||||
@ -459,32 +506,79 @@ public function handle(
|
|||||||
resolvedMetaEvidence: $resolvedCurrentMetaEvidence,
|
resolvedMetaEvidence: $resolvedCurrentMetaEvidence,
|
||||||
);
|
);
|
||||||
|
|
||||||
$baselinePolicyVersionResolver = app(BaselinePolicyVersionResolver::class);
|
$strategy = $compareStrategyRegistry->resolve($strategySelection->strategyKey);
|
||||||
$driftHasher = app(DriftHasher::class);
|
$orchestrationContext = new CompareOrchestrationContext(
|
||||||
$settingsNormalizer = app(SettingsNormalizer::class);
|
workspaceId: (int) $workspace->getKey(),
|
||||||
$assignmentsNormalizer = app(AssignmentsNormalizer::class);
|
tenantId: (int) $tenant->getKey(),
|
||||||
$scopeTagsNormalizer = app(ScopeTagsNormalizer::class);
|
|
||||||
|
|
||||||
$computeResult = $this->computeDrift(
|
|
||||||
tenant: $tenant,
|
|
||||||
baselineProfileId: (int) $profile->getKey(),
|
baselineProfileId: (int) $profile->getKey(),
|
||||||
baselineSnapshotId: (int) $snapshot->getKey(),
|
baselineSnapshotId: (int) $snapshot->getKey(),
|
||||||
compareOperationRunId: (int) $this->operationRun->getKey(),
|
operationRunId: (int) $this->operationRun->getKey(),
|
||||||
inventorySyncRunId: (int) $inventorySyncRun->getKey(),
|
normalizedScope: $effectiveScope->toStoredJsonb(),
|
||||||
baselineItems: $baselineItems,
|
strategySelection: $strategySelection,
|
||||||
currentItems: $currentItems,
|
coverageContext: [
|
||||||
resolvedCurrentEvidence: $resolvedEffectiveCurrentEvidence,
|
'inventory_sync_run_id' => (int) $inventorySyncRun->getKey(),
|
||||||
severityMapping: $this->resolveSeverityMapping($workspace, $settingsResolver),
|
'effective_types' => $effectiveTypes,
|
||||||
baselinePolicyVersionResolver: $baselinePolicyVersionResolver,
|
'covered_types' => $coveredTypes,
|
||||||
hasher: $driftHasher,
|
'uncovered_types' => $uncoveredTypes,
|
||||||
settingsNormalizer: $settingsNormalizer,
|
],
|
||||||
assignmentsNormalizer: $assignmentsNormalizer,
|
launchContext: is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
|
||||||
scopeTagsNormalizer: $scopeTagsNormalizer,
|
|
||||||
contentEvidenceProvider: $contentEvidenceProvider,
|
|
||||||
);
|
);
|
||||||
$driftResults = $computeResult['drift'];
|
|
||||||
$driftGaps = $computeResult['evidence_gaps'];
|
try {
|
||||||
$rbacRoleDefinitionSummary = $computeResult['rbac_role_definitions'];
|
$compareResult = $strategy->compare(
|
||||||
|
context: $orchestrationContext,
|
||||||
|
tenant: $tenant,
|
||||||
|
baselineItems: $baselineItems,
|
||||||
|
currentItems: $currentItems,
|
||||||
|
resolvedCurrentEvidence: $resolvedEffectiveCurrentEvidence,
|
||||||
|
severityMapping: $this->resolveSeverityMapping($workspace, $settingsResolver),
|
||||||
|
);
|
||||||
|
} catch (\Throwable $exception) {
|
||||||
|
$failedContext = $this->withCompareStrategyDiagnostics(
|
||||||
|
context: is_array($this->operationRun->context) ? $this->operationRun->context : [],
|
||||||
|
strategySelection: $strategySelection,
|
||||||
|
executionDiagnostics: [
|
||||||
|
'failed' => true,
|
||||||
|
'exception_class' => $exception::class,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->operationRun->update(['context' => $failedContext]);
|
||||||
|
$this->operationRun->refresh();
|
||||||
|
|
||||||
|
$this->completeWithCoverageWarning(
|
||||||
|
operationRunService: $operationRunService,
|
||||||
|
auditLogger: $auditLogger,
|
||||||
|
tenant: $tenant,
|
||||||
|
profile: $profile,
|
||||||
|
initiator: $initiator,
|
||||||
|
inventorySyncRun: $inventorySyncRun,
|
||||||
|
coverageProof: true,
|
||||||
|
effectiveTypes: $effectiveTypes,
|
||||||
|
coveredTypes: $coveredTypes,
|
||||||
|
uncoveredTypes: $uncoveredTypes,
|
||||||
|
errorsRecorded: max(1, count($coveredTypes !== [] ? $coveredTypes : $effectiveTypes)),
|
||||||
|
captureMode: $captureMode,
|
||||||
|
reasonCode: BaselineCompareReasonCode::StrategyFailed,
|
||||||
|
evidenceGapsByReason: [
|
||||||
|
'strategy_failed' => max(1, count($coveredTypes !== [] ? $coveredTypes : $effectiveTypes)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedStrategyResults = $this->normalizeStrategySubjectResults($compareResult['subject_results'] ?? []);
|
||||||
|
$driftResults = $normalizedStrategyResults['drift_results'];
|
||||||
|
$driftGaps = $normalizedStrategyResults['gap_counts'];
|
||||||
|
$rbacRoleDefinitionSummary = is_array($compareResult['diagnostics']['rbac_role_definitions'] ?? null)
|
||||||
|
? $compareResult['diagnostics']['rbac_role_definitions']
|
||||||
|
: $this->emptyRbacRoleDefinitionSummary();
|
||||||
|
$strategyGapSubjects = $normalizedStrategyResults['gap_subjects'];
|
||||||
|
$strategyStateCounts = $normalizedStrategyResults['state_counts'];
|
||||||
|
$strategyDiagnostics = is_array($compareResult['diagnostics'] ?? null)
|
||||||
|
? $compareResult['diagnostics']
|
||||||
|
: [];
|
||||||
|
|
||||||
$upsertResult = $this->upsertFindings(
|
$upsertResult = $this->upsertFindings(
|
||||||
$tenant,
|
$tenant,
|
||||||
@ -502,7 +596,7 @@ public function handle(
|
|||||||
$gapSubjects = $this->collectGapSubjects(
|
$gapSubjects = $this->collectGapSubjects(
|
||||||
ambiguousKeys: $ambiguousKeys,
|
ambiguousKeys: $ambiguousKeys,
|
||||||
phaseGapSubjects: $phaseGapSubjects ?? [],
|
phaseGapSubjects: $phaseGapSubjects ?? [],
|
||||||
driftGapSubjects: $computeResult['evidence_gap_subjects'] ?? [],
|
driftGapSubjects: $strategyGapSubjects,
|
||||||
);
|
);
|
||||||
|
|
||||||
$summaryCounts = [
|
$summaryCounts = [
|
||||||
@ -565,6 +659,9 @@ public function handle(
|
|||||||
} elseif (count($driftResults) === 0) {
|
} elseif (count($driftResults) === 0) {
|
||||||
$reasonCode = match (true) {
|
$reasonCode = match (true) {
|
||||||
$uncoveredTypes !== [] => BaselineCompareReasonCode::CoverageUnproven,
|
$uncoveredTypes !== [] => BaselineCompareReasonCode::CoverageUnproven,
|
||||||
|
($strategyStateCounts[CompareState::Failed->value] ?? 0) > 0 => BaselineCompareReasonCode::StrategyFailed,
|
||||||
|
($strategyStateCounts[CompareState::Ambiguous->value] ?? 0) > 0 => BaselineCompareReasonCode::AmbiguousSubjects,
|
||||||
|
($strategyStateCounts[CompareState::Unsupported->value] ?? 0) > 0 => BaselineCompareReasonCode::UnsupportedSubjects,
|
||||||
$resumeToken !== null || $gapsCount > 0 => BaselineCompareReasonCode::EvidenceCaptureIncomplete,
|
$resumeToken !== null || $gapsCount > 0 => BaselineCompareReasonCode::EvidenceCaptureIncomplete,
|
||||||
default => BaselineCompareReasonCode::NoDriftDetected,
|
default => BaselineCompareReasonCode::NoDriftDetected,
|
||||||
};
|
};
|
||||||
@ -577,6 +674,11 @@ public function handle(
|
|||||||
'inventory_sync_run_id' => (int) $inventorySyncRun->getKey(),
|
'inventory_sync_run_id' => (int) $inventorySyncRun->getKey(),
|
||||||
'since' => $since?->toIso8601String(),
|
'since' => $since?->toIso8601String(),
|
||||||
'subjects_total' => $subjectsTotal,
|
'subjects_total' => $subjectsTotal,
|
||||||
|
'strategy' => $this->strategyContext(
|
||||||
|
strategySelection: $strategySelection,
|
||||||
|
executionDiagnostics: $strategyDiagnostics,
|
||||||
|
stateCounts: $strategyStateCounts,
|
||||||
|
),
|
||||||
'evidence_capture' => $phaseStats,
|
'evidence_capture' => $phaseStats,
|
||||||
'evidence_gaps' => [
|
'evidence_gaps' => [
|
||||||
'count' => $gapsCount,
|
'count' => $gapsCount,
|
||||||
@ -994,6 +1096,193 @@ private function withCompareReasonTranslation(array $context, ?string $reasonCod
|
|||||||
return $context;
|
return $context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function withCompareStrategySelection(array $context, CompareStrategySelection $strategySelection): array
|
||||||
|
{
|
||||||
|
$context['baseline_compare'] = array_merge(
|
||||||
|
is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
|
||||||
|
[
|
||||||
|
'strategy' => $this->strategyContext($strategySelection),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
* @param array<string, mixed> $executionDiagnostics
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function withCompareStrategyDiagnostics(
|
||||||
|
array $context,
|
||||||
|
CompareStrategySelection $strategySelection,
|
||||||
|
array $executionDiagnostics,
|
||||||
|
array $stateCounts = [],
|
||||||
|
): array {
|
||||||
|
$context['baseline_compare'] = array_merge(
|
||||||
|
is_array($context['baseline_compare'] ?? null) ? $context['baseline_compare'] : [],
|
||||||
|
[
|
||||||
|
'strategy' => $this->strategyContext($strategySelection, $executionDiagnostics, $stateCounts),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return $context;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $executionDiagnostics
|
||||||
|
* @param array<string, int> $stateCounts
|
||||||
|
* @return array{
|
||||||
|
* key: ?string,
|
||||||
|
* selection_state: string,
|
||||||
|
* matched_scope_entries: list<array<string, mixed>>,
|
||||||
|
* rejected_scope_entries: list<array<string, mixed>>,
|
||||||
|
* operator_reason: string,
|
||||||
|
* diagnostics: array<string, mixed>,
|
||||||
|
* execution_diagnostics: array<string, mixed>,
|
||||||
|
* state_counts: array<string, int>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function strategyContext(
|
||||||
|
CompareStrategySelection $strategySelection,
|
||||||
|
array $executionDiagnostics = [],
|
||||||
|
array $stateCounts = [],
|
||||||
|
): array {
|
||||||
|
return [
|
||||||
|
'key' => $strategySelection->strategyKey?->value,
|
||||||
|
'selection_state' => $strategySelection->selectionState->value,
|
||||||
|
'matched_scope_entries' => $strategySelection->matchedScopeEntries,
|
||||||
|
'rejected_scope_entries' => $strategySelection->rejectedScopeEntries,
|
||||||
|
'operator_reason' => $strategySelection->operatorReason,
|
||||||
|
'diagnostics' => $strategySelection->diagnostics,
|
||||||
|
'execution_diagnostics' => $executionDiagnostics,
|
||||||
|
'state_counts' => $stateCounts,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function strategySelectionGapReason(CompareStrategySelection $strategySelection): string
|
||||||
|
{
|
||||||
|
return $strategySelection->selectionState === StrategySelectionState::Mixed
|
||||||
|
? 'mixed_scope'
|
||||||
|
: 'unsupported_subjects';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $subjectResults
|
||||||
|
* @return array{
|
||||||
|
* drift_results: 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>}>,
|
||||||
|
* gap_counts: array<string, int>,
|
||||||
|
* gap_subjects: list<array<string, mixed>>,
|
||||||
|
* state_counts: array<string, int>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function normalizeStrategySubjectResults(mixed $subjectResults): array
|
||||||
|
{
|
||||||
|
if (! is_array($subjectResults)) {
|
||||||
|
return [
|
||||||
|
'drift_results' => [],
|
||||||
|
'gap_counts' => [],
|
||||||
|
'gap_subjects' => [],
|
||||||
|
'state_counts' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$driftResults = [];
|
||||||
|
$gapCounts = [];
|
||||||
|
$gapSubjects = [];
|
||||||
|
$stateCounts = [];
|
||||||
|
|
||||||
|
foreach ($subjectResults as $subjectResult) {
|
||||||
|
if (! $subjectResult instanceof CompareSubjectResult) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = $subjectResult->compareState->value;
|
||||||
|
$stateCounts[$state] = ($stateCounts[$state] ?? 0) + 1;
|
||||||
|
|
||||||
|
if ($subjectResult->compareState === CompareState::Drift && $subjectResult->findingCandidate instanceof CompareFindingCandidate) {
|
||||||
|
$driftResults[] = [
|
||||||
|
'change_type' => $subjectResult->findingCandidate->changeType,
|
||||||
|
'severity' => $subjectResult->findingCandidate->severity,
|
||||||
|
'subject_type' => $subjectResult->projection->platformSubjectClass,
|
||||||
|
'subject_external_id' => $subjectResult->subjectIdentity->externalSubjectId ?? '',
|
||||||
|
'subject_key' => $subjectResult->subjectIdentity->subjectKey,
|
||||||
|
'policy_type' => $subjectResult->subjectIdentity->subjectTypeKey,
|
||||||
|
'evidence_fidelity' => $subjectResult->evidenceQuality,
|
||||||
|
'baseline_hash' => is_string(data_get($subjectResult->findingCandidate->evidencePayload, 'baseline.hash'))
|
||||||
|
? (string) data_get($subjectResult->findingCandidate->evidencePayload, 'baseline.hash')
|
||||||
|
: '',
|
||||||
|
'current_hash' => is_string(data_get($subjectResult->findingCandidate->evidencePayload, 'current.hash'))
|
||||||
|
? (string) data_get($subjectResult->findingCandidate->evidencePayload, 'current.hash')
|
||||||
|
: '',
|
||||||
|
'evidence' => $subjectResult->findingCandidate->evidencePayload,
|
||||||
|
];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $subjectResult->isGapState()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reasonCode = $subjectResult->gapReasonCode() ?? $this->defaultGapReasonForState($subjectResult->compareState);
|
||||||
|
$gapCounts[$reasonCode] = ($gapCounts[$reasonCode] ?? 0) + 1;
|
||||||
|
$gapSubjects[] = $subjectResult->gapRecord() ?? $this->fallbackGapRecord($subjectResult, $reasonCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($gapCounts);
|
||||||
|
ksort($stateCounts);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'drift_results' => $driftResults,
|
||||||
|
'gap_counts' => $gapCounts,
|
||||||
|
'gap_subjects' => $gapSubjects,
|
||||||
|
'state_counts' => $stateCounts,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function defaultGapReasonForState(CompareState $state): string
|
||||||
|
{
|
||||||
|
return match ($state) {
|
||||||
|
CompareState::Unsupported => 'unsupported_subject',
|
||||||
|
CompareState::Ambiguous => 'ambiguous_match',
|
||||||
|
CompareState::Failed => 'strategy_failed',
|
||||||
|
default => 'missing_current',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function fallbackGapRecord(CompareSubjectResult $subjectResult, string $reasonCode): array
|
||||||
|
{
|
||||||
|
$descriptor = $this->subjectResolver()->describeForCompare(
|
||||||
|
policyType: $subjectResult->subjectIdentity->subjectTypeKey,
|
||||||
|
subjectExternalId: $subjectResult->subjectIdentity->externalSubjectId,
|
||||||
|
subjectKey: $subjectResult->subjectIdentity->subjectKey,
|
||||||
|
);
|
||||||
|
|
||||||
|
$outcome = match ($reasonCode) {
|
||||||
|
'missing_current' => $this->subjectResolver()->missingExpectedRecord($descriptor),
|
||||||
|
'ambiguous_match' => $this->subjectResolver()->ambiguousMatch($descriptor),
|
||||||
|
default => $this->subjectResolver()->captureFailed($descriptor),
|
||||||
|
};
|
||||||
|
|
||||||
|
return array_merge($descriptor->toArray(), $outcome->toArray(), [
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'search_text' => strtolower(implode(' ', array_filter([
|
||||||
|
$subjectResult->subjectIdentity->subjectTypeKey,
|
||||||
|
$subjectResult->subjectIdentity->subjectKey,
|
||||||
|
$reasonCode,
|
||||||
|
$subjectResult->projection->operatorLabel,
|
||||||
|
]))),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load current inventory items keyed by "policy_type|subject_key".
|
* Load current inventory items keyed by "policy_type|subject_key".
|
||||||
*
|
*
|
||||||
@ -1069,13 +1358,13 @@ private function loadCurrentInventory(
|
|||||||
'subject_external_id' => (string) $inventoryItem->external_id,
|
'subject_external_id' => (string) $inventoryItem->external_id,
|
||||||
'subject_key' => $subjectKey,
|
'subject_key' => $subjectKey,
|
||||||
'policy_type' => (string) $inventoryItem->policy_type,
|
'policy_type' => (string) $inventoryItem->policy_type,
|
||||||
'meta_jsonb' => [
|
'meta_jsonb' => array_replace($metaJsonb, [
|
||||||
'display_name' => $inventoryItem->display_name,
|
'display_name' => $metaJsonb['display_name'] ?? $inventoryItem->display_name,
|
||||||
'category' => $inventoryItem->category,
|
'category' => $metaJsonb['category'] ?? $inventoryItem->category,
|
||||||
'platform' => $inventoryItem->platform,
|
'platform' => $metaJsonb['platform'] ?? $inventoryItem->platform,
|
||||||
'is_built_in' => $metaJsonb['is_built_in'] ?? null,
|
'is_built_in' => $metaJsonb['is_built_in'] ?? null,
|
||||||
'role_permission_count' => $metaJsonb['role_permission_count'] ?? null,
|
'role_permission_count' => $metaJsonb['role_permission_count'] ?? null,
|
||||||
],
|
]),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -19,6 +19,9 @@
|
|||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
use App\Support\Baselines\BaselineScope;
|
use App\Support\Baselines\BaselineScope;
|
||||||
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
use App\Support\Baselines\BaselineSupportCapabilityGuard;
|
||||||
|
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||||
|
use App\Support\Baselines\Compare\CompareStrategySelection;
|
||||||
|
use App\Support\Baselines\Compare\StrategySelectionState;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use App\Support\ReasonTranslation\ReasonPresenter;
|
use App\Support\ReasonTranslation\ReasonPresenter;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
@ -31,6 +34,7 @@ public function __construct(
|
|||||||
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
|
||||||
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
|
private readonly BaselineSupportCapabilityGuard $capabilityGuard,
|
||||||
private readonly CapabilityResolver $capabilityResolver,
|
private readonly CapabilityResolver $capabilityResolver,
|
||||||
|
private readonly CompareStrategyRegistry $compareStrategyRegistry,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -109,9 +113,7 @@ public function startCompareForProfile(
|
|||||||
$snapshotId = (int) $snapshot->getKey();
|
$snapshotId = (int) $snapshot->getKey();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$profileScope = BaselineScope::fromJsonb(
|
$profileScope = $profile->normalizedScope();
|
||||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
|
||||||
);
|
|
||||||
$overrideScope = $assignment->override_scope_jsonb !== null
|
$overrideScope = $assignment->override_scope_jsonb !== null
|
||||||
? BaselineScope::fromJsonb(
|
? BaselineScope::fromJsonb(
|
||||||
is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null,
|
is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null,
|
||||||
@ -127,10 +129,10 @@ public function startCompareForProfile(
|
|||||||
return $this->failedStart(BaselineReasonCodes::COMPARE_INVALID_SCOPE);
|
return $this->failedStart(BaselineReasonCodes::COMPARE_INVALID_SCOPE);
|
||||||
}
|
}
|
||||||
|
|
||||||
$eligibility = $effectiveScope->operationEligibility('compare', $this->capabilityGuard);
|
$selection = $this->compareStrategyRegistry->select($effectiveScope);
|
||||||
|
|
||||||
if (! $eligibility['ok']) {
|
if (! $selection->isSupported()) {
|
||||||
return $this->failedStart(BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE);
|
return $this->failedStart($this->selectionFailureReasonCode($selection));
|
||||||
}
|
}
|
||||||
|
|
||||||
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
$captureMode = $profile->capture_mode instanceof BaselineCaptureMode
|
||||||
@ -146,6 +148,9 @@ public function startCompareForProfile(
|
|||||||
'baseline_snapshot_id' => $snapshotId,
|
'baseline_snapshot_id' => $snapshotId,
|
||||||
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'compare'),
|
'effective_scope' => $effectiveScope->toEffectiveScopeContext($this->capabilityGuard, 'compare'),
|
||||||
'capture_mode' => $captureMode->value,
|
'capture_mode' => $captureMode->value,
|
||||||
|
'baseline_compare' => [
|
||||||
|
'strategy' => $this->strategyContext($selection),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
$run = $this->runs->ensureRunWithIdentity(
|
$run = $this->runs->ensureRunWithIdentity(
|
||||||
@ -275,6 +280,36 @@ private function validatePreconditions(BaselineProfile $profile): ?string
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function selectionFailureReasonCode(CompareStrategySelection $selection): string
|
||||||
|
{
|
||||||
|
return match ($selection->selectionState) {
|
||||||
|
StrategySelectionState::Mixed => BaselineReasonCodes::COMPARE_MIXED_SCOPE,
|
||||||
|
default => BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* key: ?string,
|
||||||
|
* selection_state: string,
|
||||||
|
* matched_scope_entries: list<array<string, mixed>>,
|
||||||
|
* rejected_scope_entries: list<array<string, mixed>>,
|
||||||
|
* operator_reason: string,
|
||||||
|
* diagnostics: array<string, mixed>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function strategyContext(CompareStrategySelection $selection): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'key' => $selection->strategyKey?->value,
|
||||||
|
'selection_state' => $selection->selectionState->value,
|
||||||
|
'matched_scope_entries' => $selection->matchedScopeEntries,
|
||||||
|
'rejected_scope_entries' => $selection->rejectedScopeEntries,
|
||||||
|
'operator_reason' => $selection->operatorReason,
|
||||||
|
'diagnostics' => $selection->diagnostics,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{ok: false, reason_code: string, reason_translation?: array<string, mixed>}
|
* @return array{ok: false, reason_code: string, reason_translation?: array<string, mixed>}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
@ -151,6 +152,7 @@ public static function diagnosticsPayload(array $baselineCompare): array
|
|||||||
'subjects_total' => self::intOrNull($baselineCompare['subjects_total'] ?? null),
|
'subjects_total' => self::intOrNull($baselineCompare['subjects_total'] ?? null),
|
||||||
'resume_token' => self::stringOrNull($baselineCompare['resume_token'] ?? null),
|
'resume_token' => self::stringOrNull($baselineCompare['resume_token'] ?? null),
|
||||||
'fidelity' => self::stringOrNull($baselineCompare['fidelity'] ?? null),
|
'fidelity' => self::stringOrNull($baselineCompare['fidelity'] ?? null),
|
||||||
|
'strategy' => is_array($baselineCompare['strategy'] ?? null) ? $baselineCompare['strategy'] : null,
|
||||||
'coverage' => is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : null,
|
'coverage' => is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : null,
|
||||||
'evidence_capture' => is_array($baselineCompare['evidence_capture'] ?? null) ? $baselineCompare['evidence_capture'] : null,
|
'evidence_capture' => is_array($baselineCompare['evidence_capture'] ?? null) ? $baselineCompare['evidence_capture'] : null,
|
||||||
'evidence_gaps' => is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : null,
|
'evidence_gaps' => is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : null,
|
||||||
@ -332,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),
|
||||||
@ -372,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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -658,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),
|
||||||
@ -681,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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,9 @@ enum BaselineCompareReasonCode: string
|
|||||||
case NoSubjectsInScope = 'no_subjects_in_scope';
|
case NoSubjectsInScope = 'no_subjects_in_scope';
|
||||||
case CoverageUnproven = 'coverage_unproven';
|
case CoverageUnproven = 'coverage_unproven';
|
||||||
case EvidenceCaptureIncomplete = 'evidence_capture_incomplete';
|
case EvidenceCaptureIncomplete = 'evidence_capture_incomplete';
|
||||||
|
case UnsupportedSubjects = 'unsupported_subjects';
|
||||||
|
case AmbiguousSubjects = 'ambiguous_subjects';
|
||||||
|
case StrategyFailed = 'strategy_failed';
|
||||||
case RolloutDisabled = 'rollout_disabled';
|
case RolloutDisabled = 'rollout_disabled';
|
||||||
case NoDriftDetected = 'no_drift_detected';
|
case NoDriftDetected = 'no_drift_detected';
|
||||||
case OverdueFindingsRemain = 'overdue_findings_remain';
|
case OverdueFindingsRemain = 'overdue_findings_remain';
|
||||||
@ -24,6 +27,9 @@ public function message(): string
|
|||||||
self::NoSubjectsInScope => 'No subjects were in scope for this comparison.',
|
self::NoSubjectsInScope => 'No subjects were in scope for this comparison.',
|
||||||
self::CoverageUnproven => 'Coverage proof was missing or incomplete, so some findings were suppressed for safety.',
|
self::CoverageUnproven => 'Coverage proof was missing or incomplete, so some findings were suppressed for safety.',
|
||||||
self::EvidenceCaptureIncomplete => 'Evidence capture was incomplete, so some drift evaluation may have been suppressed.',
|
self::EvidenceCaptureIncomplete => 'Evidence capture was incomplete, so some drift evaluation may have been suppressed.',
|
||||||
|
self::UnsupportedSubjects => 'One or more in-scope subjects could not be compared by the selected strategy.',
|
||||||
|
self::AmbiguousSubjects => 'One or more in-scope subjects could not be compared because identity matching stayed ambiguous.',
|
||||||
|
self::StrategyFailed => 'One or more in-scope subjects failed during strategy processing, so the compare result is incomplete.',
|
||||||
self::RolloutDisabled => 'Full-content baseline compare is currently disabled by rollout configuration.',
|
self::RolloutDisabled => 'Full-content baseline compare is currently disabled by rollout configuration.',
|
||||||
self::NoDriftDetected => 'No drift was detected for in-scope subjects.',
|
self::NoDriftDetected => 'No drift was detected for in-scope subjects.',
|
||||||
self::OverdueFindingsRemain => 'Overdue findings still need action even though the latest compare did not produce new drift.',
|
self::OverdueFindingsRemain => 'Overdue findings still need action even though the latest compare did not produce new drift.',
|
||||||
@ -38,10 +44,13 @@ public function explanationFamily(): ExplanationFamily
|
|||||||
self::NoDriftDetected => ExplanationFamily::NoIssuesDetected,
|
self::NoDriftDetected => ExplanationFamily::NoIssuesDetected,
|
||||||
self::CoverageUnproven,
|
self::CoverageUnproven,
|
||||||
self::EvidenceCaptureIncomplete,
|
self::EvidenceCaptureIncomplete,
|
||||||
|
self::UnsupportedSubjects,
|
||||||
|
self::AmbiguousSubjects,
|
||||||
self::RolloutDisabled,
|
self::RolloutDisabled,
|
||||||
self::OverdueFindingsRemain,
|
self::OverdueFindingsRemain,
|
||||||
self::GovernanceExpiring,
|
self::GovernanceExpiring,
|
||||||
self::GovernanceLapsed => ExplanationFamily::CompletedButLimited,
|
self::GovernanceLapsed => ExplanationFamily::CompletedButLimited,
|
||||||
|
self::StrategyFailed => ExplanationFamily::BlockedPrerequisite,
|
||||||
self::NoSubjectsInScope => ExplanationFamily::MissingInput,
|
self::NoSubjectsInScope => ExplanationFamily::MissingInput,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -52,9 +61,12 @@ public function trustworthinessLevel(): TrustworthinessLevel
|
|||||||
self::NoDriftDetected => TrustworthinessLevel::Trustworthy,
|
self::NoDriftDetected => TrustworthinessLevel::Trustworthy,
|
||||||
self::CoverageUnproven,
|
self::CoverageUnproven,
|
||||||
self::EvidenceCaptureIncomplete,
|
self::EvidenceCaptureIncomplete,
|
||||||
|
self::UnsupportedSubjects,
|
||||||
|
self::AmbiguousSubjects,
|
||||||
self::OverdueFindingsRemain,
|
self::OverdueFindingsRemain,
|
||||||
self::GovernanceExpiring,
|
self::GovernanceExpiring,
|
||||||
self::GovernanceLapsed => TrustworthinessLevel::LimitedConfidence,
|
self::GovernanceLapsed => TrustworthinessLevel::LimitedConfidence,
|
||||||
|
self::StrategyFailed,
|
||||||
self::RolloutDisabled,
|
self::RolloutDisabled,
|
||||||
self::NoSubjectsInScope => TrustworthinessLevel::Unusable,
|
self::NoSubjectsInScope => TrustworthinessLevel::Unusable,
|
||||||
};
|
};
|
||||||
@ -66,9 +78,12 @@ public function absencePattern(): ?string
|
|||||||
self::NoDriftDetected => 'true_no_result',
|
self::NoDriftDetected => 'true_no_result',
|
||||||
self::CoverageUnproven,
|
self::CoverageUnproven,
|
||||||
self::EvidenceCaptureIncomplete,
|
self::EvidenceCaptureIncomplete,
|
||||||
|
self::UnsupportedSubjects,
|
||||||
|
self::AmbiguousSubjects,
|
||||||
self::OverdueFindingsRemain,
|
self::OverdueFindingsRemain,
|
||||||
self::GovernanceExpiring,
|
self::GovernanceExpiring,
|
||||||
self::GovernanceLapsed => 'suppressed_output',
|
self::GovernanceLapsed => 'suppressed_output',
|
||||||
|
self::StrategyFailed,
|
||||||
self::RolloutDisabled => 'blocked_prerequisite',
|
self::RolloutDisabled => 'blocked_prerequisite',
|
||||||
self::NoSubjectsInScope => 'missing_input',
|
self::NoSubjectsInScope => 'missing_input',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -122,9 +122,7 @@ public static function forTenant(?Tenant $tenant): self
|
|||||||
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode);
|
$snapshotReasonMessage = self::missingSnapshotMessage($snapshotReasonCode);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$profileScope = BaselineScope::fromJsonb(
|
$profileScope = $profile->normalizedScope();
|
||||||
is_array($profile->scope_jsonb) ? $profile->scope_jsonb : null,
|
|
||||||
);
|
|
||||||
$overrideScope = $assignment->override_scope_jsonb !== null
|
$overrideScope = $assignment->override_scope_jsonb !== null
|
||||||
? BaselineScope::fromJsonb(
|
? BaselineScope::fromJsonb(
|
||||||
is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null,
|
is_array($assignment->override_scope_jsonb) ? $assignment->override_scope_jsonb : null,
|
||||||
|
|||||||
@ -54,6 +54,8 @@ final class BaselineReasonCodes
|
|||||||
|
|
||||||
public const string COMPARE_UNSUPPORTED_SCOPE = 'baseline.compare.unsupported_scope';
|
public const string COMPARE_UNSUPPORTED_SCOPE = 'baseline.compare.unsupported_scope';
|
||||||
|
|
||||||
|
public const string COMPARE_MIXED_SCOPE = 'baseline.compare.mixed_scope';
|
||||||
|
|
||||||
public const string COMPARE_SNAPSHOT_BUILDING = 'baseline.compare.snapshot_building';
|
public const string COMPARE_SNAPSHOT_BUILDING = 'baseline.compare.snapshot_building';
|
||||||
|
|
||||||
public const string COMPARE_SNAPSHOT_INCOMPLETE = 'baseline.compare.snapshot_incomplete';
|
public const string COMPARE_SNAPSHOT_INCOMPLETE = 'baseline.compare.snapshot_incomplete';
|
||||||
@ -87,6 +89,7 @@ public static function all(): array
|
|||||||
self::COMPARE_ROLLOUT_DISABLED,
|
self::COMPARE_ROLLOUT_DISABLED,
|
||||||
self::COMPARE_INVALID_SCOPE,
|
self::COMPARE_INVALID_SCOPE,
|
||||||
self::COMPARE_UNSUPPORTED_SCOPE,
|
self::COMPARE_UNSUPPORTED_SCOPE,
|
||||||
|
self::COMPARE_MIXED_SCOPE,
|
||||||
self::COMPARE_SNAPSHOT_BUILDING,
|
self::COMPARE_SNAPSHOT_BUILDING,
|
||||||
self::COMPARE_SNAPSHOT_INCOMPLETE,
|
self::COMPARE_SNAPSHOT_INCOMPLETE,
|
||||||
self::COMPARE_SNAPSHOT_SUPERSEDED,
|
self::COMPARE_SNAPSHOT_SUPERSEDED,
|
||||||
@ -121,6 +124,7 @@ public static function trustImpact(?string $reasonCode): ?string
|
|||||||
self::COMPARE_SNAPSHOT_SUPERSEDED,
|
self::COMPARE_SNAPSHOT_SUPERSEDED,
|
||||||
self::COMPARE_INVALID_SCOPE,
|
self::COMPARE_INVALID_SCOPE,
|
||||||
self::COMPARE_UNSUPPORTED_SCOPE,
|
self::COMPARE_UNSUPPORTED_SCOPE,
|
||||||
|
self::COMPARE_MIXED_SCOPE,
|
||||||
self::CAPTURE_MISSING_SOURCE_TENANT,
|
self::CAPTURE_MISSING_SOURCE_TENANT,
|
||||||
self::CAPTURE_PROFILE_NOT_ACTIVE,
|
self::CAPTURE_PROFILE_NOT_ACTIVE,
|
||||||
self::CAPTURE_INVALID_SCOPE,
|
self::CAPTURE_INVALID_SCOPE,
|
||||||
@ -150,6 +154,7 @@ public static function absencePattern(?string $reasonCode): ?string
|
|||||||
self::COMPARE_INVALID_SNAPSHOT,
|
self::COMPARE_INVALID_SNAPSHOT,
|
||||||
self::COMPARE_INVALID_SCOPE,
|
self::COMPARE_INVALID_SCOPE,
|
||||||
self::COMPARE_UNSUPPORTED_SCOPE,
|
self::COMPARE_UNSUPPORTED_SCOPE,
|
||||||
|
self::COMPARE_MIXED_SCOPE,
|
||||||
self::COMPARE_ROLLOUT_DISABLED,
|
self::COMPARE_ROLLOUT_DISABLED,
|
||||||
self::SNAPSHOT_SUPERSEDED,
|
self::SNAPSHOT_SUPERSEDED,
|
||||||
self::COMPARE_SNAPSHOT_SUPERSEDED => 'blocked_prerequisite',
|
self::COMPARE_SNAPSHOT_SUPERSEDED => 'blocked_prerequisite',
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
@ -187,9 +188,19 @@ public function allTypes(): array
|
|||||||
{
|
{
|
||||||
$expanded = $this->expandDefaults();
|
$expanded = $this->expandDefaults();
|
||||||
|
|
||||||
|
$canonicalTypeKeys = [];
|
||||||
|
|
||||||
|
foreach ($expanded->entries as $entry) {
|
||||||
|
$canonicalTypeKeys = array_merge(
|
||||||
|
$canonicalTypeKeys,
|
||||||
|
is_array($entry['subject_type_keys'] ?? null) ? $entry['subject_type_keys'] : [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return self::uniqueSorted(array_merge(
|
return self::uniqueSorted(array_merge(
|
||||||
$expanded->policyTypes,
|
$expanded->policyTypes,
|
||||||
$expanded->foundationTypes,
|
$expanded->foundationTypes,
|
||||||
|
$canonicalTypeKeys,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -293,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.
|
||||||
*
|
*
|
||||||
@ -301,7 +322,7 @@ public function summaryGroups(?GovernanceSubjectTaxonomyRegistry $registry = nul
|
|||||||
public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard = null, ?string $operation = null): array
|
public function toEffectiveScopeContext(?BaselineSupportCapabilityGuard $guard = null, ?string $operation = null): array
|
||||||
{
|
{
|
||||||
$expanded = $this->expandDefaults();
|
$expanded = $this->expandDefaults();
|
||||||
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
|
$allTypes = $expanded->allTypes();
|
||||||
|
|
||||||
$context = [
|
$context = [
|
||||||
'canonical_scope' => $expanded->toStoredJsonb(),
|
'canonical_scope' => $expanded->toStoredJsonb(),
|
||||||
@ -311,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 === '') {
|
||||||
|
|||||||
@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines\Compare;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class CompareFindingCandidate
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $fingerprintBasis
|
||||||
|
* @param array<string, mixed> $evidencePayload
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $changeType,
|
||||||
|
public readonly string $severity,
|
||||||
|
public readonly array $fingerprintBasis,
|
||||||
|
public readonly array $evidencePayload,
|
||||||
|
public readonly bool $autoCloseEligible = true,
|
||||||
|
) {
|
||||||
|
if (trim($this->changeType) === '' || trim($this->severity) === '') {
|
||||||
|
throw new InvalidArgumentException('Compare finding candidates require non-empty change type and severity values.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* change_type: string,
|
||||||
|
* severity: string,
|
||||||
|
* fingerprint_basis: array<string, mixed>,
|
||||||
|
* evidence_payload: array<string, mixed>,
|
||||||
|
* auto_close_eligible: bool
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'change_type' => $this->changeType,
|
||||||
|
'severity' => $this->severity,
|
||||||
|
'fingerprint_basis' => $this->fingerprintBasis,
|
||||||
|
'evidence_payload' => $this->evidencePayload,
|
||||||
|
'auto_close_eligible' => $this->autoCloseEligible,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines\Compare;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class CompareOrchestrationContext
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $normalizedScope
|
||||||
|
* @param array<string, mixed> $coverageContext
|
||||||
|
* @param array<string, mixed> $launchContext
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $workspaceId,
|
||||||
|
public readonly int $tenantId,
|
||||||
|
public readonly int $baselineProfileId,
|
||||||
|
public readonly int $baselineSnapshotId,
|
||||||
|
public readonly int $operationRunId,
|
||||||
|
public readonly array $normalizedScope,
|
||||||
|
public readonly CompareStrategySelection $strategySelection,
|
||||||
|
public readonly array $coverageContext = [],
|
||||||
|
public readonly array $launchContext = [],
|
||||||
|
) {
|
||||||
|
if ($this->workspaceId <= 0 || $this->tenantId <= 0 || $this->baselineProfileId <= 0 || $this->baselineSnapshotId <= 0 || $this->operationRunId <= 0) {
|
||||||
|
throw new InvalidArgumentException('Compare orchestration contexts require positive workspace, tenant, profile, snapshot, and operation run identifiers.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function strategyKey(): CompareStrategyKey
|
||||||
|
{
|
||||||
|
if (! $this->strategySelection->strategyKey instanceof CompareStrategyKey) {
|
||||||
|
throw new InvalidArgumentException('Compare orchestration context requires a supported strategy selection before execution.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->strategySelection->strategyKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function inventorySyncRunId(): ?int
|
||||||
|
{
|
||||||
|
$value = $this->coverageContext['inventory_sync_run_id'] ?? $this->launchContext['inventory_sync_run_id'] ?? null;
|
||||||
|
|
||||||
|
return is_numeric($value) ? (int) $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* workspace_id: int,
|
||||||
|
* tenant_id: int,
|
||||||
|
* baseline_profile_id: int,
|
||||||
|
* baseline_snapshot_id: int,
|
||||||
|
* operation_run_id: int,
|
||||||
|
* normalized_scope: array<string, mixed>,
|
||||||
|
* strategy_selection: array{
|
||||||
|
* selection_state: string,
|
||||||
|
* strategy_key: ?string,
|
||||||
|
* matched_scope_entries: list<array<string, mixed>>,
|
||||||
|
* rejected_scope_entries: list<array<string, mixed>>,
|
||||||
|
* operator_reason: string,
|
||||||
|
* diagnostics: array<string, mixed>
|
||||||
|
* },
|
||||||
|
* coverage_context: array<string, mixed>,
|
||||||
|
* launch_context: array<string, mixed>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'workspace_id' => $this->workspaceId,
|
||||||
|
'tenant_id' => $this->tenantId,
|
||||||
|
'baseline_profile_id' => $this->baselineProfileId,
|
||||||
|
'baseline_snapshot_id' => $this->baselineSnapshotId,
|
||||||
|
'operation_run_id' => $this->operationRunId,
|
||||||
|
'normalized_scope' => $this->normalizedScope,
|
||||||
|
'strategy_selection' => $this->strategySelection->toArray(),
|
||||||
|
'coverage_context' => $this->coverageContext,
|
||||||
|
'launch_context' => $this->launchContext,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/platform/app/Support/Baselines/Compare/CompareState.php
Normal file
15
apps/platform/app/Support/Baselines/Compare/CompareState.php
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines\Compare;
|
||||||
|
|
||||||
|
enum CompareState: string
|
||||||
|
{
|
||||||
|
case NoDrift = 'no_drift';
|
||||||
|
case Drift = 'drift';
|
||||||
|
case Unsupported = 'unsupported';
|
||||||
|
case Incomplete = 'incomplete';
|
||||||
|
case Ambiguous = 'ambiguous';
|
||||||
|
case Failed = 'failed';
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines\Compare;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
||||||
|
|
||||||
|
interface CompareStrategy
|
||||||
|
{
|
||||||
|
public function key(): CompareStrategyKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<CompareStrategyCapability>
|
||||||
|
*/
|
||||||
|
public function capabilities(): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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{subject_results: list<CompareSubjectResult>, diagnostics: array<string, mixed>}
|
||||||
|
*/
|
||||||
|
public function compare(
|
||||||
|
CompareOrchestrationContext $context,
|
||||||
|
Tenant $tenant,
|
||||||
|
array $baselineItems,
|
||||||
|
array $currentItems,
|
||||||
|
array $resolvedCurrentEvidence,
|
||||||
|
array $severityMapping,
|
||||||
|
): array;
|
||||||
|
}
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines\Compare;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class CompareStrategyCapability
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<string> $domainKeys
|
||||||
|
* @param list<string> $subjectClasses
|
||||||
|
* @param list<string>|'all' $subjectTypeKeys
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly CompareStrategyKey $strategyKey,
|
||||||
|
public readonly array $domainKeys,
|
||||||
|
public readonly array $subjectClasses,
|
||||||
|
public readonly array|string $subjectTypeKeys = 'all',
|
||||||
|
public readonly bool $compareSupported = true,
|
||||||
|
public readonly bool $active = true,
|
||||||
|
) {
|
||||||
|
if ($this->domainKeys === [] || $this->subjectClasses === []) {
|
||||||
|
throw new InvalidArgumentException('Compare strategy capabilities require at least one domain key and one subject class.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->subjectTypeKeys !== 'all' && $this->subjectTypeKeys === []) {
|
||||||
|
throw new InvalidArgumentException('Compare strategy capabilities must either support all subject type keys or at least one explicit subject type key.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{domain_key?: mixed, subject_class?: mixed, subject_type_keys?: mixed} $entry
|
||||||
|
*/
|
||||||
|
public function supportsEntry(array $entry): bool
|
||||||
|
{
|
||||||
|
if (! $this->active || ! $this->compareSupported) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$domainKey = is_string($entry['domain_key'] ?? null) ? trim((string) $entry['domain_key']) : '';
|
||||||
|
$subjectClass = is_string($entry['subject_class'] ?? null) ? trim((string) $entry['subject_class']) : '';
|
||||||
|
$subjectTypeKeys = is_array($entry['subject_type_keys'] ?? null)
|
||||||
|
? array_values(array_filter($entry['subject_type_keys'], 'is_string'))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if ($domainKey === '' || $subjectClass === '' || $subjectTypeKeys === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($domainKey, $this->domainKeys, true) || ! in_array($subjectClass, $this->subjectClasses, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->subjectTypeKeys === 'all') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_diff($subjectTypeKeys, $this->subjectTypeKeys) === [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* strategy_key: string,
|
||||||
|
* domain_keys: list<string>,
|
||||||
|
* subject_classes: list<string>,
|
||||||
|
* subject_type_keys: list<string>|'all',
|
||||||
|
* compare_supported: bool,
|
||||||
|
* active: bool
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'strategy_key' => $this->strategyKey->value,
|
||||||
|
'domain_keys' => $this->domainKeys,
|
||||||
|
'subject_classes' => $this->subjectClasses,
|
||||||
|
'subject_type_keys' => $this->subjectTypeKeys,
|
||||||
|
'compare_supported' => $this->compareSupported,
|
||||||
|
'active' => $this->active,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines\Compare;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Stringable;
|
||||||
|
|
||||||
|
final class CompareStrategyKey implements Stringable
|
||||||
|
{
|
||||||
|
public readonly string $value;
|
||||||
|
|
||||||
|
public function __construct(string $value)
|
||||||
|
{
|
||||||
|
$normalized = trim(mb_strtolower($value));
|
||||||
|
|
||||||
|
if ($normalized === '' || ! preg_match('/^[a-z0-9_]+$/', $normalized)) {
|
||||||
|
throw new InvalidArgumentException('Compare strategy keys must be non-empty lowercase snake_case strings.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->value = $normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function from(self|string $value): self
|
||||||
|
{
|
||||||
|
return $value instanceof self ? $value : new self($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function intunePolicy(): self
|
||||||
|
{
|
||||||
|
return new self('intune_policy');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function equals(self|string $other): bool
|
||||||
|
{
|
||||||
|
return $this->value === self::from($other)->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __toString(): string
|
||||||
|
{
|
||||||
|
return $this->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,202 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines\Compare;
|
||||||
|
|
||||||
|
use App\Support\Baselines\BaselineScope;
|
||||||
|
use App\Support\Governance\GovernanceDomainKey;
|
||||||
|
use App\Support\Governance\GovernanceSubjectClass;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class CompareStrategyRegistry
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<CompareStrategy> $strategies
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly array $strategies = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<CompareStrategy>
|
||||||
|
*/
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
if ($this->strategies !== []) {
|
||||||
|
return $this->strategies;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [app(IntuneCompareStrategy::class)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find(CompareStrategyKey|string|null $strategyKey): ?CompareStrategy
|
||||||
|
{
|
||||||
|
if ($strategyKey === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedKey = CompareStrategyKey::from($strategyKey);
|
||||||
|
|
||||||
|
foreach ($this->all() as $strategy) {
|
||||||
|
if ($strategy->key()->equals($normalizedKey)) {
|
||||||
|
return $strategy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function resolve(CompareStrategyKey|string $strategyKey): CompareStrategy
|
||||||
|
{
|
||||||
|
$strategy = $this->find($strategyKey);
|
||||||
|
|
||||||
|
if ($strategy instanceof CompareStrategy) {
|
||||||
|
return $strategy;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidArgumentException('Unknown compare strategy ['.CompareStrategyKey::from($strategyKey)->value.'].');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function select(BaselineScope $scope): CompareStrategySelection
|
||||||
|
{
|
||||||
|
$entries = $this->entriesForScope($scope);
|
||||||
|
|
||||||
|
if ($entries === []) {
|
||||||
|
return CompareStrategySelection::unsupported(
|
||||||
|
matchedScopeEntries: [],
|
||||||
|
rejectedScopeEntries: [],
|
||||||
|
diagnostics: [],
|
||||||
|
operatorReason: 'No governed subjects were selected for compare.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$matchedScopeEntries = [];
|
||||||
|
$rejectedScopeEntries = [];
|
||||||
|
$matchedStrategyKeys = [];
|
||||||
|
$entryMatches = [];
|
||||||
|
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
$matchingKeys = $this->matchingStrategyKeysForEntry($entry);
|
||||||
|
$entryFingerprint = $this->entryFingerprint($entry);
|
||||||
|
$entryMatches[$entryFingerprint] = $matchingKeys;
|
||||||
|
|
||||||
|
if ($matchingKeys === []) {
|
||||||
|
$rejectedScopeEntries[] = $entry;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$matchedScopeEntries[] = $entry;
|
||||||
|
$matchedStrategyKeys = array_values(array_unique(array_merge($matchedStrategyKeys, $matchingKeys)));
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($matchedStrategyKeys, SORT_STRING);
|
||||||
|
|
||||||
|
if ($rejectedScopeEntries !== []) {
|
||||||
|
return CompareStrategySelection::unsupported(
|
||||||
|
matchedScopeEntries: $matchedScopeEntries,
|
||||||
|
rejectedScopeEntries: $rejectedScopeEntries,
|
||||||
|
diagnostics: [
|
||||||
|
'matched_strategy_keys' => $matchedStrategyKeys,
|
||||||
|
'entry_matches' => $entryMatches,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($matchedStrategyKeys) !== 1) {
|
||||||
|
return CompareStrategySelection::mixed(
|
||||||
|
matchedScopeEntries: $matchedScopeEntries,
|
||||||
|
diagnostics: [
|
||||||
|
'matched_strategy_keys' => $matchedStrategyKeys,
|
||||||
|
'entry_matches' => $entryMatches,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CompareStrategySelection::supported(
|
||||||
|
strategyKey: $matchedStrategyKeys[0],
|
||||||
|
matchedScopeEntries: $matchedScopeEntries,
|
||||||
|
diagnostics: [
|
||||||
|
'matched_strategy_keys' => $matchedStrategyKeys,
|
||||||
|
'entry_matches' => $entryMatches,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{domain_key?: mixed, subject_class?: mixed, subject_type_keys?: mixed, filters?: mixed} $entry
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function matchingStrategyKeysForEntry(array $entry): array
|
||||||
|
{
|
||||||
|
$matches = [];
|
||||||
|
|
||||||
|
foreach ($this->all() as $strategy) {
|
||||||
|
foreach ($strategy->capabilities() as $capability) {
|
||||||
|
if (! $capability->supportsEntry($entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$matches[] = $strategy->key()->value;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$matches = array_values(array_unique($matches));
|
||||||
|
sort($matches, SORT_STRING);
|
||||||
|
|
||||||
|
return $matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{domain_key: string, subject_class: string, subject_type_keys: list<string>, filters: array<string, mixed>}>
|
||||||
|
*/
|
||||||
|
private function entriesForScope(BaselineScope $scope): array
|
||||||
|
{
|
||||||
|
if ($scope->entries !== []) {
|
||||||
|
return $scope->entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entries = [];
|
||||||
|
|
||||||
|
if ($scope->policyTypes !== []) {
|
||||||
|
$entries[] = [
|
||||||
|
'domain_key' => GovernanceDomainKey::Intune->value,
|
||||||
|
'subject_class' => GovernanceSubjectClass::Policy->value,
|
||||||
|
'subject_type_keys' => $scope->policyTypes,
|
||||||
|
'filters' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($scope->foundationTypes !== []) {
|
||||||
|
$entries[] = [
|
||||||
|
'domain_key' => GovernanceDomainKey::PlatformFoundation->value,
|
||||||
|
'subject_class' => GovernanceSubjectClass::ConfigurationResource->value,
|
||||||
|
'subject_type_keys' => $scope->foundationTypes,
|
||||||
|
'filters' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{domain_key?: mixed, subject_class?: mixed, subject_type_keys?: mixed, filters?: mixed} $entry
|
||||||
|
*/
|
||||||
|
private function entryFingerprint(array $entry): string
|
||||||
|
{
|
||||||
|
$subjectTypeKeys = is_array($entry['subject_type_keys'] ?? null)
|
||||||
|
? array_values(array_filter($entry['subject_type_keys'], 'is_string'))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
sort($subjectTypeKeys, SORT_STRING);
|
||||||
|
|
||||||
|
return implode('|', [
|
||||||
|
trim((string) ($entry['domain_key'] ?? '')),
|
||||||
|
trim((string) ($entry['subject_class'] ?? '')),
|
||||||
|
implode(',', $subjectTypeKeys),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines\Compare;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class CompareStrategySelection
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $matchedScopeEntries
|
||||||
|
* @param list<array<string, mixed>> $rejectedScopeEntries
|
||||||
|
* @param array<string, mixed> $diagnostics
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly StrategySelectionState $selectionState,
|
||||||
|
public readonly ?CompareStrategyKey $strategyKey,
|
||||||
|
public readonly array $matchedScopeEntries,
|
||||||
|
public readonly array $rejectedScopeEntries,
|
||||||
|
public readonly string $operatorReason,
|
||||||
|
public readonly array $diagnostics = [],
|
||||||
|
) {
|
||||||
|
if ($this->selectionState === StrategySelectionState::Supported && ! $this->strategyKey instanceof CompareStrategyKey) {
|
||||||
|
throw new InvalidArgumentException('Supported compare strategy selections require a strategy key.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trim($this->operatorReason) === '') {
|
||||||
|
throw new InvalidArgumentException('Compare strategy selections require an operator-safe reason.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $matchedScopeEntries
|
||||||
|
* @param array<string, mixed> $diagnostics
|
||||||
|
*/
|
||||||
|
public static function supported(
|
||||||
|
CompareStrategyKey|string $strategyKey,
|
||||||
|
array $matchedScopeEntries,
|
||||||
|
array $diagnostics = [],
|
||||||
|
string $operatorReason = 'Compare strategy resolved successfully.',
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
selectionState: StrategySelectionState::Supported,
|
||||||
|
strategyKey: CompareStrategyKey::from($strategyKey),
|
||||||
|
matchedScopeEntries: $matchedScopeEntries,
|
||||||
|
rejectedScopeEntries: [],
|
||||||
|
operatorReason: $operatorReason,
|
||||||
|
diagnostics: $diagnostics,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $matchedScopeEntries
|
||||||
|
* @param list<array<string, mixed>> $rejectedScopeEntries
|
||||||
|
* @param array<string, mixed> $diagnostics
|
||||||
|
*/
|
||||||
|
public static function unsupported(
|
||||||
|
array $matchedScopeEntries,
|
||||||
|
array $rejectedScopeEntries,
|
||||||
|
array $diagnostics = [],
|
||||||
|
string $operatorReason = 'No compare strategy supports the selected governed subjects.',
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
selectionState: StrategySelectionState::Unsupported,
|
||||||
|
strategyKey: null,
|
||||||
|
matchedScopeEntries: $matchedScopeEntries,
|
||||||
|
rejectedScopeEntries: $rejectedScopeEntries,
|
||||||
|
operatorReason: $operatorReason,
|
||||||
|
diagnostics: $diagnostics,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string, mixed>> $matchedScopeEntries
|
||||||
|
* @param array<string, mixed> $diagnostics
|
||||||
|
*/
|
||||||
|
public static function mixed(
|
||||||
|
array $matchedScopeEntries,
|
||||||
|
array $diagnostics = [],
|
||||||
|
string $operatorReason = 'The selected governed subjects span multiple compare strategy families.',
|
||||||
|
): self {
|
||||||
|
return new self(
|
||||||
|
selectionState: StrategySelectionState::Mixed,
|
||||||
|
strategyKey: null,
|
||||||
|
matchedScopeEntries: $matchedScopeEntries,
|
||||||
|
rejectedScopeEntries: [],
|
||||||
|
operatorReason: $operatorReason,
|
||||||
|
diagnostics: $diagnostics,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isSupported(): bool
|
||||||
|
{
|
||||||
|
return $this->selectionState === StrategySelectionState::Supported;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isUnsupported(): bool
|
||||||
|
{
|
||||||
|
return $this->selectionState === StrategySelectionState::Unsupported;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isMixed(): bool
|
||||||
|
{
|
||||||
|
return $this->selectionState === StrategySelectionState::Mixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* selection_state: string,
|
||||||
|
* strategy_key: ?string,
|
||||||
|
* matched_scope_entries: list<array<string, mixed>>,
|
||||||
|
* rejected_scope_entries: list<array<string, mixed>>,
|
||||||
|
* operator_reason: string,
|
||||||
|
* diagnostics: array<string, mixed>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'selection_state' => $this->selectionState->value,
|
||||||
|
'strategy_key' => $this->strategyKey?->value,
|
||||||
|
'matched_scope_entries' => $this->matchedScopeEntries,
|
||||||
|
'rejected_scope_entries' => $this->rejectedScopeEntries,
|
||||||
|
'operator_reason' => $this->operatorReason,
|
||||||
|
'diagnostics' => $this->diagnostics,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines\Compare;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class CompareSubjectIdentity
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $domainKey,
|
||||||
|
public readonly string $subjectClass,
|
||||||
|
public readonly string $subjectTypeKey,
|
||||||
|
public readonly ?string $externalSubjectId,
|
||||||
|
public readonly string $subjectKey,
|
||||||
|
) {
|
||||||
|
if (trim($this->domainKey) === '' || trim($this->subjectClass) === '' || trim($this->subjectTypeKey) === '' || trim($this->subjectKey) === '') {
|
||||||
|
throw new InvalidArgumentException('Compare subject identities require non-empty domain, subject class, subject type key, and subject key values.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* domain_key: string,
|
||||||
|
* subject_class: string,
|
||||||
|
* subject_type_key: string,
|
||||||
|
* external_subject_id: ?string,
|
||||||
|
* subject_key: string
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'domain_key' => $this->domainKey,
|
||||||
|
'subject_class' => $this->subjectClass,
|
||||||
|
'subject_type_key' => $this->subjectTypeKey,
|
||||||
|
'external_subject_id' => $this->externalSubjectId,
|
||||||
|
'subject_key' => $this->subjectKey,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines\Compare;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class CompareSubjectProjection
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $additionalLabels
|
||||||
|
* @param array<string, mixed>|null $subjectDescriptor
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $platformSubjectClass,
|
||||||
|
public readonly string $domainKey,
|
||||||
|
public readonly string $subjectTypeKey,
|
||||||
|
public readonly string $operatorLabel,
|
||||||
|
public readonly ?string $summaryKind = null,
|
||||||
|
public readonly array $additionalLabels = [],
|
||||||
|
public readonly ?array $subjectDescriptor = null,
|
||||||
|
) {
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* platform_subject_class: string,
|
||||||
|
* domain_key: string,
|
||||||
|
* subject_type_key: string,
|
||||||
|
* operator_label: string,
|
||||||
|
* summary_kind: ?string,
|
||||||
|
* additional_labels: array<string, string>,
|
||||||
|
* subject_descriptor: ?array<string, mixed>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'platform_subject_class' => $this->platformSubjectClass,
|
||||||
|
'domain_key' => $this->domainKey,
|
||||||
|
'subject_type_key' => $this->subjectTypeKey,
|
||||||
|
'operator_label' => $this->operatorLabel,
|
||||||
|
'summary_kind' => $this->summaryKind,
|
||||||
|
'additional_labels' => $this->additionalLabels,
|
||||||
|
'subject_descriptor' => $this->subjectDescriptor,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,119 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines\Compare;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class CompareSubjectResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $diagnostics
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly CompareSubjectIdentity $subjectIdentity,
|
||||||
|
public readonly CompareSubjectProjection $projection,
|
||||||
|
public readonly string $baselineAvailability,
|
||||||
|
public readonly string $currentStateAvailability,
|
||||||
|
public readonly CompareState $compareState,
|
||||||
|
public readonly string $trustLevel,
|
||||||
|
public readonly string $evidenceQuality,
|
||||||
|
public readonly ?string $severityRecommendation = null,
|
||||||
|
public readonly ?CompareFindingCandidate $findingCandidate = null,
|
||||||
|
public readonly array $diagnostics = [],
|
||||||
|
) {
|
||||||
|
if (trim($this->baselineAvailability) === '' || trim($this->currentStateAvailability) === '' || trim($this->trustLevel) === '' || trim($this->evidenceQuality) === '') {
|
||||||
|
throw new InvalidArgumentException('Compare subject results require non-empty availability, trust level, and evidence quality values.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->compareState === CompareState::Drift && ! $this->findingCandidate instanceof CompareFindingCandidate) {
|
||||||
|
throw new InvalidArgumentException('Drift compare subject results require a finding candidate.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->compareState !== CompareState::Drift && $this->findingCandidate instanceof CompareFindingCandidate) {
|
||||||
|
throw new InvalidArgumentException('Only drift compare subject results may carry a finding candidate.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasFindingCandidate(): bool
|
||||||
|
{
|
||||||
|
return $this->findingCandidate instanceof CompareFindingCandidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isGapState(): bool
|
||||||
|
{
|
||||||
|
return in_array($this->compareState, [
|
||||||
|
CompareState::Unsupported,
|
||||||
|
CompareState::Incomplete,
|
||||||
|
CompareState::Ambiguous,
|
||||||
|
CompareState::Failed,
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function gapReasonCode(): ?string
|
||||||
|
{
|
||||||
|
$reasonCode = $this->diagnostics['reason_code'] ?? null;
|
||||||
|
|
||||||
|
return is_string($reasonCode) && trim($reasonCode) !== '' ? trim($reasonCode) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function gapRecord(): ?array
|
||||||
|
{
|
||||||
|
$gapRecord = $this->diagnostics['gap_record'] ?? null;
|
||||||
|
|
||||||
|
return is_array($gapRecord) ? $gapRecord : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* subject_identity: array{
|
||||||
|
* domain_key: string,
|
||||||
|
* subject_class: string,
|
||||||
|
* subject_type_key: string,
|
||||||
|
* external_subject_id: ?string,
|
||||||
|
* subject_key: string
|
||||||
|
* },
|
||||||
|
* projection: array{
|
||||||
|
* platform_subject_class: string,
|
||||||
|
* domain_key: string,
|
||||||
|
* subject_type_key: string,
|
||||||
|
* operator_label: string,
|
||||||
|
* summary_kind: ?string,
|
||||||
|
* additional_labels: array<string, string>
|
||||||
|
* },
|
||||||
|
* baseline_availability: string,
|
||||||
|
* current_state_availability: string,
|
||||||
|
* compare_state: string,
|
||||||
|
* trust_level: string,
|
||||||
|
* evidence_quality: string,
|
||||||
|
* severity_recommendation: ?string,
|
||||||
|
* finding_candidate: ?array{
|
||||||
|
* change_type: string,
|
||||||
|
* severity: string,
|
||||||
|
* fingerprint_basis: array<string, mixed>,
|
||||||
|
* evidence_payload: array<string, mixed>,
|
||||||
|
* auto_close_eligible: bool
|
||||||
|
* },
|
||||||
|
* diagnostics: array<string, mixed>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'subject_identity' => $this->subjectIdentity->toArray(),
|
||||||
|
'projection' => $this->projection->toArray(),
|
||||||
|
'baseline_availability' => $this->baselineAvailability,
|
||||||
|
'current_state_availability' => $this->currentStateAvailability,
|
||||||
|
'compare_state' => $this->compareState->value,
|
||||||
|
'trust_level' => $this->trustLevel,
|
||||||
|
'evidence_quality' => $this->evidenceQuality,
|
||||||
|
'severity_recommendation' => $this->severityRecommendation,
|
||||||
|
'finding_candidate' => $this->findingCandidate?->toArray(),
|
||||||
|
'diagnostics' => $this->diagnostics,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Support\Baselines\Compare;
|
||||||
|
|
||||||
|
enum StrategySelectionState: string
|
||||||
|
{
|
||||||
|
case Supported = 'supported';
|
||||||
|
case Unsupported = 'unsupported';
|
||||||
|
case Mixed = 'mixed';
|
||||||
|
}
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -182,6 +214,12 @@ private function translateBaselineReason(string $reasonCode): ReasonResolutionEn
|
|||||||
'prerequisite_missing',
|
'prerequisite_missing',
|
||||||
'Refresh the page and select a valid snapshot for this baseline profile.',
|
'Refresh the page and select a valid snapshot for this baseline profile.',
|
||||||
],
|
],
|
||||||
|
BaselineReasonCodes::COMPARE_MIXED_SCOPE => [
|
||||||
|
'Mixed compare scope',
|
||||||
|
'The selected governed subjects span multiple compare strategy families, so TenantPilot will not start one misleading combined compare run.',
|
||||||
|
'prerequisite_missing',
|
||||||
|
'Narrow the governed subject selection so one compare strategy family owns the requested scope.',
|
||||||
|
],
|
||||||
default => [
|
default => [
|
||||||
'Baseline workflow blocked',
|
'Baseline workflow blocked',
|
||||||
'TenantPilot recorded a baseline precondition that prevents this workflow from continuing safely.',
|
'TenantPilot recorded a baseline precondition that prevents this workflow from continuing safely.',
|
||||||
@ -236,6 +274,24 @@ private function translateBaselineCompareReason(string $reasonCode): ReasonResol
|
|||||||
'prerequisite_missing',
|
'prerequisite_missing',
|
||||||
'Resume or rerun evidence capture before relying on this compare result.',
|
'Resume or rerun evidence capture before relying on this compare result.',
|
||||||
],
|
],
|
||||||
|
BaselineCompareReasonCode::UnsupportedSubjects => [
|
||||||
|
'Unsupported subjects remained',
|
||||||
|
'The comparison finished, but one or more in-scope subjects are not currently supported by the selected compare strategy.',
|
||||||
|
'prerequisite_missing',
|
||||||
|
'Narrow scope or wait for support before treating zero visible findings as complete.',
|
||||||
|
],
|
||||||
|
BaselineCompareReasonCode::AmbiguousSubjects => [
|
||||||
|
'Subject identity stayed ambiguous',
|
||||||
|
'The comparison finished, but one or more in-scope subjects could not be matched cleanly enough to produce a trustworthy result.',
|
||||||
|
'prerequisite_missing',
|
||||||
|
'Review the ambiguous subject mapping before relying on this compare result.',
|
||||||
|
],
|
||||||
|
BaselineCompareReasonCode::StrategyFailed => [
|
||||||
|
'Strategy processing failed',
|
||||||
|
'The comparison finished without a fully usable result because strategy-owned subject processing failed for one or more in-scope subjects.',
|
||||||
|
'retryable_transient',
|
||||||
|
'Inspect the compare run diagnostics and retry once the subject-processing failure is addressed.',
|
||||||
|
],
|
||||||
BaselineCompareReasonCode::RolloutDisabled => [
|
BaselineCompareReasonCode::RolloutDisabled => [
|
||||||
'Compare rollout disabled',
|
'Compare rollout disabled',
|
||||||
'The comparison path was limited by rollout configuration, so the result is not decision-grade.',
|
'The comparison path was limited by rollout configuration, so the result is not decision-grade.',
|
||||||
@ -248,6 +304,24 @@ private function translateBaselineCompareReason(string $reasonCode): ReasonResol
|
|||||||
'prerequisite_missing',
|
'prerequisite_missing',
|
||||||
'Review scope selection and baseline inputs before comparing again.',
|
'Review scope selection and baseline inputs before comparing again.',
|
||||||
],
|
],
|
||||||
|
BaselineCompareReasonCode::OverdueFindingsRemain => [
|
||||||
|
'Overdue findings remain',
|
||||||
|
'The latest compare did not produce new drift, but overdue findings still require attention.',
|
||||||
|
'prerequisite_missing',
|
||||||
|
'Review and resolve the overdue findings before treating this posture as healthy.',
|
||||||
|
],
|
||||||
|
BaselineCompareReasonCode::GovernanceExpiring => [
|
||||||
|
'Accepted-risk governance is expiring',
|
||||||
|
'Accepted-risk coverage is still valid, but renewal is approaching and needs review.',
|
||||||
|
'prerequisite_missing',
|
||||||
|
'Review the expiring governance before it lapses.',
|
||||||
|
],
|
||||||
|
BaselineCompareReasonCode::GovernanceLapsed => [
|
||||||
|
'Accepted-risk governance lapsed',
|
||||||
|
'Accepted-risk coverage has lapsed, so the current posture still needs follow-up.',
|
||||||
|
'prerequisite_missing',
|
||||||
|
'Restore valid governance or move the affected findings back into active remediation.',
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
return new ReasonResolutionEnvelope(
|
return new ReasonResolutionEnvelope(
|
||||||
@ -263,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',
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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,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 {
|
||||||
|
|||||||
@ -2,17 +2,29 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__.'/Support/FakeCompareStrategy.php';
|
||||||
|
|
||||||
use App\Jobs\CompareBaselineToTenantJob;
|
use App\Jobs\CompareBaselineToTenantJob;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
|
use App\Models\BaselineSnapshotItem;
|
||||||
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
|
use App\Services\Baselines\BaselineCompareService;
|
||||||
use App\Services\Baselines\BaselineSnapshotIdentity;
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Baselines\BaselineCompareReasonCode;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
|
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||||
|
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Tests\Feature\Baselines\Support\FailingCompareStrategy;
|
||||||
|
use Tests\Feature\Baselines\Support\FakeGovernanceSubjectTaxonomyRegistry;
|
||||||
|
|
||||||
it('blocks compare execution when the queued snapshot is incomplete', function (): void {
|
it('blocks compare execution when the queued snapshot is incomplete', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
@ -113,3 +125,86 @@
|
|||||||
->and(data_get($context, 'baseline_compare.latest_attempted_snapshot_id'))->toBe((int) $currentSnapshot->getKey())
|
->and(data_get($context, 'baseline_compare.latest_attempted_snapshot_id'))->toBe((int) $currentSnapshot->getKey())
|
||||||
->and(data_get($run->failure_summary, '0.reason_code'))->toBe(BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED);
|
->and(data_get($run->failure_summary, '0.reason_code'))->toBe(BaselineReasonCodes::COMPARE_SNAPSHOT_SUPERSEDED);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks compare runs as partially succeeded when strategy-owned processing fails before subject classification completes', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
|
||||||
|
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
|
||||||
|
app(FailingCompareStrategy::class),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => [
|
||||||
|
'version' => 2,
|
||||||
|
'entries' => [[
|
||||||
|
'domain_key' => 'entra',
|
||||||
|
'subject_class' => 'control',
|
||||||
|
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||||
|
'filters' => [],
|
||||||
|
]],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'captured_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||||
|
|
||||||
|
BaselineTenantAssignment::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
BaselineSnapshotItem::factory()->create([
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'subject_type' => 'control',
|
||||||
|
'subject_external_id' => 'conditional-access-policy-1',
|
||||||
|
'subject_key' => 'conditional-access-policy-1',
|
||||||
|
'policy_type' => 'conditionalAccessPolicy',
|
||||||
|
'baseline_hash' => hash('sha256', 'baseline'),
|
||||||
|
'meta_jsonb' => ['display_name' => 'Conditional Access Policy'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$inventorySyncRun = createInventorySyncOperationRunWithCoverage($tenant, ['conditionalAccessPolicy' => 'succeeded']);
|
||||||
|
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'external_id' => 'conditional-access-policy-1',
|
||||||
|
'policy_type' => 'conditionalAccessPolicy',
|
||||||
|
'display_name' => 'Conditional Access Policy',
|
||||||
|
'meta_jsonb' => ['display_name' => 'Conditional Access Policy'],
|
||||||
|
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||||
|
'last_seen_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(BaselineCompareService::class)->startCompare($tenant, $user);
|
||||||
|
|
||||||
|
expect($result['ok'])->toBeTrue();
|
||||||
|
|
||||||
|
/** @var OperationRun $run */
|
||||||
|
$run = $result['run'];
|
||||||
|
|
||||||
|
(new CompareBaselineToTenantJob($run))->handle(
|
||||||
|
app(BaselineSnapshotIdentity::class),
|
||||||
|
app(AuditLogger::class),
|
||||||
|
app(OperationRunService::class),
|
||||||
|
);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
|
||||||
|
expect($run->outcome)->toBe(OperationRunOutcome::PartiallySucceeded->value)
|
||||||
|
->and(data_get($run->context, 'baseline_compare.reason_code'))->toBe(BaselineCompareReasonCode::StrategyFailed->value)
|
||||||
|
->and(data_get($run->context, 'baseline_compare.strategy.execution_diagnostics.failed'))->toBeTrue()
|
||||||
|
->and(data_get($run->context, 'baseline_compare.strategy.execution_diagnostics.exception_class'))->toBe(RuntimeException::class)
|
||||||
|
->and(data_get($run->context, 'baseline_compare.strategy.state_counts'))->toBe([])
|
||||||
|
->and(data_get($run->context, 'baseline_compare.evidence_gaps.by_reason.strategy_failed'))->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')
|
||||||
|
|||||||
@ -2,16 +2,24 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__.'/Support/FakeCompareStrategy.php';
|
||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareMatrix;
|
use App\Filament\Pages\BaselineCompareMatrix;
|
||||||
use App\Jobs\CompareBaselineToTenantJob;
|
use App\Jobs\CompareBaselineToTenantJob;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Baselines\BaselineCompareService;
|
use App\Services\Baselines\BaselineCompareService;
|
||||||
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
|
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||||
|
use App\Support\Baselines\Compare\IntuneCompareStrategy;
|
||||||
|
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
use Tests\Feature\Baselines\Support\FakeCompareStrategy;
|
||||||
|
use Tests\Feature\Baselines\Support\FakeGovernanceSubjectTaxonomyRegistry;
|
||||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
||||||
|
|
||||||
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
||||||
@ -93,3 +101,82 @@
|
|||||||
->whereNull('tenant_id')
|
->whereNull('tenant_id')
|
||||||
->count())->toBe(0);
|
->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('blocks visible assignment fanout when the baseline scope spans multiple compare strategy families', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
|
||||||
|
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
|
||||||
|
app(IntuneCompareStrategy::class),
|
||||||
|
app(FakeCompareStrategy::class),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$fixture['profile']->update([
|
||||||
|
'scope_jsonb' => [
|
||||||
|
'version' => 2,
|
||||||
|
'entries' => [
|
||||||
|
[
|
||||||
|
'domain_key' => 'intune',
|
||||||
|
'subject_class' => 'policy',
|
||||||
|
'subject_type_keys' => ['deviceConfiguration'],
|
||||||
|
'filters' => [],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'domain_key' => 'entra',
|
||||||
|
'subject_class' => 'control',
|
||||||
|
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||||
|
'filters' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(BaselineCompareService::class)->startCompareForVisibleAssignments($fixture['profile'], $fixture['user']);
|
||||||
|
|
||||||
|
expect($result['visibleAssignedTenantCount'])->toBe(2)
|
||||||
|
->and($result['queuedCount'])->toBe(0)
|
||||||
|
->and($result['alreadyQueuedCount'])->toBe(0)
|
||||||
|
->and($result['blockedCount'])->toBe(2)
|
||||||
|
->and(collect($result['targets'])->pluck('reasonCode')->unique()->values()->all())
|
||||||
|
->toBe([BaselineReasonCodes::COMPARE_MIXED_SCOPE]);
|
||||||
|
|
||||||
|
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks visible assignment fanout when the baseline scope has no compatible compare strategy family', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
|
||||||
|
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
|
||||||
|
app(IntuneCompareStrategy::class),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$fixture['profile']->update([
|
||||||
|
'scope_jsonb' => [
|
||||||
|
'version' => 2,
|
||||||
|
'entries' => [
|
||||||
|
[
|
||||||
|
'domain_key' => 'entra',
|
||||||
|
'subject_class' => 'control',
|
||||||
|
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||||
|
'filters' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = app(BaselineCompareService::class)->startCompareForVisibleAssignments($fixture['profile'], $fixture['user']);
|
||||||
|
|
||||||
|
expect($result['visibleAssignedTenantCount'])->toBe(2)
|
||||||
|
->and($result['queuedCount'])->toBe(0)
|
||||||
|
->and($result['alreadyQueuedCount'])->toBe(0)
|
||||||
|
->and($result['blockedCount'])->toBe(2)
|
||||||
|
->and(collect($result['targets'])->pluck('reasonCode')->unique()->values()->all())
|
||||||
|
->toBe([BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE]);
|
||||||
|
|
||||||
|
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||||
|
});
|
||||||
|
|||||||
@ -1,15 +1,22 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__.'/Support/FakeCompareStrategy.php';
|
||||||
|
|
||||||
use App\Jobs\CompareBaselineToTenantJob;
|
use App\Jobs\CompareBaselineToTenantJob;
|
||||||
use App\Models\BaselineProfile;
|
use App\Models\BaselineProfile;
|
||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
use App\Models\BaselineTenantAssignment;
|
use App\Models\BaselineTenantAssignment;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Services\Baselines\BaselineCompareService;
|
use App\Services\Baselines\BaselineCompareService;
|
||||||
|
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||||
use App\Support\Baselines\BaselineProfileStatus;
|
use App\Support\Baselines\BaselineProfileStatus;
|
||||||
use App\Support\Baselines\BaselineReasonCodes;
|
use App\Support\Baselines\BaselineReasonCodes;
|
||||||
|
use App\Support\Baselines\Compare\IntuneCompareStrategy;
|
||||||
|
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Tests\Feature\Baselines\Support\FakeCompareStrategy;
|
||||||
|
use Tests\Feature\Baselines\Support\FakeGovernanceSubjectTaxonomyRegistry;
|
||||||
|
|
||||||
// --- T040: Compare precondition 422 tests ---
|
// --- T040: Compare precondition 422 tests ---
|
||||||
|
|
||||||
@ -217,6 +224,158 @@
|
|||||||
Queue::assertPushed(CompareBaselineToTenantJob::class);
|
Queue::assertPushed(CompareBaselineToTenantJob::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects compare when canonical scope spans multiple compare strategy families', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
|
||||||
|
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
|
||||||
|
app(IntuneCompareStrategy::class),
|
||||||
|
app(FakeCompareStrategy::class),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => [
|
||||||
|
'version' => 2,
|
||||||
|
'entries' => [
|
||||||
|
[
|
||||||
|
'domain_key' => 'intune',
|
||||||
|
'subject_class' => 'policy',
|
||||||
|
'subject_type_keys' => ['deviceConfiguration'],
|
||||||
|
'filters' => [],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'domain_key' => 'entra',
|
||||||
|
'subject_class' => 'control',
|
||||||
|
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||||
|
'filters' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||||
|
|
||||||
|
BaselineTenantAssignment::create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(BaselineCompareService::class);
|
||||||
|
$result = $service->startCompare($tenant, $user);
|
||||||
|
|
||||||
|
expect($result['ok'])->toBeFalse();
|
||||||
|
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_MIXED_SCOPE);
|
||||||
|
|
||||||
|
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||||
|
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects compare when canonical scope uses an inactive subject type', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => ['policy_types' => ['deviceConfiguration'], 'foundation_types' => []],
|
||||||
|
]);
|
||||||
|
|
||||||
|
BaselineProfile::query()
|
||||||
|
->whereKey($profile->getKey())
|
||||||
|
->update([
|
||||||
|
'scope_jsonb' => json_encode([
|
||||||
|
'version' => 2,
|
||||||
|
'entries' => [
|
||||||
|
[
|
||||||
|
'domain_key' => 'platform_foundation',
|
||||||
|
'subject_class' => 'configuration_resource',
|
||||||
|
'subject_type_keys' => ['intuneRoleAssignment'],
|
||||||
|
'filters' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], JSON_THROW_ON_ERROR),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||||
|
|
||||||
|
BaselineTenantAssignment::create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(BaselineCompareService::class);
|
||||||
|
$result = $service->startCompare($tenant, $user);
|
||||||
|
|
||||||
|
expect($result['ok'])->toBeFalse();
|
||||||
|
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_INVALID_SCOPE);
|
||||||
|
|
||||||
|
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||||
|
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects compare when canonical scope has no compatible compare strategy', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
|
||||||
|
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
|
||||||
|
app(IntuneCompareStrategy::class),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => [
|
||||||
|
'version' => 2,
|
||||||
|
'entries' => [
|
||||||
|
[
|
||||||
|
'domain_key' => 'entra',
|
||||||
|
'subject_class' => 'control',
|
||||||
|
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||||
|
'filters' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => $snapshot->getKey()]);
|
||||||
|
|
||||||
|
BaselineTenantAssignment::create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(BaselineCompareService::class);
|
||||||
|
$result = $service->startCompare($tenant, $user);
|
||||||
|
|
||||||
|
expect($result['ok'])->toBeFalse();
|
||||||
|
expect($result['reason_code'])->toBe(BaselineReasonCodes::COMPARE_UNSUPPORTED_SCOPE);
|
||||||
|
|
||||||
|
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||||
|
expect(OperationRun::query()->where('type', OperationRunType::BaselineCompare->value)->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('uses an explicit snapshot override instead of baseline_profiles.active_snapshot_id when provided', function () {
|
it('uses an explicit snapshot override instead of baseline_profiles.active_snapshot_id when provided', function () {
|
||||||
Queue::fake();
|
Queue::fake();
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once __DIR__.'/Support/FakeCompareStrategy.php';
|
||||||
|
|
||||||
|
use App\Jobs\CompareBaselineToTenantJob;
|
||||||
|
use App\Models\BaselineProfile;
|
||||||
|
use App\Models\BaselineSnapshot;
|
||||||
|
use App\Models\BaselineTenantAssignment;
|
||||||
|
use App\Models\Finding;
|
||||||
|
use App\Models\InventoryItem;
|
||||||
|
use App\Models\OperationRun;
|
||||||
|
use App\Services\Baselines\BaselineCompareService;
|
||||||
|
use App\Services\Baselines\BaselineSnapshotIdentity;
|
||||||
|
use App\Services\Intune\AuditLogger;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OperationRunType;
|
||||||
|
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
use Tests\Feature\Baselines\Support\FakeCompareStrategy;
|
||||||
|
use Tests\Feature\Baselines\Support\FakeGovernanceSubjectTaxonomyRegistry;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
it('runs a future-domain compare strategy through the shared lifecycle without implicit intune fallback', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
|
||||||
|
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
|
||||||
|
app(FakeCompareStrategy::class),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => [
|
||||||
|
'version' => 2,
|
||||||
|
'entries' => [[
|
||||||
|
'domain_key' => 'entra',
|
||||||
|
'subject_class' => 'control',
|
||||||
|
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||||
|
'filters' => [],
|
||||||
|
]],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'captured_at' => now()->subMinute(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||||
|
|
||||||
|
BaselineTenantAssignment::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$displayName = 'Conditional Access Global Block';
|
||||||
|
$externalId = 'conditional-access-policy-1';
|
||||||
|
$subjectKey = app(BaselineSnapshotIdentity::class)->subjectKey(
|
||||||
|
policyType: 'conditionalAccessPolicy',
|
||||||
|
displayName: $displayName,
|
||||||
|
subjectExternalId: $externalId,
|
||||||
|
) ?? $externalId;
|
||||||
|
|
||||||
|
$baselineMeta = [
|
||||||
|
'display_name' => $displayName,
|
||||||
|
'conditions' => ['users' => ['includeGuestsOrExternalUsers' => true]],
|
||||||
|
];
|
||||||
|
$currentMeta = [
|
||||||
|
'display_name' => $displayName,
|
||||||
|
'conditions' => ['users' => ['includeGuestsOrExternalUsers' => false]],
|
||||||
|
];
|
||||||
|
|
||||||
|
$baselineHash = app(BaselineSnapshotIdentity::class)->hashItemContent(
|
||||||
|
policyType: 'conditionalAccessPolicy',
|
||||||
|
subjectExternalId: $externalId,
|
||||||
|
metaJsonb: $baselineMeta,
|
||||||
|
);
|
||||||
|
|
||||||
|
\App\Models\BaselineSnapshotItem::factory()->create([
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'subject_type' => 'control',
|
||||||
|
'subject_external_id' => $externalId,
|
||||||
|
'subject_key' => $subjectKey,
|
||||||
|
'policy_type' => 'conditionalAccessPolicy',
|
||||||
|
'baseline_hash' => $baselineHash,
|
||||||
|
'meta_jsonb' => $baselineMeta,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$inventorySyncRun = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => OperationRunType::InventorySync->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||||
|
'completed_at' => now(),
|
||||||
|
'context' => [
|
||||||
|
'inventory' => [
|
||||||
|
'coverage' => [
|
||||||
|
'policy_types' => [
|
||||||
|
'conditionalAccessPolicy' => ['status' => 'succeeded'],
|
||||||
|
],
|
||||||
|
'foundation_types' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
InventoryItem::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'external_id' => $externalId,
|
||||||
|
'policy_type' => 'conditionalAccessPolicy',
|
||||||
|
'display_name' => $displayName,
|
||||||
|
'meta_jsonb' => $currentMeta,
|
||||||
|
'last_seen_operation_run_id' => (int) $inventorySyncRun->getKey(),
|
||||||
|
'last_seen_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$service = app(BaselineCompareService::class);
|
||||||
|
$result = $service->startCompare($tenant, $user);
|
||||||
|
|
||||||
|
expect($result['ok'] ?? false)->toBeTrue();
|
||||||
|
Queue::assertPushed(CompareBaselineToTenantJob::class);
|
||||||
|
|
||||||
|
/** @var OperationRun $run */
|
||||||
|
$run = $result['run'];
|
||||||
|
|
||||||
|
(new CompareBaselineToTenantJob($run))->handle(
|
||||||
|
app(\App\Services\Baselines\BaselineSnapshotIdentity::class),
|
||||||
|
app(AuditLogger::class),
|
||||||
|
app(OperationRunService::class),
|
||||||
|
);
|
||||||
|
|
||||||
|
$run->refresh();
|
||||||
|
|
||||||
|
expect($run->status)->toBe(OperationRunStatus::Completed->value)
|
||||||
|
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
|
||||||
|
->and(data_get($run->context, 'baseline_compare.strategy.key'))->toBe('future_control')
|
||||||
|
->and(data_get($run->context, 'baseline_compare.strategy.selection_state'))->toBe('supported')
|
||||||
|
->and(data_get($run->context, 'findings.counts_by_change_type.different_version'))->toBe(1)
|
||||||
|
->and(data_get($run->context, 'result.findings_total'))->toBe(1);
|
||||||
|
|
||||||
|
$finding = Finding::query()->where('tenant_id', (int) $tenant->getKey())->first();
|
||||||
|
|
||||||
|
expect($finding)->not->toBeNull()
|
||||||
|
->and($finding?->subject_type)->toBe('control')
|
||||||
|
->and(data_get($finding?->evidence_jsonb, 'summary.kind'))->toBe('control_snapshot')
|
||||||
|
->and(data_get($finding?->evidence_jsonb, 'policy_type'))->toBe('conditionalAccessPolicy');
|
||||||
|
});
|
||||||
@ -0,0 +1,454 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature\Baselines\Support;
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Services\Baselines\Evidence\EvidenceProvenance;
|
||||||
|
use App\Services\Baselines\Evidence\ResolvedEvidence;
|
||||||
|
use App\Support\Baselines\Compare\CompareFindingCandidate;
|
||||||
|
use App\Support\Baselines\Compare\CompareOrchestrationContext;
|
||||||
|
use App\Support\Baselines\Compare\CompareState;
|
||||||
|
use App\Support\Baselines\Compare\CompareStrategy;
|
||||||
|
use App\Support\Baselines\Compare\CompareStrategyCapability;
|
||||||
|
use App\Support\Baselines\Compare\CompareStrategyKey;
|
||||||
|
use App\Support\Baselines\Compare\CompareSubjectIdentity;
|
||||||
|
use App\Support\Baselines\Compare\CompareSubjectProjection;
|
||||||
|
use App\Support\Baselines\Compare\CompareSubjectResult;
|
||||||
|
use App\Support\Baselines\OperatorActionCategory;
|
||||||
|
use App\Support\Baselines\ResolutionOutcome;
|
||||||
|
use App\Support\Baselines\ResolutionPath;
|
||||||
|
use App\Support\Baselines\SubjectClass;
|
||||||
|
use App\Support\Governance\GovernanceDomainKey;
|
||||||
|
use App\Support\Governance\GovernanceSubjectClass;
|
||||||
|
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||||
|
use App\Support\Governance\GovernanceSubjectType;
|
||||||
|
|
||||||
|
final class FakeCompareStrategy implements CompareStrategy
|
||||||
|
{
|
||||||
|
public function key(): CompareStrategyKey
|
||||||
|
{
|
||||||
|
return CompareStrategyKey::from('future_control');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function capabilities(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new CompareStrategyCapability(
|
||||||
|
strategyKey: $this->key(),
|
||||||
|
domainKeys: [GovernanceDomainKey::Entra->value],
|
||||||
|
subjectClasses: [GovernanceSubjectClass::Control->value],
|
||||||
|
subjectTypeKeys: ['conditionalAccessPolicy'],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function compare(
|
||||||
|
CompareOrchestrationContext $context,
|
||||||
|
Tenant $tenant,
|
||||||
|
array $baselineItems,
|
||||||
|
array $currentItems,
|
||||||
|
array $resolvedCurrentEvidence,
|
||||||
|
array $severityMapping,
|
||||||
|
): array {
|
||||||
|
$subjectResults = [];
|
||||||
|
|
||||||
|
foreach ($baselineItems as $key => $baselineItem) {
|
||||||
|
$currentItem = $currentItems[$key] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($currentItem)) {
|
||||||
|
$subjectResults[] = $this->driftResult(
|
||||||
|
context: $context,
|
||||||
|
baselineItem: $baselineItem,
|
||||||
|
currentItem: null,
|
||||||
|
currentEvidence: null,
|
||||||
|
changeType: 'missing_policy',
|
||||||
|
severity: 'high',
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
|
||||||
|
|
||||||
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||||
|
$subjectResults[] = $this->gapResult(
|
||||||
|
policyType: (string) $baselineItem['policy_type'],
|
||||||
|
subjectKey: (string) $baselineItem['subject_key'],
|
||||||
|
externalSubjectId: (string) $baselineItem['subject_external_id'],
|
||||||
|
operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $baselineItem['meta_jsonb']['display_name'] ?? $baselineItem['subject_key']) ?: $baselineItem['subject_key']),
|
||||||
|
compareState: CompareState::Incomplete,
|
||||||
|
reasonCode: 'missing_current',
|
||||||
|
baselineAvailability: 'available',
|
||||||
|
currentStateAvailability: 'unknown',
|
||||||
|
trustLevel: 'unusable',
|
||||||
|
evidenceQuality: 'missing',
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$baselineMeta = is_array($baselineItem['meta_jsonb'] ?? null) ? $baselineItem['meta_jsonb'] : [];
|
||||||
|
$currentMeta = is_array($currentItem['meta_jsonb'] ?? null) ? $currentItem['meta_jsonb'] : [];
|
||||||
|
|
||||||
|
if ($this->metaFingerprint($baselineMeta) !== $this->metaFingerprint($currentMeta)) {
|
||||||
|
$subjectResults[] = $this->driftResult(
|
||||||
|
context: $context,
|
||||||
|
baselineItem: $baselineItem,
|
||||||
|
currentItem: $currentItem,
|
||||||
|
currentEvidence: $currentEvidence,
|
||||||
|
changeType: 'different_version',
|
||||||
|
severity: 'medium',
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjectResults[] = new CompareSubjectResult(
|
||||||
|
subjectIdentity: $this->identity(
|
||||||
|
policyType: (string) $baselineItem['policy_type'],
|
||||||
|
externalSubjectId: (string) $baselineItem['subject_external_id'],
|
||||||
|
subjectKey: (string) $baselineItem['subject_key'],
|
||||||
|
),
|
||||||
|
projection: $this->projection(
|
||||||
|
policyType: (string) $baselineItem['policy_type'],
|
||||||
|
operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $baselineItem['meta_jsonb']['display_name'] ?? $baselineItem['subject_key']) ?: $baselineItem['subject_key']),
|
||||||
|
),
|
||||||
|
baselineAvailability: 'available',
|
||||||
|
currentStateAvailability: 'available',
|
||||||
|
compareState: CompareState::NoDrift,
|
||||||
|
trustLevel: 'trustworthy',
|
||||||
|
evidenceQuality: $currentEvidence->fidelity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($currentItems as $key => $currentItem) {
|
||||||
|
if (array_key_exists($key, $baselineItems)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentEvidence = $resolvedCurrentEvidence[$key] ?? null;
|
||||||
|
|
||||||
|
if (! $currentEvidence instanceof ResolvedEvidence) {
|
||||||
|
$subjectResults[] = $this->gapResult(
|
||||||
|
policyType: (string) $currentItem['policy_type'],
|
||||||
|
subjectKey: (string) $currentItem['subject_key'],
|
||||||
|
externalSubjectId: (string) $currentItem['subject_external_id'],
|
||||||
|
operatorLabel: (string) (($currentItem['meta_jsonb']['display_name'] ?? $currentItem['subject_key']) ?: $currentItem['subject_key']),
|
||||||
|
compareState: CompareState::Incomplete,
|
||||||
|
reasonCode: 'missing_current',
|
||||||
|
baselineAvailability: 'missing',
|
||||||
|
currentStateAvailability: 'unknown',
|
||||||
|
trustLevel: 'unusable',
|
||||||
|
evidenceQuality: 'missing',
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subjectResults[] = $this->driftResult(
|
||||||
|
context: $context,
|
||||||
|
baselineItem: null,
|
||||||
|
currentItem: $currentItem,
|
||||||
|
currentEvidence: $currentEvidence,
|
||||||
|
changeType: 'unexpected_policy',
|
||||||
|
severity: 'low',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'subject_results' => $subjectResults,
|
||||||
|
'diagnostics' => [
|
||||||
|
'strategy_family' => 'future_control',
|
||||||
|
'state_counts' => [
|
||||||
|
'drift' => count(array_filter($subjectResults, static fn (CompareSubjectResult $result): bool => $result->compareState === CompareState::Drift)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $meta
|
||||||
|
*/
|
||||||
|
private function metaFingerprint(array $meta): string
|
||||||
|
{
|
||||||
|
unset($meta['display_name'], $meta['category'], $meta['platform']);
|
||||||
|
|
||||||
|
return hash('sha256', json_encode($this->sortRecursive($meta), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $value
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function sortRecursive(array $value): array
|
||||||
|
{
|
||||||
|
foreach ($value as $key => $nestedValue) {
|
||||||
|
if (! is_array($nestedValue)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value[$key] = $this->sortRecursive($nestedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($value, SORT_STRING);
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function driftResult(
|
||||||
|
CompareOrchestrationContext $context,
|
||||||
|
?array $baselineItem,
|
||||||
|
?array $currentItem,
|
||||||
|
?ResolvedEvidence $currentEvidence,
|
||||||
|
string $changeType,
|
||||||
|
string $severity,
|
||||||
|
): CompareSubjectResult {
|
||||||
|
$source = $baselineItem ?? $currentItem ?? [];
|
||||||
|
$policyType = (string) ($source['policy_type'] ?? 'conditionalAccessPolicy');
|
||||||
|
$subjectKey = (string) ($source['subject_key'] ?? 'unknown');
|
||||||
|
$externalSubjectId = (string) ($source['subject_external_id'] ?? 'unknown');
|
||||||
|
$operatorLabel = (string) ((($currentItem['meta_jsonb']['display_name'] ?? null) ?: ($baselineItem['meta_jsonb']['display_name'] ?? null) ?: $subjectKey) ?: $subjectKey);
|
||||||
|
$fidelity = $currentEvidence?->fidelity ?? EvidenceProvenance::FidelityMeta;
|
||||||
|
$baselineProvenance = EvidenceProvenance::build(
|
||||||
|
fidelity: EvidenceProvenance::FidelityMeta,
|
||||||
|
source: EvidenceProvenance::SourceInventory,
|
||||||
|
observedAt: null,
|
||||||
|
observedOperationRunId: null,
|
||||||
|
);
|
||||||
|
$currentProvenance = $currentEvidence?->tenantProvenance() ?? EvidenceProvenance::build(
|
||||||
|
fidelity: $fidelity,
|
||||||
|
source: EvidenceProvenance::SourceInventory,
|
||||||
|
observedAt: null,
|
||||||
|
observedOperationRunId: $context->inventorySyncRunId(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return new CompareSubjectResult(
|
||||||
|
subjectIdentity: $this->identity($policyType, $externalSubjectId, $subjectKey),
|
||||||
|
projection: $this->projection($policyType, $operatorLabel),
|
||||||
|
baselineAvailability: $baselineItem === null ? 'missing' : 'available',
|
||||||
|
currentStateAvailability: $currentItem === null ? 'missing' : 'available',
|
||||||
|
compareState: CompareState::Drift,
|
||||||
|
trustLevel: $fidelity === EvidenceProvenance::FidelityContent ? 'trustworthy' : 'limited_confidence',
|
||||||
|
evidenceQuality: $fidelity,
|
||||||
|
severityRecommendation: $severity,
|
||||||
|
findingCandidate: new CompareFindingCandidate(
|
||||||
|
changeType: $changeType,
|
||||||
|
severity: $severity,
|
||||||
|
fingerprintBasis: [
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'subject_key' => $subjectKey,
|
||||||
|
'change_type' => $changeType,
|
||||||
|
],
|
||||||
|
evidencePayload: [
|
||||||
|
'change_type' => $changeType,
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'subject_key' => $subjectKey,
|
||||||
|
'display_name' => $operatorLabel,
|
||||||
|
'summary' => ['kind' => 'control_snapshot'],
|
||||||
|
'baseline' => [
|
||||||
|
'hash' => $baselineItem['baseline_hash'] ?? null,
|
||||||
|
'provenance' => $baselineProvenance,
|
||||||
|
],
|
||||||
|
'current' => [
|
||||||
|
'hash' => $currentEvidence?->hash,
|
||||||
|
'provenance' => $currentProvenance,
|
||||||
|
],
|
||||||
|
'fidelity' => $fidelity,
|
||||||
|
'provenance' => [
|
||||||
|
'baseline_profile_id' => $context->baselineProfileId,
|
||||||
|
'baseline_snapshot_id' => $context->baselineSnapshotId,
|
||||||
|
'compare_operation_run_id' => $context->operationRunId,
|
||||||
|
'inventory_sync_run_id' => $context->inventorySyncRunId(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
diagnostics: [
|
||||||
|
'strategy_key' => $this->key()->value,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function gapResult(
|
||||||
|
string $policyType,
|
||||||
|
string $subjectKey,
|
||||||
|
string $externalSubjectId,
|
||||||
|
string $operatorLabel,
|
||||||
|
CompareState $compareState,
|
||||||
|
string $reasonCode,
|
||||||
|
string $baselineAvailability,
|
||||||
|
string $currentStateAvailability,
|
||||||
|
string $trustLevel,
|
||||||
|
string $evidenceQuality,
|
||||||
|
): CompareSubjectResult {
|
||||||
|
return new CompareSubjectResult(
|
||||||
|
subjectIdentity: $this->identity($policyType, $externalSubjectId, $subjectKey),
|
||||||
|
projection: $this->projection($policyType, $operatorLabel),
|
||||||
|
baselineAvailability: $baselineAvailability,
|
||||||
|
currentStateAvailability: $currentStateAvailability,
|
||||||
|
compareState: $compareState,
|
||||||
|
trustLevel: $trustLevel,
|
||||||
|
evidenceQuality: $evidenceQuality,
|
||||||
|
diagnostics: [
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'gap_record' => [
|
||||||
|
'policy_type' => $policyType,
|
||||||
|
'subject_key' => $subjectKey,
|
||||||
|
'subject_class' => SubjectClass::Derived->value,
|
||||||
|
'resolution_path' => ResolutionPath::Derived->value,
|
||||||
|
'resolution_outcome' => ResolutionOutcome::CaptureFailed->value,
|
||||||
|
'operator_action_category' => OperatorActionCategory::RunInventorySync->value,
|
||||||
|
'structural' => false,
|
||||||
|
'retryable' => $reasonCode === 'missing_current',
|
||||||
|
'reason_code' => $reasonCode,
|
||||||
|
'search_text' => strtolower(implode(' ', [$policyType, $subjectKey, $reasonCode])),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function identity(string $policyType, string $externalSubjectId, string $subjectKey): CompareSubjectIdentity
|
||||||
|
{
|
||||||
|
return new CompareSubjectIdentity(
|
||||||
|
domainKey: GovernanceDomainKey::Entra->value,
|
||||||
|
subjectClass: GovernanceSubjectClass::Control->value,
|
||||||
|
subjectTypeKey: $policyType,
|
||||||
|
externalSubjectId: $externalSubjectId,
|
||||||
|
subjectKey: $subjectKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function projection(string $policyType, string $operatorLabel): CompareSubjectProjection
|
||||||
|
{
|
||||||
|
return new CompareSubjectProjection(
|
||||||
|
platformSubjectClass: 'control',
|
||||||
|
domainKey: GovernanceDomainKey::Entra->value,
|
||||||
|
subjectTypeKey: $policyType,
|
||||||
|
operatorLabel: $operatorLabel,
|
||||||
|
summaryKind: 'control_snapshot',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class FailingCompareStrategy implements CompareStrategy
|
||||||
|
{
|
||||||
|
public function key(): CompareStrategyKey
|
||||||
|
{
|
||||||
|
return CompareStrategyKey::from('failing_control');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function capabilities(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new CompareStrategyCapability(
|
||||||
|
strategyKey: $this->key(),
|
||||||
|
domainKeys: [GovernanceDomainKey::Entra->value],
|
||||||
|
subjectClasses: [GovernanceSubjectClass::Control->value],
|
||||||
|
subjectTypeKeys: ['conditionalAccessPolicy'],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function compare(
|
||||||
|
CompareOrchestrationContext $context,
|
||||||
|
Tenant $tenant,
|
||||||
|
array $baselineItems,
|
||||||
|
array $currentItems,
|
||||||
|
array $resolvedCurrentEvidence,
|
||||||
|
array $severityMapping,
|
||||||
|
): array {
|
||||||
|
throw new \RuntimeException('Synthetic strategy failure for compare testing.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class FakeGovernanceSubjectTaxonomyRegistry
|
||||||
|
{
|
||||||
|
private readonly GovernanceSubjectTaxonomyRegistry $inner;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->inner = new GovernanceSubjectTaxonomyRegistry;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function all(): array
|
||||||
|
{
|
||||||
|
return array_values(array_merge($this->inner->all(), [
|
||||||
|
new GovernanceSubjectType(
|
||||||
|
domainKey: GovernanceDomainKey::Entra,
|
||||||
|
subjectClass: GovernanceSubjectClass::Control,
|
||||||
|
subjectTypeKey: 'conditionalAccessPolicy',
|
||||||
|
label: 'Conditional Access Policy',
|
||||||
|
description: 'Synthetic test-only future domain control',
|
||||||
|
captureSupported: true,
|
||||||
|
compareSupported: true,
|
||||||
|
inventorySupported: true,
|
||||||
|
active: true,
|
||||||
|
supportMode: 'supported',
|
||||||
|
legacyBucket: null,
|
||||||
|
),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
require_once dirname(__DIR__).'/Baselines/Support/FakeCompareStrategy.php';
|
||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareLanding;
|
use App\Filament\Pages\BaselineCompareLanding;
|
||||||
use App\Jobs\CompareBaselineToTenantJob;
|
use App\Jobs\CompareBaselineToTenantJob;
|
||||||
use App\Livewire\BulkOperationProgress;
|
use App\Livewire\BulkOperationProgress;
|
||||||
@ -7,6 +9,9 @@
|
|||||||
use App\Models\BaselineSnapshot;
|
use App\Models\BaselineSnapshot;
|
||||||
use App\Models\BaselineTenantAssignment;
|
use App\Models\BaselineTenantAssignment;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
|
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||||
|
use App\Support\Baselines\Compare\IntuneCompareStrategy;
|
||||||
|
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
use App\Support\OperationRunType;
|
use App\Support\OperationRunType;
|
||||||
@ -15,6 +20,8 @@
|
|||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
use Tests\Feature\Baselines\Support\FakeCompareStrategy;
|
||||||
|
use Tests\Feature\Baselines\Support\FakeGovernanceSubjectTaxonomyRegistry;
|
||||||
|
|
||||||
it('redirects unauthenticated users (302)', function (): void {
|
it('redirects unauthenticated users (302)', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
@ -186,6 +193,64 @@
|
|||||||
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows mixed-strategy compare rejection truth on the tenant landing surface', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
|
||||||
|
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
|
||||||
|
app(IntuneCompareStrategy::class),
|
||||||
|
app(FakeCompareStrategy::class),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => [
|
||||||
|
'version' => 2,
|
||||||
|
'entries' => [
|
||||||
|
[
|
||||||
|
'domain_key' => 'intune',
|
||||||
|
'subject_class' => 'policy',
|
||||||
|
'subject_type_keys' => ['deviceConfiguration'],
|
||||||
|
'filters' => [],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'domain_key' => 'entra',
|
||||||
|
'subject_class' => 'control',
|
||||||
|
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||||
|
'filters' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||||
|
|
||||||
|
BaselineTenantAssignment::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(BaselineCompareLanding::class)
|
||||||
|
->callAction('compareNow')
|
||||||
|
->assertNotified('Cannot start comparison')
|
||||||
|
->assertStatus(200);
|
||||||
|
|
||||||
|
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||||
|
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('can refresh stats without calling mount directly', function (): void {
|
it('can refresh stats without calling mount directly', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|||||||
@ -182,3 +182,75 @@
|
|||||||
->assertDontSee('Evidence gap details')
|
->assertDontSee('Evidence gap details')
|
||||||
->assertSee('Baseline compare evidence');
|
->assertSee('Baseline compare evidence');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes strategy diagnostics in the landing evidence payload when strategy-owned compare processing fails', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
$tenant->makeCurrent();
|
||||||
|
Filament::setTenant($tenant, true);
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||||
|
|
||||||
|
BaselineTenantAssignment::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => OperationRunType::BaselineCompare->value,
|
||||||
|
'status' => OperationRunStatus::Completed->value,
|
||||||
|
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||||
|
'completed_at' => now(),
|
||||||
|
'context' => [
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
'baseline_snapshot_id' => (int) $snapshot->getKey(),
|
||||||
|
'baseline_compare' => [
|
||||||
|
'reason_code' => BaselineCompareReasonCode::StrategyFailed->value,
|
||||||
|
'coverage' => [
|
||||||
|
'effective_types' => ['deviceConfiguration'],
|
||||||
|
'covered_types' => ['deviceConfiguration'],
|
||||||
|
'uncovered_types' => [],
|
||||||
|
'proof' => true,
|
||||||
|
],
|
||||||
|
'fidelity' => 'meta',
|
||||||
|
'strategy' => [
|
||||||
|
'key' => 'intune_policy',
|
||||||
|
'selection_state' => 'supported',
|
||||||
|
'operator_reason' => 'Compare strategy resolved successfully.',
|
||||||
|
'execution_diagnostics' => [
|
||||||
|
'failed' => true,
|
||||||
|
'exception_class' => RuntimeException::class,
|
||||||
|
],
|
||||||
|
'state_counts' => [
|
||||||
|
'failed' => 1,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'evidence_gaps' => [
|
||||||
|
'count' => 1,
|
||||||
|
'by_reason' => [
|
||||||
|
'strategy_failed' => 1,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(BaselineCompareLanding::class)
|
||||||
|
->assertSee('Baseline compare evidence')
|
||||||
|
->assertSee('intune_policy')
|
||||||
|
->assertSee('strategy_failed')
|
||||||
|
->assertSee('RuntimeException');
|
||||||
|
});
|
||||||
|
|||||||
@ -2,12 +2,20 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require_once dirname(__DIR__).'/Baselines/Support/FakeCompareStrategy.php';
|
||||||
|
|
||||||
use App\Filament\Pages\BaselineCompareMatrix;
|
use App\Filament\Pages\BaselineCompareMatrix;
|
||||||
use App\Filament\Resources\BaselineProfileResource;
|
use App\Filament\Resources\BaselineProfileResource;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\WorkspaceMembership;
|
use App\Models\WorkspaceMembership;
|
||||||
|
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||||
|
use App\Support\Baselines\Compare\IntuneCompareStrategy;
|
||||||
|
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||||
|
use Filament\Actions\Action;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
use Tests\Feature\Baselines\Support\FakeCompareStrategy;
|
||||||
|
use Tests\Feature\Baselines\Support\FakeGovernanceSubjectTaxonomyRegistry;
|
||||||
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
use Tests\Feature\Concerns\BuildsBaselineCompareMatrixFixtures;
|
||||||
|
|
||||||
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
uses(RefreshDatabase::class, BuildsBaselineCompareMatrixFixtures::class);
|
||||||
@ -199,6 +207,44 @@
|
|||||||
->assertSee('Capture a complete baseline snapshot before using the compare matrix.');
|
->assertSee('Capture a complete baseline snapshot before using the compare matrix.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('disables compare-assigned-tenants when the scope spans multiple compare strategy families', function (): void {
|
||||||
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
|
||||||
|
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
|
||||||
|
app(IntuneCompareStrategy::class),
|
||||||
|
app(FakeCompareStrategy::class),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$fixture['profile']->update([
|
||||||
|
'scope_jsonb' => [
|
||||||
|
'version' => 2,
|
||||||
|
'entries' => [
|
||||||
|
[
|
||||||
|
'domain_key' => 'intune',
|
||||||
|
'subject_class' => 'policy',
|
||||||
|
'subject_type_keys' => ['deviceConfiguration'],
|
||||||
|
'filters' => [],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'domain_key' => 'entra',
|
||||||
|
'subject_class' => 'control',
|
||||||
|
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||||
|
'filters' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->setAdminWorkspaceContext($fixture['user'], $fixture['workspace']);
|
||||||
|
|
||||||
|
Livewire::actingAs($fixture['user'])
|
||||||
|
->test(BaselineCompareMatrix::class, ['record' => $fixture['profile']->getKey()])
|
||||||
|
->assertActionVisible('compareAssignedTenants')
|
||||||
|
->assertActionDisabled('compareAssignedTenants')
|
||||||
|
->assertActionExists('compareAssignedTenants', fn (Action $action): bool => $action->getTooltip() === 'The selected governed subjects span multiple compare strategy families and must be narrowed before comparing assigned tenants.');
|
||||||
|
});
|
||||||
|
|
||||||
it('renders an empty state when the baseline profile has no assigned tenants', function (): void {
|
it('renders an empty state when the baseline profile has no assigned tenants', function (): void {
|
||||||
$fixture = $this->makeBaselineCompareMatrixFixture();
|
$fixture = $this->makeBaselineCompareMatrixFixture();
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
require_once dirname(__DIR__).'/Baselines/Support/FakeCompareStrategy.php';
|
||||||
|
|
||||||
use App\Filament\Resources\BaselineProfileResource;
|
use App\Filament\Resources\BaselineProfileResource;
|
||||||
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
|
use App\Filament\Resources\BaselineProfileResource\Pages\ViewBaselineProfile;
|
||||||
use App\Jobs\CompareBaselineToTenantJob;
|
use App\Jobs\CompareBaselineToTenantJob;
|
||||||
@ -8,6 +10,9 @@
|
|||||||
use App\Models\BaselineTenantAssignment;
|
use App\Models\BaselineTenantAssignment;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Support\Baselines\BaselineCaptureMode;
|
use App\Support\Baselines\BaselineCaptureMode;
|
||||||
|
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||||
|
use App\Support\Baselines\Compare\IntuneCompareStrategy;
|
||||||
|
use App\Support\Governance\GovernanceSubjectTaxonomyRegistry;
|
||||||
use App\Support\Workspaces\WorkspaceContext;
|
use App\Support\Workspaces\WorkspaceContext;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@ -15,6 +20,8 @@
|
|||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
use Livewire\Features\SupportTesting\Testable;
|
use Livewire\Features\SupportTesting\Testable;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
use Tests\Feature\Baselines\Support\FakeCompareStrategy;
|
||||||
|
use Tests\Feature\Baselines\Support\FakeGovernanceSubjectTaxonomyRegistry;
|
||||||
|
|
||||||
function baselineProfileHeaderActions(Testable $component): array
|
function baselineProfileHeaderActions(Testable $component): array
|
||||||
{
|
{
|
||||||
@ -149,6 +156,64 @@ function baselineProfileHeaderActions(Testable $component): array
|
|||||||
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows mixed-strategy compare rejection truth on the workspace start surface', function (): void {
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
app()->instance(GovernanceSubjectTaxonomyRegistry::class, new FakeGovernanceSubjectTaxonomyRegistry);
|
||||||
|
app()->instance(CompareStrategyRegistry::class, new CompareStrategyRegistry([
|
||||||
|
app(IntuneCompareStrategy::class),
|
||||||
|
app(FakeCompareStrategy::class),
|
||||||
|
]));
|
||||||
|
|
||||||
|
$profile = BaselineProfile::factory()->active()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'scope_jsonb' => [
|
||||||
|
'version' => 2,
|
||||||
|
'entries' => [
|
||||||
|
[
|
||||||
|
'domain_key' => 'intune',
|
||||||
|
'subject_class' => 'policy',
|
||||||
|
'subject_type_keys' => ['deviceConfiguration'],
|
||||||
|
'filters' => [],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'domain_key' => 'entra',
|
||||||
|
'subject_class' => 'control',
|
||||||
|
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||||
|
'filters' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$snapshot = BaselineSnapshot::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$profile->update(['active_snapshot_id' => (int) $snapshot->getKey()]);
|
||||||
|
|
||||||
|
BaselineTenantAssignment::factory()->create([
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'baseline_profile_id' => (int) $profile->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
session()->put(WorkspaceContext::SESSION_KEY, (int) $tenant->workspace_id);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(ViewBaselineProfile::class, ['record' => $profile->getKey()])
|
||||||
|
->assertSee('Mixed strategy scope')
|
||||||
|
->callAction('compareNow', data: ['target_tenant_id' => (int) $tenant->getKey()])
|
||||||
|
->assertNotified('Cannot start comparison')
|
||||||
|
->assertStatus(200);
|
||||||
|
|
||||||
|
Queue::assertNotPushed(CompareBaselineToTenantJob::class);
|
||||||
|
expect(OperationRun::query()->where('type', 'baseline_compare')->count())->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('moves compare-matrix navigation into related context while keeping compare-assigned-tenants secondary', function (): void {
|
it('moves compare-matrix navigation into related context while keeping compare-assigned-tenants secondary', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -151,6 +151,73 @@ function visibleLivewireText(Testable $component): string
|
|||||||
->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
|
->and(mb_strpos($pageText, 'Decision'))->toBeLessThan(mb_strpos($pageText, 'Artifact truth details'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows strategy diagnostics and operator-safe failure meaning for strategy-owned compare failures', function (): void {
|
||||||
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
$run = OperationRun::factory()->create([
|
||||||
|
'tenant_id' => (int) $tenant->getKey(),
|
||||||
|
'workspace_id' => (int) $tenant->workspace_id,
|
||||||
|
'type' => 'baseline_compare',
|
||||||
|
'status' => 'completed',
|
||||||
|
'outcome' => 'partially_succeeded',
|
||||||
|
'context' => [
|
||||||
|
'baseline_compare' => [
|
||||||
|
'reason_code' => BaselineCompareReasonCode::StrategyFailed->value,
|
||||||
|
'coverage' => [
|
||||||
|
'proof' => true,
|
||||||
|
'effective_types' => ['conditionalAccessPolicy'],
|
||||||
|
'covered_types' => ['conditionalAccessPolicy'],
|
||||||
|
'uncovered_types' => [],
|
||||||
|
],
|
||||||
|
'evidence_gaps' => [
|
||||||
|
'count' => 1,
|
||||||
|
'by_reason' => [
|
||||||
|
'strategy_failed' => 1,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'strategy' => [
|
||||||
|
'key' => 'intune_policy',
|
||||||
|
'selection_state' => 'supported',
|
||||||
|
'operator_reason' => 'Compare strategy resolved successfully.',
|
||||||
|
'execution_diagnostics' => [
|
||||||
|
'failed' => true,
|
||||||
|
'exception_class' => RuntimeException::class,
|
||||||
|
],
|
||||||
|
'state_counts' => [
|
||||||
|
'failed' => 2,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'summary_counts' => [
|
||||||
|
'total' => 0,
|
||||||
|
'processed' => 0,
|
||||||
|
'errors_recorded' => 1,
|
||||||
|
],
|
||||||
|
'completed_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$truth = app(ArtifactTruthPresenter::class)->forOperationRun($run->fresh());
|
||||||
|
$explanation = $truth->operatorExplanation;
|
||||||
|
|
||||||
|
Filament::setTenant(null, true);
|
||||||
|
$this->withSession([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||||
|
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||||
|
|
||||||
|
Livewire::actingAs($user)
|
||||||
|
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||||
|
->assertSee($explanation?->headline ?? '')
|
||||||
|
->assertSee($explanation?->nextActionText ?? '')
|
||||||
|
->assertSee('Compare strategy')
|
||||||
|
->assertSee('Intune Policy')
|
||||||
|
->assertSee('Strategy selection')
|
||||||
|
->assertSee('Supported')
|
||||||
|
->assertSee('Strategy subject states')
|
||||||
|
->assertSee('Failed 2')
|
||||||
|
->assertSee('Baseline compare evidence')
|
||||||
|
->assertSee('RuntimeException');
|
||||||
|
});
|
||||||
|
|
||||||
it('deduplicates repeated artifact truth explanation text for follow-up runs without a usable artifact', function (): void {
|
it('deduplicates repeated artifact truth explanation text for follow-up runs without a usable artifact', function (): void {
|
||||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||||
|
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -0,0 +1,217 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Models\Tenant;
|
||||||
|
use App\Support\Baselines\BaselineScope;
|
||||||
|
use App\Support\Baselines\Compare\CompareOrchestrationContext;
|
||||||
|
use App\Support\Baselines\Compare\CompareStrategy;
|
||||||
|
use App\Support\Baselines\Compare\CompareStrategyCapability;
|
||||||
|
use App\Support\Baselines\Compare\CompareStrategyKey;
|
||||||
|
use App\Support\Baselines\Compare\CompareStrategyRegistry;
|
||||||
|
use App\Support\Governance\GovernanceDomainKey;
|
||||||
|
use App\Support\Governance\GovernanceSubjectClass;
|
||||||
|
|
||||||
|
it('selects a single compatible strategy family for a canonical scope entry', function (): void {
|
||||||
|
$registry = new CompareStrategyRegistry([
|
||||||
|
compareStrategyStub(
|
||||||
|
key: 'intune_policy',
|
||||||
|
capabilities: [
|
||||||
|
new CompareStrategyCapability(
|
||||||
|
strategyKey: CompareStrategyKey::intunePolicy(),
|
||||||
|
domainKeys: [GovernanceDomainKey::Intune->value, GovernanceDomainKey::PlatformFoundation->value],
|
||||||
|
subjectClasses: [
|
||||||
|
GovernanceSubjectClass::Policy->value,
|
||||||
|
GovernanceSubjectClass::ConfigurationResource->value,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$scope = BaselineScope::fromJsonb([
|
||||||
|
'version' => 2,
|
||||||
|
'entries' => [
|
||||||
|
[
|
||||||
|
'domain_key' => GovernanceDomainKey::Intune->value,
|
||||||
|
'subject_class' => GovernanceSubjectClass::Policy->value,
|
||||||
|
'subject_type_keys' => ['deviceConfiguration'],
|
||||||
|
'filters' => [],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'domain_key' => GovernanceDomainKey::PlatformFoundation->value,
|
||||||
|
'subject_class' => GovernanceSubjectClass::ConfigurationResource->value,
|
||||||
|
'subject_type_keys' => ['assignmentFilter'],
|
||||||
|
'filters' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$selection = $registry->select($scope);
|
||||||
|
|
||||||
|
expect($selection->isSupported())->toBeTrue()
|
||||||
|
->and($selection->strategyKey?->value)->toBe('intune_policy')
|
||||||
|
->and($selection->matchedScopeEntries)->toHaveCount(2)
|
||||||
|
->and($selection->rejectedScopeEntries)->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects canonical scope entries when no strategy supports them', function (): void {
|
||||||
|
$registry = new CompareStrategyRegistry([
|
||||||
|
compareStrategyStub(
|
||||||
|
key: 'intune_policy',
|
||||||
|
capabilities: [
|
||||||
|
new CompareStrategyCapability(
|
||||||
|
strategyKey: CompareStrategyKey::intunePolicy(),
|
||||||
|
domainKeys: [GovernanceDomainKey::Intune->value],
|
||||||
|
subjectClasses: [GovernanceSubjectClass::Policy->value],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$scope = new BaselineScope(
|
||||||
|
entries: [[
|
||||||
|
'domain_key' => GovernanceDomainKey::Entra->value,
|
||||||
|
'subject_class' => GovernanceSubjectClass::Control->value,
|
||||||
|
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||||
|
'filters' => [],
|
||||||
|
]],
|
||||||
|
version: 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
$selection = $registry->select($scope);
|
||||||
|
|
||||||
|
expect($selection->isUnsupported())->toBeTrue()
|
||||||
|
->and($selection->strategyKey)->toBeNull()
|
||||||
|
->and($selection->matchedScopeEntries)->toBe([])
|
||||||
|
->and($selection->rejectedScopeEntries)->toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks scope as mixed when multiple strategy families are required', function (): void {
|
||||||
|
$registry = new CompareStrategyRegistry([
|
||||||
|
compareStrategyStub(
|
||||||
|
key: 'intune_policy',
|
||||||
|
capabilities: [
|
||||||
|
new CompareStrategyCapability(
|
||||||
|
strategyKey: CompareStrategyKey::intunePolicy(),
|
||||||
|
domainKeys: [GovernanceDomainKey::Intune->value],
|
||||||
|
subjectClasses: [GovernanceSubjectClass::Policy->value],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
compareStrategyStub(
|
||||||
|
key: 'future_control',
|
||||||
|
capabilities: [
|
||||||
|
new CompareStrategyCapability(
|
||||||
|
strategyKey: CompareStrategyKey::from('future_control'),
|
||||||
|
domainKeys: [GovernanceDomainKey::Entra->value],
|
||||||
|
subjectClasses: [GovernanceSubjectClass::Control->value],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$scope = new BaselineScope(
|
||||||
|
entries: [
|
||||||
|
[
|
||||||
|
'domain_key' => GovernanceDomainKey::Intune->value,
|
||||||
|
'subject_class' => GovernanceSubjectClass::Policy->value,
|
||||||
|
'subject_type_keys' => ['deviceConfiguration'],
|
||||||
|
'filters' => [],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'domain_key' => GovernanceDomainKey::Entra->value,
|
||||||
|
'subject_class' => GovernanceSubjectClass::Control->value,
|
||||||
|
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||||
|
'filters' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
version: 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
$selection = $registry->select($scope);
|
||||||
|
|
||||||
|
expect($selection->isMixed())->toBeTrue()
|
||||||
|
->and($selection->strategyKey)->toBeNull()
|
||||||
|
->and($selection->matchedScopeEntries)->toHaveCount(2)
|
||||||
|
->and($selection->diagnostics['matched_strategy_keys'] ?? [])->toEqual(['future_control', 'intune_policy']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports deterministic future-domain selection without implicit intune fallback', function (): void {
|
||||||
|
$registry = new CompareStrategyRegistry([
|
||||||
|
compareStrategyStub(
|
||||||
|
key: 'future_control',
|
||||||
|
capabilities: [
|
||||||
|
new CompareStrategyCapability(
|
||||||
|
strategyKey: CompareStrategyKey::from('future_control'),
|
||||||
|
domainKeys: [GovernanceDomainKey::Entra->value],
|
||||||
|
subjectClasses: [GovernanceSubjectClass::Control->value],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$scope = new BaselineScope(
|
||||||
|
entries: [[
|
||||||
|
'domain_key' => GovernanceDomainKey::Entra->value,
|
||||||
|
'subject_class' => GovernanceSubjectClass::Control->value,
|
||||||
|
'subject_type_keys' => ['conditionalAccessPolicy'],
|
||||||
|
'filters' => [],
|
||||||
|
]],
|
||||||
|
version: 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
$selection = $registry->select($scope);
|
||||||
|
|
||||||
|
expect($selection->isSupported())->toBeTrue()
|
||||||
|
->and($selection->strategyKey?->value)->toBe('future_control')
|
||||||
|
->and($registry->resolve('future_control'))->toBeInstanceOf(CompareStrategy::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws when resolving an unknown strategy key', function (): void {
|
||||||
|
$registry = new CompareStrategyRegistry([]);
|
||||||
|
|
||||||
|
expect(fn (): CompareStrategy => $registry->resolve('missing_strategy'))
|
||||||
|
->toThrow(InvalidArgumentException::class, 'Unknown compare strategy');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<CompareStrategyCapability> $capabilities
|
||||||
|
*/
|
||||||
|
function compareStrategyStub(string $key, array $capabilities): CompareStrategy
|
||||||
|
{
|
||||||
|
return new class($key, $capabilities) implements CompareStrategy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param list<CompareStrategyCapability> $capabilities
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly string $keyValue,
|
||||||
|
private readonly array $capabilities,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function key(): CompareStrategyKey
|
||||||
|
{
|
||||||
|
return CompareStrategyKey::from($this->keyValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function capabilities(): array
|
||||||
|
{
|
||||||
|
return $this->capabilities;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function compare(
|
||||||
|
CompareOrchestrationContext $context,
|
||||||
|
Tenant $tenant,
|
||||||
|
array $baselineItems,
|
||||||
|
array $currentItems,
|
||||||
|
array $resolvedCurrentEvidence,
|
||||||
|
array $severityMapping,
|
||||||
|
): array {
|
||||||
|
return [
|
||||||
|
'subject_results' => [],
|
||||||
|
'diagnostics' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Support\Baselines\Compare\CompareFindingCandidate;
|
||||||
|
use App\Support\Baselines\Compare\CompareState;
|
||||||
|
use App\Support\Baselines\Compare\CompareSubjectIdentity;
|
||||||
|
use App\Support\Baselines\Compare\CompareSubjectProjection;
|
||||||
|
use App\Support\Baselines\Compare\CompareSubjectResult;
|
||||||
|
|
||||||
|
it('serializes compare subject results with structured finding and diagnostics payloads', function (): void {
|
||||||
|
$result = new CompareSubjectResult(
|
||||||
|
subjectIdentity: new CompareSubjectIdentity(
|
||||||
|
domainKey: 'entra',
|
||||||
|
subjectClass: 'control',
|
||||||
|
subjectTypeKey: 'conditionalAccessPolicy',
|
||||||
|
externalSubjectId: 'cap-1',
|
||||||
|
subjectKey: 'cap-1',
|
||||||
|
),
|
||||||
|
projection: new CompareSubjectProjection(
|
||||||
|
platformSubjectClass: 'control',
|
||||||
|
domainKey: 'entra',
|
||||||
|
subjectTypeKey: 'conditionalAccessPolicy',
|
||||||
|
operatorLabel: 'Conditional Access Policy',
|
||||||
|
summaryKind: 'control_snapshot',
|
||||||
|
additionalLabels: ['family' => 'identity'],
|
||||||
|
),
|
||||||
|
baselineAvailability: 'available',
|
||||||
|
currentStateAvailability: 'available',
|
||||||
|
compareState: CompareState::Drift,
|
||||||
|
trustLevel: 'limited_confidence',
|
||||||
|
evidenceQuality: 'meta',
|
||||||
|
severityRecommendation: 'medium',
|
||||||
|
findingCandidate: new CompareFindingCandidate(
|
||||||
|
changeType: 'different_version',
|
||||||
|
severity: 'medium',
|
||||||
|
fingerprintBasis: [
|
||||||
|
'policy_type' => 'conditionalAccessPolicy',
|
||||||
|
'subject_key' => 'cap-1',
|
||||||
|
'change_type' => 'different_version',
|
||||||
|
],
|
||||||
|
evidencePayload: [
|
||||||
|
'summary' => ['kind' => 'control_snapshot'],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
diagnostics: [
|
||||||
|
'strategy_key' => 'future_control',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$payload = $result->toArray();
|
||||||
|
|
||||||
|
expect($result->hasFindingCandidate())->toBeTrue()
|
||||||
|
->and($result->isGapState())->toBeFalse()
|
||||||
|
->and($payload['compare_state'])->toBe(CompareState::Drift->value)
|
||||||
|
->and($payload['baseline_availability'])->toBe('available')
|
||||||
|
->and($payload['current_state_availability'])->toBe('available')
|
||||||
|
->and($payload['projection']['platform_subject_class'])->toBe('control')
|
||||||
|
->and($payload['projection']['summary_kind'])->toBe('control_snapshot')
|
||||||
|
->and($payload['finding_candidate']['change_type'])->toBe('different_version')
|
||||||
|
->and($payload['finding_candidate']['severity'])->toBe('medium')
|
||||||
|
->and($payload['diagnostics']['strategy_key'])->toBe('future_control');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires a finding candidate for drift results', function (): void {
|
||||||
|
expect(fn (): CompareSubjectResult => new CompareSubjectResult(
|
||||||
|
subjectIdentity: new CompareSubjectIdentity('intune', 'policy', 'deviceConfiguration', 'policy-1', 'policy-1'),
|
||||||
|
projection: new CompareSubjectProjection('policy', 'intune', 'deviceConfiguration', 'Policy 1'),
|
||||||
|
baselineAvailability: 'available',
|
||||||
|
currentStateAvailability: 'missing',
|
||||||
|
compareState: CompareState::Drift,
|
||||||
|
trustLevel: 'limited_confidence',
|
||||||
|
evidenceQuality: 'meta',
|
||||||
|
))->toThrow(InvalidArgumentException::class, 'require a finding candidate');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-drift results that still try to write findings', function (): void {
|
||||||
|
expect(fn (): CompareSubjectResult => new CompareSubjectResult(
|
||||||
|
subjectIdentity: new CompareSubjectIdentity('entra', 'control', 'conditionalAccessPolicy', 'cap-1', 'cap-1'),
|
||||||
|
projection: new CompareSubjectProjection('control', 'entra', 'conditionalAccessPolicy', 'CAP 1'),
|
||||||
|
baselineAvailability: 'available',
|
||||||
|
currentStateAvailability: 'unknown',
|
||||||
|
compareState: CompareState::Incomplete,
|
||||||
|
trustLevel: 'unusable',
|
||||||
|
evidenceQuality: 'missing',
|
||||||
|
findingCandidate: new CompareFindingCandidate(
|
||||||
|
changeType: 'different_version',
|
||||||
|
severity: 'high',
|
||||||
|
fingerprintBasis: ['subject_key' => 'cap-1'],
|
||||||
|
evidencePayload: [],
|
||||||
|
),
|
||||||
|
))->toThrow(InvalidArgumentException::class, 'Only drift compare subject results');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats unsupported, incomplete, ambiguous, and failed states as gap states', function (): void {
|
||||||
|
$states = [
|
||||||
|
CompareState::Unsupported,
|
||||||
|
CompareState::Incomplete,
|
||||||
|
CompareState::Ambiguous,
|
||||||
|
CompareState::Failed,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($states as $state) {
|
||||||
|
$result = new CompareSubjectResult(
|
||||||
|
subjectIdentity: new CompareSubjectIdentity('entra', 'control', 'conditionalAccessPolicy', 'cap-1', 'cap-1'),
|
||||||
|
projection: new CompareSubjectProjection('control', 'entra', 'conditionalAccessPolicy', 'CAP 1'),
|
||||||
|
baselineAvailability: 'available',
|
||||||
|
currentStateAvailability: 'unknown',
|
||||||
|
compareState: $state,
|
||||||
|
trustLevel: 'unusable',
|
||||||
|
evidenceQuality: 'missing',
|
||||||
|
diagnostics: [
|
||||||
|
'reason_code' => 'strategy_failed',
|
||||||
|
'gap_record' => ['reason_code' => 'strategy_failed'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result->isGapState())->toBeTrue()
|
||||||
|
->and($result->gapReasonCode())->toBe('strategy_failed')
|
||||||
|
->and($result->gapRecord())->toBe(['reason_code' => 'strategy_failed']);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user