From 0415e9af88d4489ae0d50a7bb501f5069a9233fd Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Tue, 10 Mar 2026 19:51:41 +0100 Subject: [PATCH] feat: add resolved reference presentation layer --- .github/agents/copilot-instructions.md | 3 +- .../PolicyVersionAssignmentsWidget.php | 61 ++-- app/Providers/AppServiceProvider.php | 48 ++++ app/Services/AssignmentBackupService.php | 22 ++ .../Directory/EntraGroupLabelResolver.php | 37 +++ .../Drift/DriftFindingDiffBuilder.php | 53 ++-- app/Services/Graph/AssignmentFetcher.php | 28 ++ app/Support/Badges/BadgeCatalog.php | 1 + app/Support/Badges/BadgeDomain.php | 1 + .../Domains/ReferenceResolutionStateBadge.php | 28 ++ .../Navigation/RelatedContextEntry.php | 56 +++- .../Navigation/RelatedNavigationResolver.php | 209 ++++++-------- .../Contracts/ReferenceResolver.php | 16 ++ app/Support/References/ReferenceClass.php | 21 ++ .../References/ReferenceDescriptor.php | 29 ++ .../References/ReferenceLinkTarget.php | 28 ++ .../ReferencePresentationVariant.php | 11 + .../References/ReferenceResolutionState.php | 25 ++ .../References/ReferenceResolverRegistry.php | 37 +++ .../References/ReferenceStatePresenter.php | 39 +++ .../References/ReferenceTechnicalDetail.php | 60 ++++ .../References/ReferenceTypeLabelCatalog.php | 35 +++ .../RelatedContextReferenceAdapter.php | 33 +++ app/Support/References/ResolvedReference.php | 71 +++++ .../References/ResolvedReferencePresenter.php | 60 ++++ .../AssignmentTargetReferenceResolver.php | 97 +++++++ .../Resolvers/BackupSetReferenceResolver.php | 77 ++++++ .../Resolvers/BaseReferenceResolver.php | 209 ++++++++++++++ .../BaselineProfileReferenceResolver.php | 84 ++++++ .../BaselineSnapshotReferenceResolver.php | 87 ++++++ .../Resolvers/EntraGroupReferenceResolver.php | 116 ++++++++ .../EntraRoleDefinitionReferenceResolver.php | 54 ++++ .../Resolvers/FallbackReferenceResolver.php | 51 ++++ .../OperationRunReferenceResolver.php | 74 +++++ .../Resolvers/PolicyReferenceResolver.php | 77 ++++++ .../PolicyVersionReferenceResolver.php | 103 +++++++ .../Resolvers/PrincipalReferenceResolver.php | 36 +++ .../entries/assignments-diff.blade.php | 23 +- .../entries/related-context.blade.php | 107 +++---- .../resolved-reference-compact.blade.php | 34 +++ .../resolved-reference-detail.blade.php | 65 +++++ ...olicy-version-assignments-widget.blade.php | 43 +-- .../checklists/requirements.md | 36 +++ .../reference-presentation.openapi.yaml | 248 +++++++++++++++++ specs/132-guid-context-resolver/data-model.md | 123 +++++++++ specs/132-guid-context-resolver/plan.md | 260 ++++++++++++++++++ specs/132-guid-context-resolver/quickstart.md | 60 ++++ specs/132-guid-context-resolver/research.md | 57 ++++ specs/132-guid-context-resolver/spec.md | 203 ++++++++++++++ specs/132-guid-context-resolver/tasks.md | 232 ++++++++++++++++ ...tFindingDetailShowsAssignmentsDiffTest.php | 11 +- ...upSetResolvedReferencePresentationTest.php | 30 ++ ...pshotResolvedReferencePresentationTest.php | 37 +++ ...GroupResolvedReferencePresentationTest.php | 73 +++++ ...ndingResolvedReferencePresentationTest.php | 65 +++++ ...olicyVersionResolvedReferenceLinksTest.php | 41 +++ .../ResolvedReferenceRenderingSmokeTest.php | 38 +++ .../ResolvedReferenceUnsupportedClassTest.php | 30 ++ ...tContextResolvedReferenceCarryoverTest.php | 46 ++++ ...onRunResolvedReferencePresentationTest.php | 29 ++ .../PolicyVersionViewAssignmentsTest.php | 32 +++ ...ossResourceNavigationAuthorizationTest.php | 2 +- .../ResolvedReferenceAuthorizationTest.php | 44 +++ tests/Unit/AssignmentBackupServiceTest.php | 68 +++++ tests/Unit/AssignmentFetcherTest.php | 14 + .../CapabilityAwareReferenceResolverTest.php | 55 ++++ .../ModelBackedReferenceResolverTest.php | 70 +++++ .../References/ReferenceLinkTargetTest.php | 21 ++ .../ReferenceResolutionStateTest.php | 17 ++ ...renceResolverRegistryExtensibilityTest.php | 48 ++++ .../ReferenceResolverRegistryTest.php | 64 +++++ .../ReferenceStateBadgeMappingTest.php | 20 ++ .../RelatedContextReferenceAdapterTest.php | 70 +++++ .../References/ResolvedReferenceTest.php | 70 +++++ .../UnsupportedReferenceResolverTest.php | 30 ++ 75 files changed, 4332 insertions(+), 261 deletions(-) create mode 100644 app/Support/Badges/Domains/ReferenceResolutionStateBadge.php create mode 100644 app/Support/References/Contracts/ReferenceResolver.php create mode 100644 app/Support/References/ReferenceClass.php create mode 100644 app/Support/References/ReferenceDescriptor.php create mode 100644 app/Support/References/ReferenceLinkTarget.php create mode 100644 app/Support/References/ReferencePresentationVariant.php create mode 100644 app/Support/References/ReferenceResolutionState.php create mode 100644 app/Support/References/ReferenceResolverRegistry.php create mode 100644 app/Support/References/ReferenceStatePresenter.php create mode 100644 app/Support/References/ReferenceTechnicalDetail.php create mode 100644 app/Support/References/ReferenceTypeLabelCatalog.php create mode 100644 app/Support/References/RelatedContextReferenceAdapter.php create mode 100644 app/Support/References/ResolvedReference.php create mode 100644 app/Support/References/ResolvedReferencePresenter.php create mode 100644 app/Support/References/Resolvers/AssignmentTargetReferenceResolver.php create mode 100644 app/Support/References/Resolvers/BackupSetReferenceResolver.php create mode 100644 app/Support/References/Resolvers/BaseReferenceResolver.php create mode 100644 app/Support/References/Resolvers/BaselineProfileReferenceResolver.php create mode 100644 app/Support/References/Resolvers/BaselineSnapshotReferenceResolver.php create mode 100644 app/Support/References/Resolvers/EntraGroupReferenceResolver.php create mode 100644 app/Support/References/Resolvers/EntraRoleDefinitionReferenceResolver.php create mode 100644 app/Support/References/Resolvers/FallbackReferenceResolver.php create mode 100644 app/Support/References/Resolvers/OperationRunReferenceResolver.php create mode 100644 app/Support/References/Resolvers/PolicyReferenceResolver.php create mode 100644 app/Support/References/Resolvers/PolicyVersionReferenceResolver.php create mode 100644 app/Support/References/Resolvers/PrincipalReferenceResolver.php create mode 100644 resources/views/filament/infolists/entries/resolved-reference-compact.blade.php create mode 100644 resources/views/filament/infolists/entries/resolved-reference-detail.blade.php create mode 100644 specs/132-guid-context-resolver/checklists/requirements.md create mode 100644 specs/132-guid-context-resolver/contracts/reference-presentation.openapi.yaml create mode 100644 specs/132-guid-context-resolver/data-model.md create mode 100644 specs/132-guid-context-resolver/plan.md create mode 100644 specs/132-guid-context-resolver/quickstart.md create mode 100644 specs/132-guid-context-resolver/research.md create mode 100644 specs/132-guid-context-resolver/spec.md create mode 100644 specs/132-guid-context-resolver/tasks.md create mode 100644 tests/Feature/Filament/BackupSetResolvedReferencePresentationTest.php create mode 100644 tests/Feature/Filament/BaselineSnapshotResolvedReferencePresentationTest.php create mode 100644 tests/Feature/Filament/EntraGroupResolvedReferencePresentationTest.php create mode 100644 tests/Feature/Filament/FindingResolvedReferencePresentationTest.php create mode 100644 tests/Feature/Filament/PolicyVersionResolvedReferenceLinksTest.php create mode 100644 tests/Feature/Filament/ResolvedReferenceRenderingSmokeTest.php create mode 100644 tests/Feature/Filament/ResolvedReferenceUnsupportedClassTest.php create mode 100644 tests/Feature/Filament/TenantContextResolvedReferenceCarryoverTest.php create mode 100644 tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php create mode 100644 tests/Feature/Rbac/ResolvedReferenceAuthorizationTest.php create mode 100644 tests/Unit/Support/References/CapabilityAwareReferenceResolverTest.php create mode 100644 tests/Unit/Support/References/ModelBackedReferenceResolverTest.php create mode 100644 tests/Unit/Support/References/ReferenceLinkTargetTest.php create mode 100644 tests/Unit/Support/References/ReferenceResolutionStateTest.php create mode 100644 tests/Unit/Support/References/ReferenceResolverRegistryExtensibilityTest.php create mode 100644 tests/Unit/Support/References/ReferenceResolverRegistryTest.php create mode 100644 tests/Unit/Support/References/ReferenceStateBadgeMappingTest.php create mode 100644 tests/Unit/Support/References/RelatedContextReferenceAdapterTest.php create mode 100644 tests/Unit/Support/References/ResolvedReferenceTest.php create mode 100644 tests/Unit/Support/References/UnsupportedReferenceResolverTest.php diff --git a/.github/agents/copilot-instructions.md b/.github/agents/copilot-instructions.md index c2a0f80..636a8db 100644 --- a/.github/agents/copilot-instructions.md +++ b/.github/agents/copilot-instructions.md @@ -60,6 +60,7 @@ ## Active Technologies - PostgreSQL via Laravel Sail plus session-backed workspace and tenant contex (129-workspace-admin-home) - PostgreSQL via Laravel Sail using existing `baseline_snapshots`, `baseline_snapshot_items`, and JSONB presentation source fields (130-structured-snapshot-rendering) - PostgreSQL via Laravel Sail, plus existing session-backed workspace and tenant contex (131-cross-resource-navigation) +- PostgreSQL via Laravel Sail plus existing workspace and tenant context, existing Eloquent relations, and provider-derived identifiers already stored in domain records (132-guid-context-resolver) - PHP 8.4.15 (feat/005-bulk-operations) @@ -79,8 +80,8 @@ ## Code Style PHP 8.4.15: Follow standard conventions ## Recent Changes +- 132-guid-context-resolver: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4 - 131-cross-resource-navigation: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4 - 130-structured-snapshot-rendering: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4 -- 129-workspace-admin-home: Added PHP 8.4.15 / Laravel 12 + Filament v5, Livewire v4.0+, Tailwind CSS v4 diff --git a/app/Livewire/PolicyVersionAssignmentsWidget.php b/app/Livewire/PolicyVersionAssignmentsWidget.php index 12d66de..30e13dd 100644 --- a/app/Livewire/PolicyVersionAssignmentsWidget.php +++ b/app/Livewire/PolicyVersionAssignmentsWidget.php @@ -5,6 +5,9 @@ use App\Models\PolicyVersion; use App\Models\Tenant; use App\Services\Directory\EntraGroupLabelResolver; +use App\Support\References\ReferencePresentationVariant; +use App\Support\References\ResolvedReferencePresenter; +use App\Support\References\Resolvers\AssignmentTargetReferenceResolver; use Livewire\Component; class PolicyVersionAssignmentsWidget extends Component @@ -21,14 +24,14 @@ public function render(): \Illuminate\Contracts\View\View return view('livewire.policy-version-assignments-widget', [ 'version' => $this->version, 'compliance' => $this->complianceNotifications(), - 'groupLabels' => $this->groupLabels(), + 'assignmentReferences' => $this->assignmentReferences(), ]); } /** - * @return array + * @return array> */ - private function groupLabels(): array + private function assignmentReferences(): array { $assignments = $this->version->assignments; @@ -38,10 +41,6 @@ private function groupLabels(): array $tenant = rescue(fn () => Tenant::current(), null); - if (! $tenant instanceof Tenant) { - return []; - } - $groupIds = []; $sourceNames = []; @@ -73,21 +72,51 @@ private function groupLabels(): array $groupIds = array_values(array_unique($groupIds)); - if ($groupIds === []) { - return []; + $groupDescriptions = []; + + if ($tenant instanceof Tenant && $groupIds !== []) { + $resolver = app(EntraGroupLabelResolver::class); + $groupDescriptions = $resolver->describeMany($tenant, $groupIds, $sourceNames); + } else { + foreach ($groupIds as $groupId) { + $groupDescriptions[$groupId] = [ + 'display_name' => $sourceNames[$groupId] ?? null, + 'resolved' => false, + ]; + } } - $resolver = app(EntraGroupLabelResolver::class); - $cached = $resolver->lookupMany($tenant, $groupIds); + $references = []; + $assignmentTargetResolver = app(AssignmentTargetReferenceResolver::class); + $presenter = app(ResolvedReferencePresenter::class); - $labels = []; + foreach ($assignments as $index => $assignment) { + if (! is_array($assignment)) { + continue; + } - foreach ($groupIds as $groupId) { - $cachedName = $cached[strtolower($groupId)] ?? null; - $labels[$groupId] = EntraGroupLabelResolver::formatLabel($cachedName ?? ($sourceNames[$groupId] ?? null), $groupId); + $target = $assignment['target'] ?? null; + + if (! is_array($target)) { + continue; + } + + $type = strtolower((string) ($target['@odata.type'] ?? '')); + $targetId = (string) ($target['groupId'] ?? $target['collectionId'] ?? ''); + + $references[$index] = $presenter->present( + $assignmentTargetResolver->resolve($target, [ + 'tenant_id' => $tenant?->getKey(), + 'target_type' => $type, + 'target_id' => $targetId, + 'group_descriptions' => $groupDescriptions, + 'fallback_label' => is_string($target['group_display_name'] ?? null) ? $target['group_display_name'] : null, + ]), + ReferencePresentationVariant::Compact, + ); } - return $labels; + return $references; } /** diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index ce16619..f8efdbd 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -44,6 +44,21 @@ use App\Services\Providers\MicrosoftGraphOptionsResolver; use App\Services\Providers\ProviderConnectionResolver; use App\Services\Providers\ProviderGateway; +use App\Support\References\Contracts\ReferenceResolver; +use App\Support\References\ReferenceResolverRegistry; +use App\Support\References\ReferenceStatePresenter; +use App\Support\References\ReferenceTypeLabelCatalog; +use App\Support\References\ResolvedReferencePresenter; +use App\Support\References\Resolvers\BackupSetReferenceResolver; +use App\Support\References\Resolvers\BaselineProfileReferenceResolver; +use App\Support\References\Resolvers\BaselineSnapshotReferenceResolver; +use App\Support\References\Resolvers\EntraGroupReferenceResolver; +use App\Support\References\Resolvers\EntraRoleDefinitionReferenceResolver; +use App\Support\References\Resolvers\FallbackReferenceResolver; +use App\Support\References\Resolvers\OperationRunReferenceResolver; +use App\Support\References\Resolvers\PolicyReferenceResolver; +use App\Support\References\Resolvers\PolicyVersionReferenceResolver; +use App\Support\References\Resolvers\PrincipalReferenceResolver; use Filament\Events\TenantSet; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; @@ -94,6 +109,39 @@ public function register(): void ); }); + $this->app->singleton(ReferenceTypeLabelCatalog::class); + $this->app->singleton(ReferenceStatePresenter::class); + $this->app->singleton(ResolvedReferencePresenter::class); + $this->app->singleton(FallbackReferenceResolver::class); + $this->app->singleton(PolicyReferenceResolver::class); + $this->app->singleton(PolicyVersionReferenceResolver::class); + $this->app->singleton(BaselineProfileReferenceResolver::class); + $this->app->singleton(BaselineSnapshotReferenceResolver::class); + $this->app->singleton(OperationRunReferenceResolver::class); + $this->app->singleton(BackupSetReferenceResolver::class); + $this->app->singleton(EntraGroupReferenceResolver::class); + $this->app->singleton(EntraRoleDefinitionReferenceResolver::class); + $this->app->singleton(PrincipalReferenceResolver::class); + $this->app->singleton(ReferenceResolverRegistry::class, function ($app): ReferenceResolverRegistry { + /** @var array $resolvers */ + $resolvers = [ + $app->make(PolicyReferenceResolver::class), + $app->make(PolicyVersionReferenceResolver::class), + $app->make(BaselineProfileReferenceResolver::class), + $app->make(BaselineSnapshotReferenceResolver::class), + $app->make(OperationRunReferenceResolver::class), + $app->make(BackupSetReferenceResolver::class), + $app->make(EntraGroupReferenceResolver::class), + $app->make(EntraRoleDefinitionReferenceResolver::class), + $app->make(PrincipalReferenceResolver::class), + ]; + + return new ReferenceResolverRegistry( + resolvers: $resolvers, + fallbackResolver: $app->make(FallbackReferenceResolver::class), + ); + }); + $this->app->tag( [ AppProtectionPolicyNormalizer::class, diff --git a/app/Services/AssignmentBackupService.php b/app/Services/AssignmentBackupService.php index 38796c2..a1a8e89 100644 --- a/app/Services/AssignmentBackupService.php +++ b/app/Services/AssignmentBackupService.php @@ -62,6 +62,28 @@ public function enrichWithAssignments( return $backupItem->refresh(); } + if (! $this->assignmentFetcher->supportsStandardAssignments($policyType, $policyPayload['@odata.type'] ?? null)) { + unset( + $metadata['assignments_fetch_error'], + $metadata['assignments_fetch_error_code'], + ); + + $metadata['assignment_count'] = 0; + $metadata['assignments_fetch_failed'] = false; + $metadata['assignments_not_applicable'] = true; + $metadata['assignment_capture_reason'] = 'separate_role_assignments'; + $metadata['has_orphaned_assignments'] = false; + + $backupItem->update([ + 'assignments' => [], + 'metadata' => $metadata, + ]); + + $this->recordFetchOperationRun($backupItem, $tenant, $metadata); + + return $backupItem->refresh(); + } + // Fetch assignments from Graph API $graphOptions = $this->graphOptionsResolver->resolveForTenant($tenant); $tenantId = (string) ($graphOptions['tenant'] ?? ''); diff --git a/app/Services/Directory/EntraGroupLabelResolver.php b/app/Services/Directory/EntraGroupLabelResolver.php index 0691983..f694bb2 100644 --- a/app/Services/Directory/EntraGroupLabelResolver.php +++ b/app/Services/Directory/EntraGroupLabelResolver.php @@ -8,6 +8,14 @@ class EntraGroupLabelResolver { + public function lookupOne(Tenant $tenant, string $groupId): ?string + { + $labels = $this->lookupMany($tenant, [$groupId]); + $lookupId = Str::isUuid($groupId) ? strtolower($groupId) : $groupId; + + return $labels[$lookupId] ?? null; + } + public function resolveOne(Tenant $tenant, string $groupId): string { $labels = $this->resolveMany($tenant, [$groupId]); @@ -39,6 +47,35 @@ public function resolveMany(Tenant $tenant, array $groupIds): array return $labels; } + /** + * @param array $groupIds + * @param array $fallbackLabels + * @return array + */ + public function describeMany(Tenant $tenant, array $groupIds, array $fallbackLabels = []): array + { + $groupIds = array_values(array_unique(array_filter($groupIds, fn ($id) => is_string($id) && $id !== ''))); + + if ($groupIds === []) { + return []; + } + + $displayNames = $this->lookupMany($tenant, $groupIds); + $descriptions = []; + + foreach ($groupIds as $groupId) { + $lookupId = Str::isUuid($groupId) ? strtolower($groupId) : $groupId; + $displayName = $displayNames[$lookupId] ?? ($fallbackLabels[$groupId] ?? null); + + $descriptions[$groupId] = [ + 'display_name' => is_string($displayName) && $displayName !== '' ? $displayName : null, + 'resolved' => array_key_exists($lookupId, $displayNames), + ]; + } + + return $descriptions; + } + /** * @param array $groupIds * @return array Map of groupId (lowercased UUID) => display_name diff --git a/app/Services/Drift/DriftFindingDiffBuilder.php b/app/Services/Drift/DriftFindingDiffBuilder.php index 9e9e0de..73f4d63 100644 --- a/app/Services/Drift/DriftFindingDiffBuilder.php +++ b/app/Services/Drift/DriftFindingDiffBuilder.php @@ -9,6 +9,9 @@ use App\Services\Drift\Normalizers\ScopeTagsNormalizer; use App\Services\Drift\Normalizers\SettingsNormalizer; use App\Services\Intune\VersionDiff; +use App\Support\References\ReferencePresentationVariant; +use App\Support\References\ResolvedReferencePresenter; +use App\Support\References\Resolvers\AssignmentTargetReferenceResolver; class DriftFindingDiffBuilder { @@ -18,6 +21,8 @@ public function __construct( private readonly ScopeTagsNormalizer $scopeTagsNormalizer, private readonly VersionDiff $versionDiff, private readonly EntraGroupLabelResolver $groupLabelResolver, + private readonly AssignmentTargetReferenceResolver $assignmentTargetReferenceResolver, + private readonly ResolvedReferencePresenter $resolvedReferencePresenter, ) {} /** @@ -162,10 +167,11 @@ public function buildAssignmentsDiff(Tenant $tenant, ?PolicyVersion $baselineVer $removed = array_slice($removed, 0, min(count($removed), $budget)); } - $labels = $this->groupLabelsForDiff($tenant, $added, $removed, $changed); + $groupDescriptions = $this->groupDescriptionsForDiff($tenant, $added, $removed, $changed); - $decorateAssignment = function (array $row) use ($labels): array { - $row['target_label'] = $this->targetLabel($row, $labels); + $decorateAssignment = function (array $row) use ($groupDescriptions, $tenant): array { + $row['target_reference'] = $this->targetReference($tenant, $row, $groupDescriptions); + $row['target_label'] = (string) data_get($row, 'target_reference.primaryLabel', 'Unknown target'); return $row; }; @@ -309,9 +315,9 @@ private function fingerprintBucket(?PolicyVersion $version, string $bucket): arr * @param array> $added * @param array> $removed * @param array> $changed - * @return array + * @return array */ - private function groupLabelsForDiff(Tenant $tenant, array $added, array $removed, array $changed): array + private function groupDescriptionsForDiff(Tenant $tenant, array $added, array $removed, array $changed): array { $groupIds = []; @@ -353,34 +359,27 @@ private function groupLabelsForDiff(Tenant $tenant, array $added, array $removed return []; } - return $this->groupLabelResolver->resolveMany($tenant, $groupIds); + return $this->groupLabelResolver->describeMany($tenant, $groupIds); } /** * @param array $assignment - * @param array $groupLabels + * @param array $groupDescriptions + * @return array */ - private function targetLabel(array $assignment, array $groupLabels): string + private function targetReference(Tenant $tenant, array $assignment, array $groupDescriptions): array { - $targetType = $assignment['target_type'] ?? null; - $targetId = $assignment['target_id'] ?? null; + $targetType = is_string($assignment['target_type'] ?? null) ? (string) $assignment['target_type'] : ''; + $targetId = is_string($assignment['target_id'] ?? null) ? (string) $assignment['target_id'] : ''; - if (! is_string($targetType) || ! is_string($targetId)) { - return 'Unknown target'; - } - - if (str_contains($targetType, 'alldevicesassignmenttarget')) { - return 'All devices'; - } - - if (str_contains($targetType, 'allusersassignmenttarget')) { - return 'All users'; - } - - if (str_contains($targetType, 'groupassignmenttarget')) { - return $groupLabels[$targetId] ?? EntraGroupLabelResolver::formatLabel(null, $targetId); - } - - return sprintf('%s (%s)', $targetType, $targetId); + return $this->resolvedReferencePresenter->present( + $this->assignmentTargetReferenceResolver->resolve([], [ + 'tenant_id' => (int) $tenant->getKey(), + 'target_type' => $targetType, + 'target_id' => $targetId, + 'group_descriptions' => $groupDescriptions, + ]), + ReferencePresentationVariant::Compact, + ); } } diff --git a/app/Services/Graph/AssignmentFetcher.php b/app/Services/Graph/AssignmentFetcher.php index 3200292..395002d 100644 --- a/app/Services/Graph/AssignmentFetcher.php +++ b/app/Services/Graph/AssignmentFetcher.php @@ -27,6 +27,16 @@ public function fetch( bool $throwOnFailure = false, ?string $policyOdataType = null, ): array { + if (! $this->supportsStandardAssignments($policyType, $policyOdataType)) { + Log::debug('Standard assignment fetch is not applicable for policy type', [ + 'tenant_id' => $tenantId, + 'policy_type' => $policyType, + 'policy_id' => $policyId, + ]); + + return []; + } + $contract = $this->contracts->get($policyType); $listPathTemplate = $contract['assignments_list_path'] ?? null; $resource = $contract['resource'] ?? null; @@ -189,6 +199,24 @@ public function fetch( return []; } + public function supportsStandardAssignments(string $policyType, ?string $policyOdataType = null): bool + { + $contract = $this->contracts->get($policyType); + $listPathTemplate = $contract['assignments_list_path'] ?? null; + + if (is_string($listPathTemplate) && $listPathTemplate !== '') { + return true; + } + + if ($policyType === 'appProtectionPolicy' && $this->resolveAppProtectionAssignmentsListTemplate($policyOdataType) !== null) { + return true; + } + + $allowedExpand = $contract['allowed_expand'] ?? []; + + return is_array($allowedExpand) && in_array('assignments', $allowedExpand, true); + } + private function resolveAppProtectionAssignmentsListTemplate(?string $odataType): ?string { $entitySet = $this->resolveAppProtectionEntitySet($odataType); diff --git a/app/Support/Badges/BadgeCatalog.php b/app/Support/Badges/BadgeCatalog.php index d52f0cc..7b49efd 100644 --- a/app/Support/Badges/BadgeCatalog.php +++ b/app/Support/Badges/BadgeCatalog.php @@ -45,6 +45,7 @@ final class BadgeCatalog BadgeDomain::FindingType->value => Domains\FindingTypeBadge::class, BadgeDomain::ReviewPackStatus->value => Domains\ReviewPackStatusBadge::class, BadgeDomain::SystemHealth->value => Domains\SystemHealthBadge::class, + BadgeDomain::ReferenceResolutionState->value => Domains\ReferenceResolutionStateBadge::class, ]; /** diff --git a/app/Support/Badges/BadgeDomain.php b/app/Support/Badges/BadgeDomain.php index ff8782a..e76d28a 100644 --- a/app/Support/Badges/BadgeDomain.php +++ b/app/Support/Badges/BadgeDomain.php @@ -37,4 +37,5 @@ enum BadgeDomain: string case FindingType = 'finding_type'; case ReviewPackStatus = 'review_pack_status'; case SystemHealth = 'system_health'; + case ReferenceResolutionState = 'reference_resolution_state'; } diff --git a/app/Support/Badges/Domains/ReferenceResolutionStateBadge.php b/app/Support/Badges/Domains/ReferenceResolutionStateBadge.php new file mode 100644 index 0000000..c249667 --- /dev/null +++ b/app/Support/Badges/Domains/ReferenceResolutionStateBadge.php @@ -0,0 +1,28 @@ + new BadgeSpec('Resolved', 'success', 'heroicon-m-check-circle'), + ReferenceResolutionState::PartiallyResolved => new BadgeSpec('Partially linked', 'warning', 'heroicon-m-exclamation-circle'), + ReferenceResolutionState::Unresolved => new BadgeSpec('Unresolved', 'gray', 'heroicon-m-question-mark-circle'), + ReferenceResolutionState::DeletedOrMissing => new BadgeSpec('Missing', 'gray', 'heroicon-m-x-circle'), + ReferenceResolutionState::Inaccessible => new BadgeSpec('Access denied', 'danger', 'heroicon-m-lock-closed'), + ReferenceResolutionState::ExternalLimitedContext => new BadgeSpec('Provider-only', 'info', 'heroicon-m-globe-alt'), + default => BadgeSpec::unknown(), + }; + } +} diff --git a/app/Support/Navigation/RelatedContextEntry.php b/app/Support/Navigation/RelatedContextEntry.php index df68e31..c8f9e58 100644 --- a/app/Support/Navigation/RelatedContextEntry.php +++ b/app/Support/Navigation/RelatedContextEntry.php @@ -6,6 +6,9 @@ final readonly class RelatedContextEntry { + /** + * @param array|null $reference + */ public function __construct( public string $key, public string $label, @@ -18,6 +21,7 @@ public function __construct( public ?string $contextBadge, public int $priority, public string $actionLabel, + public ?array $reference = null, ) {} public static function available( @@ -43,6 +47,7 @@ public static function available( contextBadge: $contextBadge, priority: $priority, actionLabel: $actionLabel, + reference: null, ); } @@ -66,6 +71,53 @@ public static function unavailable( contextBadge: null, priority: $priority, actionLabel: $actionLabel, + reference: null, + ); + } + + /** + * @param array{ + * primaryLabel: string, + * secondaryLabel: ?string, + * state: string, + * stateDescription: ?string, + * linkTarget: array{targetKind: string, url: string, actionLabel: string, contextBadge: ?string}|null, + * technicalDetail: array{displayId: ?string, fullId: string, sourceHint: ?string, copyable: bool, defaultCollapsed: bool}, + * isLinkable: bool + * }&array $reference + */ + public static function fromResolvedReference( + string $key, + string $label, + string $targetKind, + int $priority, + string $actionLabel, + array $reference, + ): self { + $linkTarget = is_array($reference['linkTarget'] ?? null) ? $reference['linkTarget'] : null; + $technicalDetail = is_array($reference['technicalDetail'] ?? null) ? $reference['technicalDetail'] : []; + $isLinkable = ($reference['isLinkable'] ?? false) === true + && is_string($linkTarget['url'] ?? null) + && $linkTarget['url'] !== ''; + + $secondaryValueParts = array_values(array_filter([ + is_string($reference['secondaryLabel'] ?? null) ? $reference['secondaryLabel'] : null, + is_string($technicalDetail['displayId'] ?? null) ? 'ID '.$technicalDetail['displayId'] : null, + ])); + + return new self( + key: $key, + label: $label, + value: (string) ($reference['primaryLabel'] ?? 'Reference'), + secondaryValue: $secondaryValueParts !== [] ? implode(' · ', $secondaryValueParts) : null, + targetUrl: $isLinkable ? (string) $linkTarget['url'] : null, + targetKind: $targetKind, + availability: $isLinkable ? 'available' : (string) ($reference['state'] ?? 'unresolved'), + unavailableReason: is_string($reference['stateDescription'] ?? null) ? $reference['stateDescription'] : null, + contextBadge: is_string($linkTarget['contextBadge'] ?? null) ? $linkTarget['contextBadge'] : null, + priority: $priority, + actionLabel: is_string($linkTarget['actionLabel'] ?? null) ? (string) $linkTarget['actionLabel'] : $actionLabel, + reference: $reference, ); } @@ -86,7 +138,8 @@ public function isAvailable(): bool * unavailableReason: ?string, * contextBadge: ?string, * priority: int, - * actionLabel: string + * actionLabel: string, + * reference: array|null * } */ public function toArray(): array @@ -103,6 +156,7 @@ public function toArray(): array 'contextBadge' => $this->contextBadge, 'priority' => $this->priority, 'actionLabel' => $this->actionLabel, + 'reference' => $this->reference, ]; } } diff --git a/app/Support/Navigation/RelatedNavigationResolver.php b/app/Support/Navigation/RelatedNavigationResolver.php index b53e8df..93d9b32 100644 --- a/app/Support/Navigation/RelatedNavigationResolver.php +++ b/app/Support/Navigation/RelatedNavigationResolver.php @@ -5,10 +5,8 @@ namespace App\Support\Navigation; use App\Filament\Resources\BackupSetResource; -use App\Filament\Resources\BaselineProfileResource; use App\Filament\Resources\BaselineSnapshotResource; use App\Filament\Resources\FindingResource; -use App\Filament\Resources\PolicyResource; use App\Filament\Resources\PolicyVersionResource; use App\Filament\Resources\RestoreRunResource; use App\Models\BackupSet; @@ -26,8 +24,11 @@ use App\Services\Auth\CapabilityResolver; use App\Services\Auth\WorkspaceCapabilityResolver; use App\Support\Auth\Capabilities; -use App\Support\OperationCatalog; use App\Support\OperationRunLinks; +use App\Support\References\ReferenceClass; +use App\Support\References\ReferenceDescriptor; +use App\Support\References\ReferenceResolverRegistry; +use App\Support\References\RelatedContextReferenceAdapter; use Filament\Facades\Filament; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Arr; @@ -39,6 +40,8 @@ public function __construct( private readonly RelatedActionLabelCatalog $labels, private readonly CapabilityResolver $capabilityResolver, private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver, + private readonly ReferenceResolverRegistry $referenceResolverRegistry, + private readonly RelatedContextReferenceAdapter $relatedContextReferenceAdapter, ) {} /** @@ -439,33 +442,16 @@ private function baselineSnapshotEntry( return null; } - $snapshot = BaselineSnapshot::query() - ->with('baselineProfile') - ->whereKey($snapshotId) - ->where('workspace_id', $workspaceId) - ->first(); - - if (! $snapshot instanceof BaselineSnapshot) { - return $this->unavailableEntry($rule, (string) $snapshotId, 'missing'); - } - - if (! $this->canOpenWorkspaceBaselines($workspaceId)) { - return $this->unavailableEntry($rule, '#'.$snapshotId, 'unauthorized'); - } - - $value = '#'.$snapshot->getKey(); - $secondaryValue = $snapshot->baselineProfile?->name; - - return RelatedContextEntry::available( - key: $rule->relationKey, - label: $this->labels->entryLabel($rule->relationKey), - value: $value, - secondaryValue: $secondaryValue, - targetUrl: BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'), - targetKind: $rule->targetType, - priority: $rule->priority, - actionLabel: $this->labels->actionLabel($rule->relationKey), - contextBadge: 'Workspace', + return $this->resolveReferenceEntry( + rule: $rule, + descriptor: new ReferenceDescriptor( + referenceClass: ReferenceClass::BaselineSnapshot, + rawIdentifier: (string) $snapshotId, + workspaceId: $workspaceId, + sourceType: $rule->sourceType, + sourceSurface: $rule->sourceSurface, + linkedModelId: $snapshotId, + ), ); } @@ -496,20 +482,17 @@ private function policyProfileEntry(NavigationMatrixRule $rule, ?BaselineProfile return null; } - if (! $this->canOpenWorkspaceBaselines((int) $profile->workspace_id)) { - return $this->unavailableEntry($rule, '#'.$profile->getKey(), 'unauthorized'); - } - - return RelatedContextEntry::available( - key: $rule->relationKey, - label: $this->labels->entryLabel($rule->relationKey), - value: (string) $profile->name, - secondaryValue: '#'.$profile->getKey(), - targetUrl: BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'), - targetKind: $rule->targetType, - priority: $rule->priority, - actionLabel: $this->labels->actionLabel($rule->relationKey), - contextBadge: 'Workspace', + return $this->resolveReferenceEntry( + rule: $rule, + descriptor: new ReferenceDescriptor( + referenceClass: ReferenceClass::BaselineProfile, + rawIdentifier: (string) $profile->getKey(), + workspaceId: (int) $profile->workspace_id, + sourceType: $rule->sourceType, + sourceSurface: $rule->sourceSurface, + fallbackLabel: (string) $profile->name, + linkedModelId: (int) $profile->getKey(), + ), ); } @@ -523,29 +506,19 @@ private function operationRunEntry( return null; } - $run = OperationRun::query() - ->whereKey($runId) - ->where('workspace_id', $workspaceId) - ->first(); - - if (! $run instanceof OperationRun) { - return $this->unavailableEntry($rule, (string) $runId, 'missing'); - } - - if (! $this->canOpenOperationRun($run)) { - return $this->unavailableEntry($rule, '#'.$runId, 'unauthorized'); - } - - return RelatedContextEntry::available( - key: $rule->relationKey, - label: $this->labels->entryLabel($rule->relationKey), - value: OperationCatalog::label((string) $run->type), - secondaryValue: '#'.$run->getKey(), - targetUrl: OperationRunLinks::tenantlessView($run, $context), - targetKind: $rule->targetType, - priority: $rule->priority, - actionLabel: $this->labels->actionLabel($rule->relationKey), - contextBadge: $run->tenant_id ? 'Tenant context' : 'Workspace', + return $this->resolveReferenceEntry( + rule: $rule, + descriptor: new ReferenceDescriptor( + referenceClass: ReferenceClass::OperationRun, + rawIdentifier: (string) $runId, + workspaceId: $workspaceId, + sourceType: $rule->sourceType, + sourceSurface: $rule->sourceSurface, + linkedModelId: $runId, + context: [ + 'navigation_context' => $context, + ], + ), ); } @@ -558,33 +531,16 @@ private function policyVersionEntry( return null; } - $version = PolicyVersion::query() - ->with(['policy', 'tenant']) - ->whereKey($policyVersionId) - ->where('tenant_id', $tenantId) - ->first(); - - if (! $version instanceof PolicyVersion) { - return $this->unavailableEntry($rule, (string) $policyVersionId, 'missing'); - } - - if (! $this->canOpenPolicyVersion($version)) { - return $this->unavailableEntry($rule, '#'.$policyVersionId, 'unauthorized'); - } - - $value = $version->policy?->display_name ?: 'Policy version'; - $secondaryValue = 'Version '.(string) $version->version_number; - - return RelatedContextEntry::available( - key: $rule->relationKey, - label: $this->labels->entryLabel($rule->relationKey), - value: $value, - secondaryValue: $secondaryValue, - targetUrl: PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $version->tenant), - targetKind: $rule->targetType, - priority: $rule->priority, - actionLabel: $this->labels->actionLabel($rule->relationKey), - contextBadge: 'Tenant', + return $this->resolveReferenceEntry( + rule: $rule, + descriptor: new ReferenceDescriptor( + referenceClass: ReferenceClass::PolicyVersion, + rawIdentifier: (string) $policyVersionId, + tenantId: $tenantId, + sourceType: $rule->sourceType, + sourceSurface: $rule->sourceSurface, + linkedModelId: $policyVersionId, + ), ); } @@ -594,20 +550,17 @@ private function policyEntry(NavigationMatrixRule $rule, ?Policy $policy): ?Rela return null; } - if (! $this->canOpenPolicy($policy)) { - return $this->unavailableEntry($rule, '#'.$policy->getKey(), 'unauthorized'); - } - - return RelatedContextEntry::available( - key: $rule->relationKey, - label: $this->labels->entryLabel($rule->relationKey), - value: (string) ($policy->display_name ?: 'Policy'), - secondaryValue: '#'.$policy->getKey(), - targetUrl: PolicyResource::getUrl('view', ['record' => $policy], tenant: $policy->tenant), - targetKind: $rule->targetType, - priority: $rule->priority, - actionLabel: $this->labels->actionLabel($rule->relationKey), - contextBadge: 'Tenant', + return $this->resolveReferenceEntry( + rule: $rule, + descriptor: new ReferenceDescriptor( + referenceClass: ReferenceClass::Policy, + rawIdentifier: (string) $policy->getKey(), + tenantId: is_numeric($policy->tenant_id ?? null) ? (int) $policy->tenant_id : null, + sourceType: $rule->sourceType, + sourceSurface: $rule->sourceSurface, + fallbackLabel: (string) ($policy->display_name ?: 'Policy'), + linkedModelId: (int) $policy->getKey(), + ), ); } @@ -620,30 +573,24 @@ private function backupSetEntry( return null; } - $backupSet = BackupSet::query() - ->with('tenant') - ->whereKey($backupSetId) - ->where('tenant_id', $tenantId) - ->first(); + return $this->resolveReferenceEntry( + rule: $rule, + descriptor: new ReferenceDescriptor( + referenceClass: ReferenceClass::BackupSet, + rawIdentifier: (string) $backupSetId, + tenantId: $tenantId, + sourceType: $rule->sourceType, + sourceSurface: $rule->sourceSurface, + linkedModelId: $backupSetId, + ), + ); + } - if (! $backupSet instanceof BackupSet) { - return $this->unavailableEntry($rule, (string) $backupSetId, 'missing'); - } - - if (! $this->canOpenTenantRecord($backupSet->tenant, Capabilities::TENANT_VIEW)) { - return $this->unavailableEntry($rule, '#'.$backupSetId, 'unauthorized'); - } - - return RelatedContextEntry::available( - key: $rule->relationKey, - label: $this->labels->entryLabel($rule->relationKey), - value: (string) $backupSet->name, - secondaryValue: '#'.$backupSet->getKey(), - targetUrl: BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $backupSet->tenant), - targetKind: $rule->targetType, - priority: $rule->priority, - actionLabel: $this->labels->actionLabel($rule->relationKey), - contextBadge: 'Tenant', + private function resolveReferenceEntry(NavigationMatrixRule $rule, ReferenceDescriptor $descriptor): ?RelatedContextEntry + { + return $this->relatedContextReferenceAdapter->adapt( + rule: $rule, + reference: $this->referenceResolverRegistry->resolve($descriptor), ); } diff --git a/app/Support/References/Contracts/ReferenceResolver.php b/app/Support/References/Contracts/ReferenceResolver.php new file mode 100644 index 0000000..b48a2f2 --- /dev/null +++ b/app/Support/References/Contracts/ReferenceResolver.php @@ -0,0 +1,16 @@ + $context + */ + public function __construct( + public ReferenceClass $referenceClass, + public string $rawIdentifier, + public ?int $workspaceId = null, + public ?int $tenantId = null, + public ?string $sourceType = null, + public ?string $sourceSurface = null, + public ?string $fallbackLabel = null, + public ?int $linkedModelId = null, + public ?string $linkedModelType = null, + public array $context = [], + ) {} + + public function contextValue(string $key, mixed $default = null): mixed + { + return data_get($this->context, $key, $default); + } +} diff --git a/app/Support/References/ReferenceLinkTarget.php b/app/Support/References/ReferenceLinkTarget.php new file mode 100644 index 0000000..f935158 --- /dev/null +++ b/app/Support/References/ReferenceLinkTarget.php @@ -0,0 +1,28 @@ + $this->targetKind, + 'url' => $this->url, + 'actionLabel' => $this->actionLabel, + 'contextBadge' => $this->contextBadge, + ]; + } +} diff --git a/app/Support/References/ReferencePresentationVariant.php b/app/Support/References/ReferencePresentationVariant.php new file mode 100644 index 0000000..08d226c --- /dev/null +++ b/app/Support/References/ReferencePresentationVariant.php @@ -0,0 +1,11 @@ + $resolvers + */ + public function __construct( + iterable $resolvers, + private readonly ReferenceResolver $fallbackResolver, + ) { + foreach ($resolvers as $resolver) { + $this->resolvers[$resolver->referenceClass()->value] = $resolver; + } + } + + /** + * @var array + */ + private array $resolvers = []; + + public function resolverFor(ReferenceClass $referenceClass): ReferenceResolver + { + return $this->resolvers[$referenceClass->value] ?? $this->fallbackResolver; + } + + public function resolve(ReferenceDescriptor $descriptor): ResolvedReference + { + return $this->resolverFor($descriptor->referenceClass)->resolve($descriptor); + } +} diff --git a/app/Support/References/ReferenceStatePresenter.php b/app/Support/References/ReferenceStatePresenter.php new file mode 100644 index 0000000..4b9be4a --- /dev/null +++ b/app/Support/References/ReferenceStatePresenter.php @@ -0,0 +1,39 @@ +value : $state; + + return BadgeRenderer::spec(BadgeDomain::ReferenceResolutionState, $normalized); + } + + public function description(ResolvedReference $reference, string $typeLabel): ?string + { + $description = $reference->meta['state_description'] ?? null; + + if (is_string($description) && trim($description) !== '') { + return trim($description); + } + + $subject = mb_strtolower($typeLabel); + + return match ($reference->state) { + ReferenceResolutionState::Resolved => null, + ReferenceResolutionState::PartiallyResolved => "Showing the best {$subject} label available from local context.", + ReferenceResolutionState::Unresolved => "Only the preserved technical identifier is available for this {$subject}.", + ReferenceResolutionState::DeletedOrMissing => "The referenced {$subject} is no longer available locally.", + ReferenceResolutionState::Inaccessible => "The referenced {$subject} is not available in the current scope.", + ReferenceResolutionState::ExternalLimitedContext => "This {$subject} comes from provider evidence and only limited cached context is available.", + }; + } +} diff --git a/app/Support/References/ReferenceTechnicalDetail.php b/app/Support/References/ReferenceTechnicalDetail.php new file mode 100644 index 0000000..03d5030 --- /dev/null +++ b/app/Support/References/ReferenceTechnicalDetail.php @@ -0,0 +1,60 @@ + $this->displayId, + 'fullId' => $this->fullId, + 'sourceHint' => $this->sourceHint, + 'copyable' => $this->copyable, + 'defaultCollapsed' => $this->defaultCollapsed, + ]; + } + + private static function displayId(string $fullId): ?string + { + $normalized = preg_replace('/[^a-zA-Z0-9]/', '', $fullId); + + if (! is_string($normalized) || $normalized === '') { + return null; + } + + if (mb_strlen($normalized) <= 8) { + return $normalized; + } + + return '…'.mb_substr($normalized, -8); + } +} diff --git a/app/Support/References/ReferenceTypeLabelCatalog.php b/app/Support/References/ReferenceTypeLabelCatalog.php new file mode 100644 index 0000000..9bca1cc --- /dev/null +++ b/app/Support/References/ReferenceTypeLabelCatalog.php @@ -0,0 +1,35 @@ + + */ + private const LABELS = [ + ReferenceClass::Policy->value => 'Policy', + ReferenceClass::PolicyVersion->value => 'Policy version', + ReferenceClass::BaselineProfile->value => 'Baseline profile', + ReferenceClass::BaselineSnapshot->value => 'Baseline snapshot', + ReferenceClass::OperationRun->value => 'Operation run', + ReferenceClass::BackupSet->value => 'Backup set', + ReferenceClass::RoleDefinition->value => 'Role definition', + ReferenceClass::Principal->value => 'Principal', + ReferenceClass::Group->value => 'Group', + ReferenceClass::ServicePrincipal->value => 'Service principal', + ReferenceClass::TenantExternalObject->value => 'External object', + ReferenceClass::Unsupported->value => 'Reference', + ]; + + public function label(ReferenceClass|string $referenceClass): string + { + $value = $referenceClass instanceof ReferenceClass ? $referenceClass->value : $referenceClass; + + return self::LABELS[$value] ?? Str::headline(str_replace('_', ' ', $value)); + } +} diff --git a/app/Support/References/RelatedContextReferenceAdapter.php b/app/Support/References/RelatedContextReferenceAdapter.php new file mode 100644 index 0000000..f994acf --- /dev/null +++ b/app/Support/References/RelatedContextReferenceAdapter.php @@ -0,0 +1,33 @@ +missingStatePolicy === 'hide' && $reference->state->isDegraded()) { + return null; + } + + return RelatedContextEntry::fromResolvedReference( + key: $rule->relationKey, + label: $this->labels->entryLabel($rule->relationKey), + targetKind: $rule->targetType, + priority: $rule->priority, + actionLabel: $reference->linkTarget?->actionLabel ?? $this->labels->actionLabel($rule->relationKey), + reference: $this->presenter->present($reference, ReferencePresentationVariant::Detail), + ); + } +} diff --git a/app/Support/References/ResolvedReference.php b/app/Support/References/ResolvedReference.php new file mode 100644 index 0000000..447dfe7 --- /dev/null +++ b/app/Support/References/ResolvedReference.php @@ -0,0 +1,71 @@ + $meta + */ + public function __construct( + public ReferenceClass $referenceClass, + public string $rawIdentifier, + public string $primaryLabel, + public ?string $secondaryLabel, + public ReferenceResolutionState $state, + public ?string $stateLabel, + public ?ReferenceLinkTarget $linkTarget, + public ReferenceTechnicalDetail $technicalDetail, + public array $meta = [], + ) {} + + public function isLinkable(): bool + { + return $this->linkTarget instanceof ReferenceLinkTarget && $this->state->isLinkable(); + } + + public function withLinkTarget(?ReferenceLinkTarget $linkTarget): self + { + return new self( + referenceClass: $this->referenceClass, + rawIdentifier: $this->rawIdentifier, + primaryLabel: $this->primaryLabel, + secondaryLabel: $this->secondaryLabel, + state: $this->state, + stateLabel: $this->stateLabel, + linkTarget: $linkTarget, + technicalDetail: $this->technicalDetail, + meta: $this->meta, + ); + } + + /** + * @return array{ + * referenceClass: string, + * rawIdentifier: string, + * primaryLabel: string, + * secondaryLabel: ?string, + * state: string, + * stateLabel: ?string, + * linkTarget: array{targetKind: string, url: string, actionLabel: string, contextBadge: ?string}|null, + * technicalDetail: array{displayId: ?string, fullId: string, sourceHint: ?string, copyable: bool, defaultCollapsed: bool}, + * meta: array + * } + */ + public function toArray(): array + { + return [ + 'referenceClass' => $this->referenceClass->value, + 'rawIdentifier' => $this->rawIdentifier, + 'primaryLabel' => $this->primaryLabel, + 'secondaryLabel' => $this->secondaryLabel, + 'state' => $this->state->value, + 'stateLabel' => $this->stateLabel, + 'linkTarget' => $this->linkTarget?->toArray(), + 'technicalDetail' => $this->technicalDetail->toArray(), + 'meta' => $this->meta, + ]; + } +} diff --git a/app/Support/References/ResolvedReferencePresenter.php b/app/Support/References/ResolvedReferencePresenter.php new file mode 100644 index 0000000..e558dcc --- /dev/null +++ b/app/Support/References/ResolvedReferencePresenter.php @@ -0,0 +1,60 @@ + + * } + */ + public function present(ResolvedReference $reference, ReferencePresentationVariant $variant): array + { + $badge = $this->statePresenter->badgeSpec($reference->state); + $typeLabel = $this->typeLabels->label($reference->referenceClass); + + return [ + 'referenceClass' => $reference->referenceClass->value, + 'typeLabel' => $typeLabel, + 'primaryLabel' => $reference->primaryLabel, + 'secondaryLabel' => $reference->secondaryLabel, + 'state' => $reference->state->value, + 'stateLabel' => $reference->stateLabel ?? $badge->label, + 'stateColor' => $badge->color, + 'stateIcon' => $badge->icon, + 'stateIconColor' => $badge->iconColor, + 'stateDescription' => $this->statePresenter->description($reference, $typeLabel), + 'showStateBadge' => $reference->state->isDegraded(), + 'linkTarget' => $reference->linkTarget?->toArray(), + 'technicalDetail' => $reference->technicalDetail->toArray(), + 'isLinkable' => $reference->isLinkable(), + 'isDegraded' => $reference->state->isDegraded(), + 'variant' => $variant->value, + 'meta' => $reference->meta, + ]; + } +} diff --git a/app/Support/References/Resolvers/AssignmentTargetReferenceResolver.php b/app/Support/References/Resolvers/AssignmentTargetReferenceResolver.php new file mode 100644 index 0000000..aecd00e --- /dev/null +++ b/app/Support/References/Resolvers/AssignmentTargetReferenceResolver.php @@ -0,0 +1,97 @@ + $target + * @param array $context + */ + public function resolve(array $target, array $context = []): ResolvedReference + { + $targetType = strtolower((string) ($context['target_type'] ?? ($target['@odata.type'] ?? ''))); + $targetId = (string) ($context['target_id'] ?? ($target['groupId'] ?? $target['collectionId'] ?? '')); + + if (str_contains($targetType, 'alldevicesassignmenttarget')) { + return new ResolvedReference( + referenceClass: ReferenceClass::TenantExternalObject, + rawIdentifier: $targetId !== '' ? $targetId : 'all_devices', + primaryLabel: 'All devices', + secondaryLabel: 'Assignment target', + state: ReferenceResolutionState::Resolved, + stateLabel: null, + linkTarget: null, + technicalDetail: ReferenceTechnicalDetail::forIdentifier($targetId !== '' ? $targetId : 'all_devices', 'Captured from assignment evidence'), + ); + } + + if (str_contains($targetType, 'allusersassignmenttarget') || str_contains($targetType, 'alllicensedusersassignmenttarget')) { + return new ResolvedReference( + referenceClass: ReferenceClass::TenantExternalObject, + rawIdentifier: $targetId !== '' ? $targetId : 'all_users', + primaryLabel: 'All users', + secondaryLabel: 'Assignment target', + state: ReferenceResolutionState::Resolved, + stateLabel: null, + linkTarget: null, + technicalDetail: ReferenceTechnicalDetail::forIdentifier($targetId !== '' ? $targetId : 'all_users', 'Captured from assignment evidence'), + ); + } + + if (str_contains($targetType, 'groupassignmenttarget')) { + $groupDescriptions = is_array($context['group_descriptions'] ?? null) ? $context['group_descriptions'] : []; + $groupDescription = is_array($groupDescriptions[$targetId] ?? null) ? $groupDescriptions[$targetId] : []; + + return $this->registry->resolve(new ReferenceDescriptor( + referenceClass: ReferenceClass::Group, + rawIdentifier: $targetId, + tenantId: is_numeric($context['tenant_id'] ?? null) ? (int) $context['tenant_id'] : null, + fallbackLabel: is_string($groupDescription['display_name'] ?? null) + ? $groupDescription['display_name'] + : (is_string($target['group_display_name'] ?? null) ? $target['group_display_name'] : null), + context: [ + 'cached_display_name' => $groupDescription['display_name'] ?? null, + 'resolved_from_cache' => $groupDescription['resolved'] ?? null, + 'source_hint' => 'Captured from assignment evidence', + ], + )); + } + + if ($targetId !== '') { + return $this->registry->resolve(new ReferenceDescriptor( + referenceClass: ReferenceClass::Principal, + rawIdentifier: $targetId, + fallbackLabel: is_string($context['fallback_label'] ?? null) ? $context['fallback_label'] : null, + context: [ + 'principal_type' => $context['principal_type'] ?? $targetType, + 'secondary_label' => 'Assignment target', + 'source_hint' => 'Captured from assignment evidence', + ], + )); + } + + return $this->registry->resolve(new ReferenceDescriptor( + referenceClass: ReferenceClass::Unsupported, + rawIdentifier: 'assignment_target', + fallbackLabel: 'Assignment target', + context: [ + 'secondary_label' => 'Assignment target', + 'source_hint' => 'Captured from assignment evidence', + ], + )); + } +} diff --git a/app/Support/References/Resolvers/BackupSetReferenceResolver.php b/app/Support/References/Resolvers/BackupSetReferenceResolver.php new file mode 100644 index 0000000..d5e2854 --- /dev/null +++ b/app/Support/References/Resolvers/BackupSetReferenceResolver.php @@ -0,0 +1,77 @@ +linkedModelId($descriptor); + $tenantId = $descriptor->tenantId; + + if ($backupSetId === null || $tenantId === null || $tenantId <= 0) { + return $this->unresolved($descriptor); + } + + $backupSet = BackupSet::query() + ->with('tenant') + ->whereKey($backupSetId) + ->where('tenant_id', $tenantId) + ->first(); + + if (! $backupSet instanceof BackupSet) { + return $this->missing($descriptor); + } + + if (! $this->canOpenTenantRecord($backupSet->tenant, Capabilities::TENANT_VIEW)) { + return $this->inaccessible($descriptor); + } + + return $this->resolved( + descriptor: $descriptor, + primaryLabel: (string) $backupSet->name, + secondaryLabel: 'Backup set #'.$backupSet->getKey(), + linkTarget: new ReferenceLinkTarget( + targetKind: ReferenceClass::BackupSet->value, + url: BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $backupSet->tenant), + actionLabel: 'View backup set', + contextBadge: 'Tenant', + ), + ); + } + + private function canOpenTenantRecord(?Tenant $tenant, string $capability): bool + { + $user = auth()->user(); + + return $tenant instanceof Tenant + && $user instanceof User + && $this->capabilityResolver->isMember($user, $tenant) + && $this->capabilityResolver->can($user, $tenant, $capability); + } +} diff --git a/app/Support/References/Resolvers/BaseReferenceResolver.php b/app/Support/References/Resolvers/BaseReferenceResolver.php new file mode 100644 index 0000000..e80ea99 --- /dev/null +++ b/app/Support/References/Resolvers/BaseReferenceResolver.php @@ -0,0 +1,209 @@ +contextValue('source_hint')) ? $descriptor->contextValue('source_hint') : null; + } + + return ReferenceTechnicalDetail::forIdentifier( + fullId: $descriptor->rawIdentifier, + sourceHint: $hint, + ); + } + + /** + * @param array $meta + */ + protected function resolved( + ReferenceDescriptor $descriptor, + string $primaryLabel, + ?string $secondaryLabel = null, + ?ReferenceLinkTarget $linkTarget = null, + array $meta = [], + ): ResolvedReference { + return new ResolvedReference( + referenceClass: $descriptor->referenceClass, + rawIdentifier: $descriptor->rawIdentifier, + primaryLabel: $primaryLabel, + secondaryLabel: $secondaryLabel, + state: ReferenceResolutionState::Resolved, + stateLabel: null, + linkTarget: $linkTarget, + technicalDetail: $this->technicalDetail($descriptor), + meta: $meta, + ); + } + + /** + * @param array $meta + */ + protected function partiallyResolved( + ReferenceDescriptor $descriptor, + ?string $primaryLabel = null, + ?string $secondaryLabel = null, + ?ReferenceLinkTarget $linkTarget = null, + array $meta = [], + ): ResolvedReference { + return new ResolvedReference( + referenceClass: $descriptor->referenceClass, + rawIdentifier: $descriptor->rawIdentifier, + primaryLabel: $this->preferredLabel($descriptor, $primaryLabel), + secondaryLabel: $secondaryLabel, + state: ReferenceResolutionState::PartiallyResolved, + stateLabel: null, + linkTarget: $linkTarget, + technicalDetail: $this->technicalDetail($descriptor), + meta: $meta, + ); + } + + /** + * @param array $meta + */ + protected function externalLimited( + ReferenceDescriptor $descriptor, + ?string $primaryLabel = null, + ?string $secondaryLabel = null, + array $meta = [], + ): ResolvedReference { + return new ResolvedReference( + referenceClass: $descriptor->referenceClass, + rawIdentifier: $descriptor->rawIdentifier, + primaryLabel: $this->preferredLabel($descriptor, $primaryLabel), + secondaryLabel: $secondaryLabel, + state: ReferenceResolutionState::ExternalLimitedContext, + stateLabel: null, + linkTarget: null, + technicalDetail: $this->technicalDetail($descriptor), + meta: $meta, + ); + } + + /** + * @param array $meta + */ + protected function unresolved( + ReferenceDescriptor $descriptor, + ?string $primaryLabel = null, + ?string $secondaryLabel = null, + array $meta = [], + ): ResolvedReference { + return new ResolvedReference( + referenceClass: $descriptor->referenceClass, + rawIdentifier: $descriptor->rawIdentifier, + primaryLabel: $this->preferredLabel($descriptor, $primaryLabel), + secondaryLabel: $secondaryLabel, + state: ReferenceResolutionState::Unresolved, + stateLabel: null, + linkTarget: null, + technicalDetail: $this->technicalDetail($descriptor), + meta: $meta, + ); + } + + /** + * @param array $meta + */ + protected function missing( + ReferenceDescriptor $descriptor, + ?string $primaryLabel = null, + ?string $secondaryLabel = null, + array $meta = [], + ): ResolvedReference { + return new ResolvedReference( + referenceClass: $descriptor->referenceClass, + rawIdentifier: $descriptor->rawIdentifier, + primaryLabel: $this->preferredLabel($descriptor, $primaryLabel), + secondaryLabel: $secondaryLabel, + state: ReferenceResolutionState::DeletedOrMissing, + stateLabel: null, + linkTarget: null, + technicalDetail: $this->technicalDetail($descriptor), + meta: $meta, + ); + } + + /** + * @param array $meta + */ + protected function inaccessible( + ReferenceDescriptor $descriptor, + ?string $primaryLabel = null, + ?string $secondaryLabel = null, + array $meta = [], + ): ResolvedReference { + return new ResolvedReference( + referenceClass: $descriptor->referenceClass, + rawIdentifier: $descriptor->rawIdentifier, + primaryLabel: $this->preferredLabel($descriptor, $primaryLabel, revealFallback: false), + secondaryLabel: $secondaryLabel, + state: ReferenceResolutionState::Inaccessible, + stateLabel: null, + linkTarget: null, + technicalDetail: $this->technicalDetail($descriptor), + meta: $meta, + ); + } + + protected function preferredLabel( + ReferenceDescriptor $descriptor, + ?string $label = null, + bool $revealFallback = true, + ): string { + if (is_string($label) && trim($label) !== '') { + return trim($label); + } + + if ($revealFallback && is_string($descriptor->fallbackLabel) && trim($descriptor->fallbackLabel) !== '') { + return trim($descriptor->fallbackLabel); + } + + return $this->typeLabels->label($descriptor->referenceClass); + } + + protected function linkedModelId(ReferenceDescriptor $descriptor): ?int + { + if (is_numeric($descriptor->linkedModelId) && (int) $descriptor->linkedModelId > 0) { + return (int) $descriptor->linkedModelId; + } + + return is_numeric($descriptor->rawIdentifier) && (int) $descriptor->rawIdentifier > 0 + ? (int) $descriptor->rawIdentifier + : null; + } + + protected function numericContextId(ReferenceDescriptor $descriptor, string $key): ?int + { + $value = $descriptor->contextValue($key); + + return is_numeric($value) && (int) $value > 0 ? (int) $value : null; + } + + protected function contextString(ReferenceDescriptor $descriptor, string $key): ?string + { + $value = $descriptor->contextValue($key); + + return is_string($value) && trim($value) !== '' ? trim($value) : null; + } +} diff --git a/app/Support/References/Resolvers/BaselineProfileReferenceResolver.php b/app/Support/References/Resolvers/BaselineProfileReferenceResolver.php new file mode 100644 index 0000000..7b437fa --- /dev/null +++ b/app/Support/References/Resolvers/BaselineProfileReferenceResolver.php @@ -0,0 +1,84 @@ +linkedModelId($descriptor); + $workspaceId = $descriptor->workspaceId; + + if ($profileId === null || $workspaceId === null || $workspaceId <= 0) { + return $this->unresolved($descriptor); + } + + $profile = BaselineProfile::query() + ->whereKey($profileId) + ->where('workspace_id', $workspaceId) + ->first(); + + if (! $profile instanceof BaselineProfile) { + return $this->missing($descriptor); + } + + if (! $this->canOpenWorkspaceBaselines((int) $profile->workspace_id)) { + return $this->inaccessible($descriptor); + } + + return $this->resolved( + descriptor: $descriptor, + primaryLabel: (string) $profile->name, + secondaryLabel: 'Baseline profile #'.$profile->getKey(), + linkTarget: new ReferenceLinkTarget( + targetKind: ReferenceClass::BaselineProfile->value, + url: BaselineProfileResource::getUrl('view', ['record' => $profile], panel: 'admin'), + actionLabel: 'View baseline profile', + contextBadge: 'Workspace', + ), + ); + } + + private function canOpenWorkspaceBaselines(int $workspaceId): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return false; + } + + return $this->workspaceCapabilityResolver->isMember($user, $workspace) + && $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW); + } +} diff --git a/app/Support/References/Resolvers/BaselineSnapshotReferenceResolver.php b/app/Support/References/Resolvers/BaselineSnapshotReferenceResolver.php new file mode 100644 index 0000000..86e7d1e --- /dev/null +++ b/app/Support/References/Resolvers/BaselineSnapshotReferenceResolver.php @@ -0,0 +1,87 @@ +linkedModelId($descriptor); + $workspaceId = $descriptor->workspaceId; + + if ($snapshotId === null || $workspaceId === null || $workspaceId <= 0) { + return $this->unresolved($descriptor); + } + + $snapshot = BaselineSnapshot::query() + ->with('baselineProfile') + ->whereKey($snapshotId) + ->where('workspace_id', $workspaceId) + ->first(); + + if (! $snapshot instanceof BaselineSnapshot) { + return $this->missing($descriptor); + } + + if (! $this->canOpenWorkspaceBaselines((int) $snapshot->workspace_id)) { + return $this->inaccessible($descriptor); + } + + $profileName = $snapshot->baselineProfile?->name; + + return $this->resolved( + descriptor: $descriptor, + primaryLabel: is_string($profileName) && trim($profileName) !== '' ? $profileName : 'Baseline snapshot', + secondaryLabel: 'Baseline snapshot #'.$snapshot->getKey(), + linkTarget: new ReferenceLinkTarget( + targetKind: ReferenceClass::BaselineSnapshot->value, + url: BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin'), + actionLabel: 'View snapshot', + contextBadge: 'Workspace', + ), + ); + } + + private function canOpenWorkspaceBaselines(int $workspaceId): bool + { + $user = auth()->user(); + + if (! $user instanceof User) { + return false; + } + + $workspace = Workspace::query()->whereKey($workspaceId)->first(); + + if (! $workspace instanceof Workspace) { + return false; + } + + return $this->workspaceCapabilityResolver->isMember($user, $workspace) + && $this->workspaceCapabilityResolver->can($user, $workspace, Capabilities::WORKSPACE_BASELINES_VIEW); + } +} diff --git a/app/Support/References/Resolvers/EntraGroupReferenceResolver.php b/app/Support/References/Resolvers/EntraGroupReferenceResolver.php new file mode 100644 index 0000000..0a7db3e --- /dev/null +++ b/app/Support/References/Resolvers/EntraGroupReferenceResolver.php @@ -0,0 +1,116 @@ +tenantId; + + if ($tenantId === null || $tenantId <= 0) { + return $this->unresolved($descriptor, primaryLabel: 'Group'); + } + + $tenant = Tenant::query()->whereKey($tenantId)->first(); + + if (! $tenant instanceof Tenant) { + return $this->unresolved($descriptor, primaryLabel: 'Group'); + } + + $cachedDisplayName = $this->contextString($descriptor, 'cached_display_name'); + $resolvedFromCache = $descriptor->contextValue('resolved_from_cache'); + + $group = EntraGroup::query() + ->where('tenant_id', $tenantId) + ->where('entra_id', strtolower($descriptor->rawIdentifier)) + ->first(); + + if (! $group instanceof EntraGroup && $cachedDisplayName === null) { + $cachedDisplayName = $this->groupLabelResolver->lookupOne($tenant, $descriptor->rawIdentifier); + $resolvedFromCache = $cachedDisplayName !== null; + } + + if ($group instanceof EntraGroup) { + if (! $this->canOpenTenantRecord($group->tenant, Capabilities::TENANT_VIEW)) { + return $this->inaccessible($descriptor, primaryLabel: 'Group'); + } + + return $this->resolved( + descriptor: $descriptor, + primaryLabel: (string) $group->display_name, + secondaryLabel: $this->groupTypeLabel($group), + linkTarget: new ReferenceLinkTarget( + targetKind: ReferenceClass::Group->value, + url: EntraGroupResource::getUrl('view', ['record' => $group], tenant: $group->tenant), + actionLabel: 'View group', + contextBadge: 'Tenant', + ), + ); + } + + if ($cachedDisplayName !== null) { + return ($resolvedFromCache === true) + ? $this->partiallyResolved($descriptor, primaryLabel: $cachedDisplayName, secondaryLabel: 'Cached group') + : $this->externalLimited($descriptor, primaryLabel: $cachedDisplayName, secondaryLabel: 'Captured group'); + } + + return $this->unresolved($descriptor, primaryLabel: 'Group'); + } + + private function canOpenTenantRecord(?Tenant $tenant, string $capability): bool + { + $user = auth()->user(); + + return $tenant instanceof Tenant + && $user instanceof User + && $this->capabilityResolver->isMember($user, $tenant) + && $this->capabilityResolver->can($user, $tenant, $capability); + } + + private function groupTypeLabel(EntraGroup $group): string + { + $groupTypes = $group->group_types; + + if (is_array($groupTypes) && in_array('Unified', $groupTypes, true)) { + return 'Microsoft 365 group'; + } + + if ($group->security_enabled) { + return 'Security group'; + } + + if ($group->mail_enabled) { + return 'Mail-enabled group'; + } + + return 'Directory group'; + } +} diff --git a/app/Support/References/Resolvers/EntraRoleDefinitionReferenceResolver.php b/app/Support/References/Resolvers/EntraRoleDefinitionReferenceResolver.php new file mode 100644 index 0000000..4621086 --- /dev/null +++ b/app/Support/References/Resolvers/EntraRoleDefinitionReferenceResolver.php @@ -0,0 +1,54 @@ +tenantId; + + if ($tenantId === null || $tenantId <= 0) { + return $this->unresolved($descriptor, primaryLabel: 'Role definition'); + } + + $roleDefinition = EntraRoleDefinition::query() + ->where('tenant_id', $tenantId) + ->where('entra_id', $descriptor->rawIdentifier) + ->first(); + + if ($roleDefinition instanceof EntraRoleDefinition) { + return $this->resolved( + descriptor: $descriptor, + primaryLabel: (string) $roleDefinition->display_name, + secondaryLabel: $roleDefinition->is_built_in ? 'Built-in role definition' : 'Custom role definition', + ); + } + + $fallback = $descriptor->fallbackLabel; + + if (is_string($fallback) && trim($fallback) !== '') { + return $this->externalLimited($descriptor, primaryLabel: $fallback, secondaryLabel: 'Captured role definition'); + } + + return $this->unresolved($descriptor, primaryLabel: 'Role definition'); + } +} diff --git a/app/Support/References/Resolvers/FallbackReferenceResolver.php b/app/Support/References/Resolvers/FallbackReferenceResolver.php new file mode 100644 index 0000000..651e695 --- /dev/null +++ b/app/Support/References/Resolvers/FallbackReferenceResolver.php @@ -0,0 +1,51 @@ +fallbackLabel; + $primaryLabel = is_string($primaryLabel) && trim($primaryLabel) !== '' + ? trim($primaryLabel) + : 'Unresolved reference'; + + $state = $descriptor->fallbackLabel !== null + ? ReferenceResolutionState::PartiallyResolved + : ReferenceResolutionState::Unresolved; + + return new ResolvedReference( + referenceClass: $descriptor->referenceClass, + rawIdentifier: $descriptor->rawIdentifier, + primaryLabel: $primaryLabel, + secondaryLabel: $descriptor->contextValue('secondary_label'), + state: $state, + stateLabel: null, + linkTarget: null, + technicalDetail: ReferenceTechnicalDetail::forIdentifier( + fullId: $descriptor->rawIdentifier, + sourceHint: is_string($descriptor->contextValue('source_hint')) + ? $descriptor->contextValue('source_hint') + : null, + ), + meta: [ + 'state_description' => $descriptor->contextValue('state_description'), + ], + ); + } +} diff --git a/app/Support/References/Resolvers/OperationRunReferenceResolver.php b/app/Support/References/Resolvers/OperationRunReferenceResolver.php new file mode 100644 index 0000000..874613a --- /dev/null +++ b/app/Support/References/Resolvers/OperationRunReferenceResolver.php @@ -0,0 +1,74 @@ +linkedModelId($descriptor); + $workspaceId = $descriptor->workspaceId; + + if ($runId === null || $workspaceId === null || $workspaceId <= 0) { + return $this->unresolved($descriptor); + } + + $run = OperationRun::query() + ->whereKey($runId) + ->where('workspace_id', $workspaceId) + ->first(); + + if (! $run instanceof OperationRun) { + return $this->missing($descriptor); + } + + if (! $this->canOpenOperationRun($run)) { + return $this->inaccessible($descriptor); + } + + $context = $descriptor->contextValue('navigation_context'); + $navigationContext = $context instanceof CanonicalNavigationContext ? $context : null; + + return $this->resolved( + descriptor: $descriptor, + primaryLabel: OperationCatalog::label((string) $run->type), + secondaryLabel: 'Run #'.$run->getKey(), + linkTarget: new ReferenceLinkTarget( + targetKind: ReferenceClass::OperationRun->value, + url: OperationRunLinks::tenantlessView($run, $navigationContext), + actionLabel: 'View run', + contextBadge: $run->tenant_id ? 'Tenant context' : 'Workspace', + ), + ); + } + + private function canOpenOperationRun(OperationRun $run): bool + { + $user = auth()->user(); + + return $user instanceof User && $user->can('view', $run); + } +} diff --git a/app/Support/References/Resolvers/PolicyReferenceResolver.php b/app/Support/References/Resolvers/PolicyReferenceResolver.php new file mode 100644 index 0000000..d279f1b --- /dev/null +++ b/app/Support/References/Resolvers/PolicyReferenceResolver.php @@ -0,0 +1,77 @@ +linkedModelId($descriptor); + $tenantId = $descriptor->tenantId; + + if ($policyId === null || $tenantId === null || $tenantId <= 0) { + return $this->unresolved($descriptor); + } + + $policy = Policy::query() + ->with('tenant') + ->whereKey($policyId) + ->where('tenant_id', $tenantId) + ->first(); + + if (! $policy instanceof Policy) { + return $this->missing($descriptor); + } + + if (! $this->canOpenTenantRecord($policy->tenant, Capabilities::TENANT_VIEW)) { + return $this->inaccessible($descriptor); + } + + return $this->resolved( + descriptor: $descriptor, + primaryLabel: (string) ($policy->display_name ?: 'Policy'), + secondaryLabel: 'Policy #'.$policy->getKey(), + linkTarget: new ReferenceLinkTarget( + targetKind: ReferenceClass::Policy->value, + url: PolicyResource::getUrl('view', ['record' => $policy], tenant: $policy->tenant), + actionLabel: 'View policy', + contextBadge: 'Tenant', + ), + ); + } + + private function canOpenTenantRecord(?Tenant $tenant, string $capability): bool + { + $user = auth()->user(); + + return $tenant instanceof Tenant + && $user instanceof User + && $this->capabilityResolver->isMember($user, $tenant) + && $this->capabilityResolver->can($user, $tenant, $capability); + } +} diff --git a/app/Support/References/Resolvers/PolicyVersionReferenceResolver.php b/app/Support/References/Resolvers/PolicyVersionReferenceResolver.php new file mode 100644 index 0000000..03a6843 --- /dev/null +++ b/app/Support/References/Resolvers/PolicyVersionReferenceResolver.php @@ -0,0 +1,103 @@ +linkedModelId($descriptor); + $tenantId = $descriptor->tenantId; + + if ($policyVersionId === null || $tenantId === null || $tenantId <= 0) { + return $this->unresolved($descriptor); + } + + $version = PolicyVersion::query() + ->with(['policy', 'tenant']) + ->whereKey($policyVersionId) + ->where('tenant_id', $tenantId) + ->first(); + + if (! $version instanceof PolicyVersion) { + return $this->missing($descriptor); + } + + if (! $this->canOpenPolicyVersion($version)) { + return $this->inaccessible($descriptor); + } + + $policyName = $version->policy?->display_name; + $secondary = 'Version '.(string) $version->version_number; + + if (is_string($version->capture_purpose?->value) && $version->capture_purpose->value !== '') { + $secondary .= ' · '.str_replace('_', ' ', $version->capture_purpose->value); + } + + return $this->resolved( + descriptor: $descriptor, + primaryLabel: is_string($policyName) && trim($policyName) !== '' ? $policyName : 'Policy version', + secondaryLabel: $secondary, + linkTarget: new ReferenceLinkTarget( + targetKind: ReferenceClass::PolicyVersion->value, + url: PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $version->tenant), + actionLabel: 'View policy version', + contextBadge: 'Tenant', + ), + ); + } + + private function canOpenPolicyVersion(PolicyVersion $version): bool + { + $tenant = $version->tenant; + + if (! $tenant instanceof Tenant || ! $this->canOpenTenantRecord($tenant, Capabilities::TENANT_VIEW)) { + return false; + } + + if (in_array((string) $version->capture_purpose?->value, ['baseline_capture', 'baseline_compare'], true)) { + $user = auth()->user(); + + return $user instanceof User + && $this->capabilityResolver->isMember($user, $tenant) + && $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_SYNC); + } + + return true; + } + + private function canOpenTenantRecord(?Tenant $tenant, string $capability): bool + { + $user = auth()->user(); + + return $tenant instanceof Tenant + && $user instanceof User + && $this->capabilityResolver->isMember($user, $tenant) + && $this->capabilityResolver->can($user, $tenant, $capability); + } +} diff --git a/app/Support/References/Resolvers/PrincipalReferenceResolver.php b/app/Support/References/Resolvers/PrincipalReferenceResolver.php new file mode 100644 index 0000000..bbb2bb1 --- /dev/null +++ b/app/Support/References/Resolvers/PrincipalReferenceResolver.php @@ -0,0 +1,36 @@ +contextString($descriptor, 'principal_type'); + $secondaryLabel = $principalType !== null ? Str::headline(str_replace('_', ' ', $principalType)) : 'Principal'; + + if (is_string($descriptor->fallbackLabel) && trim($descriptor->fallbackLabel) !== '') { + return $this->externalLimited($descriptor, primaryLabel: $descriptor->fallbackLabel, secondaryLabel: $secondaryLabel); + } + + return $this->unresolved($descriptor, primaryLabel: $secondaryLabel, secondaryLabel: $secondaryLabel); + } +} diff --git a/resources/views/filament/infolists/entries/assignments-diff.blade.php b/resources/views/filament/infolists/entries/assignments-diff.blade.php index 1118937..6567063 100644 --- a/resources/views/filament/infolists/entries/assignments-diff.blade.php +++ b/resources/views/filament/infolists/entries/assignments-diff.blade.php @@ -10,6 +10,7 @@ return [ 'include_exclude' => (string) ($row['include_exclude'] ?? 'include'), 'target_label' => (string) ($row['target_label'] ?? 'Unknown target'), + 'target_reference' => is_array($row['target_reference'] ?? null) ? $row['target_reference'] : null, 'filter_type' => (string) ($row['filter_type'] ?? 'none'), 'filter_id' => $row['filter_id'] ?? null, 'intent' => $row['intent'] ?? null, @@ -52,9 +53,13 @@ @endphp
-
- {{ $to['target_label'] }} -
+ @if (is_array($to['target_reference'])) + @include('filament.infolists.entries.resolved-reference-compact', ['reference' => $to['target_reference']]) + @else +
+ {{ $to['target_label'] }} +
+ @endif
@@ -85,7 +90,11 @@ @php $row = $renderRow(is_array($row) ? $row : []); @endphp
-
{{ $row['target_label'] }}
+ @if (is_array($row['target_reference'])) + @include('filament.infolists.entries.resolved-reference-compact', ['reference' => $row['target_reference']]) + @else +
{{ $row['target_label'] }}
+ @endif
{{ $row['include_exclude'] }} · filter: {{ $row['filter_type'] }}
@@ -102,7 +111,11 @@ @php $row = $renderRow(is_array($row) ? $row : []); @endphp
-
{{ $row['target_label'] }}
+ @if (is_array($row['target_reference'])) + @include('filament.infolists.entries.resolved-reference-compact', ['reference' => $row['target_reference']]) + @else +
{{ $row['target_label'] }}
+ @endif
{{ $row['include_exclude'] }} · filter: {{ $row['filter_type'] }}
diff --git a/resources/views/filament/infolists/entries/related-context.blade.php b/resources/views/filament/infolists/entries/related-context.blade.php index b9d5530..be58338 100644 --- a/resources/views/filament/infolists/entries/related-context.blade.php +++ b/resources/views/filament/infolists/entries/related-context.blade.php @@ -11,60 +11,67 @@ @foreach ($entries as $entry) @php $isAvailable = ($entry['availability'] ?? null) === 'available' && filled($entry['targetUrl'] ?? null); + $reference = is_array($entry['reference'] ?? null) ? $entry['reference'] : null; @endphp
-
-
-
- {{ $entry['label'] ?? 'Related record' }} +
+
+ {{ $entry['label'] ?? 'Related record' }} +
+ + @if ($reference !== null) + @include('filament.infolists.entries.resolved-reference-detail', ['reference' => $reference]) + @else +
+
+ @if ($isAvailable) + + {{ $entry['value'] ?? 'Open related record' }} + + @else +
+ {{ $entry['value'] ?? 'Unavailable' }} +
+ @endif + + @if (filled($entry['secondaryValue'] ?? null)) +
+ {{ $entry['secondaryValue'] }} +
+ @endif + + @if (filled($entry['unavailableReason'] ?? null)) +
+ {{ $entry['unavailableReason'] }} +
+ @endif +
+ +
+ @if ($isAvailable && filled($entry['actionLabel'] ?? null)) + + {{ $entry['actionLabel'] }} + + @endif + + @if (filled($entry['contextBadge'] ?? null)) + + {{ $entry['contextBadge'] }} + + @endif + + @unless ($isAvailable) + + Unavailable + + @endunless +
- - @if ($isAvailable) - - {{ $entry['value'] ?? 'Open related record' }} - - @else -
- {{ $entry['value'] ?? 'Unavailable' }} -
- @endif - - @if (filled($entry['secondaryValue'] ?? null)) -
- {{ $entry['secondaryValue'] }} -
- @endif - - @if (filled($entry['unavailableReason'] ?? null)) -
- {{ $entry['unavailableReason'] }} -
- @endif -
- -
- @if ($isAvailable && filled($entry['actionLabel'] ?? null)) - - {{ $entry['actionLabel'] }} - - @endif - - @if (filled($entry['contextBadge'] ?? null)) - - {{ $entry['contextBadge'] }} - - @endif - - @unless ($isAvailable) - - Unavailable - - @endunless -
+ @endif
@endforeach diff --git a/resources/views/filament/infolists/entries/resolved-reference-compact.blade.php b/resources/views/filament/infolists/entries/resolved-reference-compact.blade.php new file mode 100644 index 0000000..b5de09e --- /dev/null +++ b/resources/views/filament/infolists/entries/resolved-reference-compact.blade.php @@ -0,0 +1,34 @@ +
+ @if (($reference['isLinkable'] ?? false) === true && filled(data_get($reference, 'linkTarget.url'))) + + {{ $reference['primaryLabel'] ?? 'Reference' }} + + @else +
+ {{ $reference['primaryLabel'] ?? 'Reference' }} +
+ @endif + +
+ @if (filled($reference['secondaryLabel'] ?? null)) + {{ $reference['secondaryLabel'] }} + @endif + + @if ((data_get($reference, 'showStateBadge', false)) === true) + + {{ data_get($reference, 'stateLabel', 'Unknown') }} + + @endif + + @if (filled(data_get($reference, 'technicalDetail.displayId'))) + ID {{ data_get($reference, 'technicalDetail.displayId') }} + @endif +
+
diff --git a/resources/views/filament/infolists/entries/resolved-reference-detail.blade.php b/resources/views/filament/infolists/entries/resolved-reference-detail.blade.php new file mode 100644 index 0000000..023eee1 --- /dev/null +++ b/resources/views/filament/infolists/entries/resolved-reference-detail.blade.php @@ -0,0 +1,65 @@ +
+
+ @if (($reference['isLinkable'] ?? false) === true && filled(data_get($reference, 'linkTarget.url'))) + + {{ $reference['primaryLabel'] ?? 'Reference' }} + + @else +
+ {{ $reference['primaryLabel'] ?? 'Reference' }} +
+ @endif + + @if (filled($reference['secondaryLabel'] ?? null)) +
+ {{ $reference['secondaryLabel'] }} +
+ @endif + + @if (filled($reference['stateDescription'] ?? null)) +
+ {{ $reference['stateDescription'] }} +
+ @endif + + @if (filled(data_get($reference, 'technicalDetail.fullId'))) +
+ ID: {{ data_get($reference, 'technicalDetail.displayId') ?: data_get($reference, 'technicalDetail.fullId') }} + + @if (filled(data_get($reference, 'technicalDetail.sourceHint'))) + {{ data_get($reference, 'technicalDetail.sourceHint') }} + @endif +
+ @endif +
+ +
+ @if ((data_get($reference, 'showStateBadge', false)) === true) + + {{ data_get($reference, 'stateLabel', 'Unknown') }} + + @endif + + @if (filled(data_get($reference, 'linkTarget.contextBadge'))) + + {{ data_get($reference, 'linkTarget.contextBadge') }} + + @endif + + @if (($reference['isLinkable'] ?? false) === true && filled(data_get($reference, 'linkTarget.actionLabel'))) + + {{ data_get($reference, 'linkTarget.actionLabel') }} + + @endif +
+
diff --git a/resources/views/livewire/policy-version-assignments-widget.blade.php b/resources/views/livewire/policy-version-assignments-widget.blade.php index 65006bf..3f51311 100644 --- a/resources/views/livewire/policy-version-assignments-widget.blade.php +++ b/resources/views/livewire/policy-version-assignments-widget.blade.php @@ -65,27 +65,20 @@ {{ $typeName }} - @if($groupId) - @php - $groupLabel = $groupLabels[$groupId] ?? \App\Services\Directory\EntraGroupLabelResolver::formatLabel( - is_string($groupName) ? $groupName : null, - (string) $groupId, - ); - @endphp + @php + $targetReference = $assignmentReferences[$loop->index] ?? null; + @endphp + + @if(is_array($targetReference)) : - @if($groupOrphaned) - - ⚠️ {{ $groupLabel }} - - @elseif($groupLabel) - - {{ $groupLabel }} - - @else - - {{ $groupId }} - - @endif +
+ @include('filament.infolists.entries.resolved-reference-compact', ['reference' => $targetReference]) +
+ @elseif($groupId) + : + + {{ $groupName ?: $groupId }} + @endif @if($filterLabel) @@ -104,12 +97,20 @@ @else @php + $usesSeparateRoleAssignments = ($version->policy_type ?? null) === 'intuneRoleDefinition'; $assignmentsFetched = $version->metadata['assignments_fetched'] ?? false; $assignmentsFetchFailed = $version->metadata['assignments_fetch_failed'] ?? false; $assignmentsFetchError = $version->metadata['assignments_fetch_error'] ?? null; @endphp - @if($assignmentsFetchFailed) + @if($usesSeparateRoleAssignments) +

+ Standard policy assignments do not apply to Intune RBAC role definitions. +

+

+ Role memberships and scope are modeled separately as Intune RBAC role assignments. +

+ @elseif($assignmentsFetchFailed)

Assignments could not be fetched from Microsoft Graph.

diff --git a/specs/132-guid-context-resolver/checklists/requirements.md b/specs/132-guid-context-resolver/checklists/requirements.md new file mode 100644 index 0000000..41383dc --- /dev/null +++ b/specs/132-guid-context-resolver/checklists/requirements.md @@ -0,0 +1,36 @@ +# Specification Quality Checklist: GUID Context Resolver & Human-Readable Reference Presentation + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-03-10 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Validation pass completed on 2026-03-10. +- No unresolved clarification markers remain. +- Spec is ready for `/speckit.plan`. \ No newline at end of file diff --git a/specs/132-guid-context-resolver/contracts/reference-presentation.openapi.yaml b/specs/132-guid-context-resolver/contracts/reference-presentation.openapi.yaml new file mode 100644 index 0000000..241a2e1 --- /dev/null +++ b/specs/132-guid-context-resolver/contracts/reference-presentation.openapi.yaml @@ -0,0 +1,248 @@ +openapi: 3.1.0 +info: + title: Reference Presentation Contract + version: 0.1.0 + description: >- + Internal contract for the shared reference-resolution and presentation layer used by + existing Filament resources, infolists, related-context sections, and dense list surfaces. + This feature introduces no new public HTTP API; the contract formalizes the payloads, + states, and canonical-link rules required for label-first reference rendering. +paths: {} +components: + schemas: + ReferenceDescriptor: + type: object + required: + - referenceClass + - rawIdentifier + properties: + referenceClass: + type: string + enum: + - policy + - policy_version + - baseline_profile + - baseline_snapshot + - operation_run + - backup_set + - role_definition + - principal + - group + - service_principal + - tenant_external_object + - unsupported + rawIdentifier: + type: string + example: 8df3de3c-6287-44a4-b303-5d6d0a3d1c55 + workspaceId: + type: + - integer + - 'null' + tenantId: + type: + - integer + - 'null' + sourceType: + type: + - string + - 'null' + example: finding + sourceSurface: + type: + - string + - 'null' + enum: + - detail_section + - detail_header + - list_row + - assignment_block + fallbackLabel: + type: + - string + - 'null' + example: Global Administrator + linkedModelType: + type: + - string + - 'null' + example: App\Models\PolicyVersion + linkedModelId: + type: + - integer + - 'null' + context: + type: object + additionalProperties: true + + ResolvedReference: + type: object + required: + - referenceClass + - rawIdentifier + - primaryLabel + - state + - technicalDetail + properties: + referenceClass: + type: string + example: policy_version + rawIdentifier: + type: string + example: pv_1829 + primaryLabel: + type: string + example: Windows Security Baseline v12 + secondaryLabel: + type: + - string + - 'null' + example: Policy version + state: + type: string + enum: + - resolved + - partially_resolved + - unresolved + - deleted_or_missing + - inaccessible + - external_limited_context + stateLabel: + type: + - string + - 'null' + example: Partially resolved + linkTarget: + $ref: '#/components/schemas/ReferenceLinkTarget' + technicalDetail: + $ref: '#/components/schemas/ReferenceTechnicalDetail' + meta: + type: object + additionalProperties: true + + ReferenceLinkTarget: + type: + - object + - 'null' + required: + - targetKind + - url + - actionLabel + properties: + targetKind: + type: string + example: operation_run + url: + type: string + format: uri-reference + example: /admin/operations/482 + actionLabel: + type: string + example: View run + contextBadge: + type: + - string + - 'null' + example: Tenant context + + ReferenceTechnicalDetail: + type: object + required: + - fullId + - copyable + - defaultCollapsed + properties: + displayId: + type: + - string + - 'null' + example: …0a3d1c55 + fullId: + type: string + example: 8df3de3c-6287-44a4-b303-5d6d0a3d1c55 + sourceHint: + type: + - string + - 'null' + example: Captured from baseline evidence + copyable: + type: boolean + default: true + defaultCollapsed: + type: boolean + default: true + + ReferencePresentationBlock: + type: object + required: + - variant + - references + properties: + variant: + type: string + enum: + - compact + - detail + title: + type: + - string + - 'null' + example: Related context + references: + type: array + items: + $ref: '#/components/schemas/ResolvedReference' + emptyMessage: + type: + - string + - 'null' + example: No related context is available for this record. + + ReferenceResolverRegistration: + type: object + required: + - referenceClass + - resolver + - supportsLinking + - supportsPartialResolution + properties: + referenceClass: + type: string + example: group + resolver: + type: string + example: EntraGroupReferenceResolver + supportsLinking: + type: boolean + supportsPartialResolution: + type: boolean + supportedSurfaces: + type: array + items: + type: string + enum: + - detail_section + - detail_header + - list_row + - assignment_block + + ReferenceDegradedState: + type: object + required: + - state + - operatorMessage + - retainTechnicalDetail + properties: + state: + type: string + enum: + - unresolved + - deleted_or_missing + - inaccessible + - external_limited_context + - partially_resolved + operatorMessage: + type: string + example: This group reference is no longer available in the current tenant context. + retainTechnicalDetail: + type: boolean + default: true \ No newline at end of file diff --git a/specs/132-guid-context-resolver/data-model.md b/specs/132-guid-context-resolver/data-model.md new file mode 100644 index 0000000..b3be8a5 --- /dev/null +++ b/specs/132-guid-context-resolver/data-model.md @@ -0,0 +1,123 @@ +# Data Model: GUID Context Resolver & Human-Readable Reference Presentation + +## Overview + +This feature adds no new database tables. It introduces an application-layer reference model that normalizes how user-visible references are described, resolved, and rendered across Filament resources and related-context sections. + +## Entities + +### 1. ReferenceDescriptor + +Represents the structured input passed into the shared resolver layer. + +| Field | Type | Required | Notes | +|------|------|----------|------| +| `referenceClass` | string | Yes | Explicit class such as `policy`, `policy_version`, `baseline_snapshot`, `operation_run`, `group`, `role_definition`, or `unsupported`. | +| `rawIdentifier` | string | Yes | Canonical raw ID, GUID, key, or source token preserved as evidence. | +| `workspaceId` | int\|null | No | Required when the target is workspace-owned or when workspace-scoped authorization is needed. | +| `tenantId` | int\|null | No | Required when the target is tenant-owned or provider-backed in tenant scope. | +| `sourceType` | string\|null | No | Calling surface or domain source, such as `finding`, `baseline_snapshot`, or `assignment_target`. | +| `sourceSurface` | string\|null | No | Rendering context such as `detail_section`, `detail_header`, or `list_row`. | +| `fallbackLabel` | string\|null | No | Safe operator-facing label derived from source payload when authoritative lookup is unavailable. | +| `linkedModelId` | int\|null | No | Optional internal record ID when the caller already knows the local model. | +| `linkedModelType` | string\|null | No | Optional model class or logical model name paired with `linkedModelId`. | +| `context` | array | No | Safe source metadata used for best-effort enrichment and target construction. | + +Validation rules: +- `referenceClass` must be from the supported registry or explicitly marked `unsupported`. +- `rawIdentifier` must be non-empty even when `linkedModelId` is present so technical detail is preserved. +- `context` must contain only safe, serializable values; no secrets or raw provider payload dumps. + +### 2. ResolvedReference + +Represents the normalized output from a resolver. + +| Field | Type | Required | Notes | +|------|------|----------|------| +| `referenceClass` | string | Yes | Echoes the resolved class. | +| `rawIdentifier` | string | Yes | Original raw identifier preserved for technical detail. | +| `primaryLabel` | string | Yes | Human-readable display name or best-known fallback label. | +| `secondaryLabel` | string\|null | No | Type or context hint such as `Policy version`, `Group`, or `Workspace baseline`. | +| `state` | string | Yes | One of `resolved`, `partially_resolved`, `unresolved`, `deleted_or_missing`, `inaccessible`, or `external_limited_context`. | +| `stateLabel` | string\|null | No | Shared operator-facing vocabulary for the current state. | +| `linkTarget` | ReferenceLinkTarget\|null | No | Present only when the target is meaningful and access is allowed. | +| `technicalDetail` | ReferenceTechnicalDetail | Yes | Structured secondary detail for advanced operators. | +| `meta` | array | No | Extra safe metadata such as badge hints or source provenance. | + +Rules: +- `primaryLabel` must never default to the raw ID when a better fallback label exists. +- `linkTarget` must be absent when the reference is inaccessible, unsupported, or not meaningful to navigate. +- `state` must be explicit; null-based implicit semantics are not allowed. + +### 3. ReferenceLinkTarget + +Represents an optional canonical destination. + +| Field | Type | Required | Notes | +|------|------|----------|------| +| `targetKind` | string | Yes | Logical target such as `policy`, `policy_version`, `baseline_snapshot`, or `operation_run`. | +| `url` | string | Yes | Canonical resolved URL when the target is actionable. | +| `contextBadge` | string\|null | No | Optional context hint such as `Tenant context`. | +| `actionLabel` | string | Yes | Operator-facing action label such as `View policy` or `View run`. | + +Rules: +- `url` must come from canonical helpers such as resource `getUrl()` or `OperationRunLinks`, not hand-built route strings. +- `actionLabel` must follow shared UI naming vocabulary. + +### 4. ReferenceTechnicalDetail + +Represents controlled technical detail. + +| Field | Type | Required | Notes | +|------|------|----------|------| +| `displayId` | string\|null | No | Shortened or copyable technical identifier for UI display. | +| `fullId` | string | Yes | Raw identifier preserved in full form. | +| `sourceHint` | string\|null | No | Optional text like `Captured from snapshot evidence`. | +| `copyable` | bool | Yes | Whether UI may expose copy-to-clipboard affordance. | +| `defaultCollapsed` | bool | Yes | Whether technical detail should remain visually secondary by default. | + +### 5. ReferencePresentationVariant + +Represents the intended rendering density. + +| Field | Type | Required | Notes | +|------|------|----------|------| +| `variant` | string | Yes | `compact` or `detail`. | +| `showStateBadge` | bool | Yes | Indicates whether state badge is rendered inline. | +| `showTechnicalDetail` | bool | Yes | Whether the variant exposes secondary technical text inline or in disclosure form. | + +## Relationships + +- `ReferenceDescriptor` is passed to `ReferenceResolverRegistry`. +- `ReferenceResolverRegistry` selects one resolver based on `referenceClass`. +- The chosen resolver returns a `ResolvedReference`. +- `ResolvedReference` may contain a `ReferenceLinkTarget` and `ReferenceTechnicalDetail`. +- Shared Filament renderers consume `ResolvedReference` plus `ReferencePresentationVariant`. +- Existing `RelatedNavigationResolver` and current related-context sections are expected to adapt from `RelatedContextEntry` arrays to the richer `ResolvedReference` contract. + +## Reference State Semantics + +| State | Meaning | UI Expectation | +|------|---------|----------------| +| `resolved` | Authoritative label and optional canonical target are known. | Label-first; actionable only when permitted. | +| `partially_resolved` | Useful label or type is known, but some context or navigation is missing. | Label-first with explicit partial context; technical ID secondary. | +| `unresolved` | Only raw identifier and perhaps source hints are known. | Visible degraded state; no false confidence. | +| `deleted_or_missing` | Reference once mapped to a record that no longer exists locally. | Distinct missing/deleted message; raw ID preserved secondarily. | +| `inaccessible` | Resolver knows a target exists but current policy does not allow navigation or safe disclosure. | Non-clickable; disclosure limited by policy. | +| `external_limited_context` | Reference belongs to an external/provider object with only limited local enrichment. | Best-effort label/type plus raw ID as secondary evidence. | + +## Current Domain Sources Expected to Feed the Model + +| Source Model / Surface | Likely Reference Classes Produced | +|------|------| +| `Finding` | `baseline_snapshot`, `operation_run`, `policy_version`, `policy`, `baseline_profile`, assignment or principal targets from evidence | +| `PolicyVersion` | `policy`, `baseline_snapshot`, `baseline_profile`, `operation_run` | +| `BaselineSnapshot` | `baseline_profile`, `policy_version`, `operation_run`, role or assignment evidence references | +| `BackupSet` | `operation_run`, `policy`, `policy_version`, assignment-related target references | +| `OperationRun` | `backup_set`, `restore_run`, `baseline_profile`, `baseline_snapshot`, `policy`, target tenant or provider-linked context | +| Assignment-like views | `group`, `user`, `service_principal`, `role_definition`, `policy`, `policy_version` | + +## Migration Impact + +- No database migration required. +- Existing related-context or label helpers may be refactored to produce structured reference outputs instead of final UI strings. \ No newline at end of file diff --git a/specs/132-guid-context-resolver/plan.md b/specs/132-guid-context-resolver/plan.md new file mode 100644 index 0000000..7f2b200 --- /dev/null +++ b/specs/132-guid-context-resolver/plan.md @@ -0,0 +1,260 @@ +# Implementation Plan: GUID Context Resolver & Human-Readable Reference Presentation + +**Branch**: `132-guid-context-resolver` | **Date**: 2026-03-10 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `/specs/132-guid-context-resolver/spec.md` + +## Summary + +Replace GUID-first reference display on core enterprise surfaces by extending the existing shared related-context/navigation work into a broader reference-resolution foundation. The implementation will introduce explicit reference descriptor and resolved-reference contracts, a resolver registry for internal and provider-backed references, and shared compact/detail rendering patterns so findings, baseline snapshots, operation runs, assignments, and related-context sections show names first, type and state second, and technical IDs only as secondary evidence. + +## Technical Context + +**Language/Version**: PHP 8.4.15 / Laravel 12 +**Primary Dependencies**: Filament v5, Livewire v4.0+, Tailwind CSS v4 +**Storage**: PostgreSQL via Laravel Sail plus existing workspace and tenant context, existing Eloquent relations, and provider-derived identifiers already stored in domain records +**Testing**: Pest v4 feature and unit tests on PHPUnit 12 +**Target Platform**: Laravel Sail web application with workspace-admin routes under `/admin`, tenant-context routes under `/admin/t/{tenant}/...`, and shared Filament infolist/table rendering patterns +**Project Type**: Laravel monolith / Filament web application +**Performance Goals**: Reference rendering remains DB-only at page render time, uses bounded eager loading and batched local lookups, introduces no render-time Graph/provider calls, and avoids new N+1 patterns on dense list surfaces +**Constraints**: No schema changes, no new Graph calls, no unauthorized existence leakage, no page-specific fallback logic for supported classes, no broken pages when a resolver is missing, preserve 404 vs 403 semantics, and keep technical IDs visibly secondary +**Scale/Scope**: One shared resolver registry and value-object model, 8 to 10 initial reference classes, compact plus detailed presentation variants, 5 primary target surface families, and focused regression coverage for state rendering, linking, and authorization-aware degradation + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- Inventory-first: PASS — the feature changes reference presentation only; inventory, snapshot, and backup persistence semantics remain unchanged. +- Read/write separation: PASS — no write flow or new mutation is introduced; the feature is read-only UI and support-layer work. +- Graph contract path: PASS — no new Microsoft Graph calls are introduced, and reference resolution must stay DB-only or source-context-only at render time. +- Deterministic capabilities: PASS — linkability and degraded-state behavior can be derived from existing gates, policies, capability registries, and current route helpers. +- RBAC-UX planes and isolation: PASS — the feature spans workspace-admin `/admin` and tenant-context `/admin/t/{tenant}/...` surfaces but must preserve 404 for non-members and 403 for in-scope capability denial. +- Workspace isolation: PASS — workspace membership remains the visibility boundary for workspace-level reference labels and canonical destinations. +- RBAC-UX destructive confirmation: PASS / N/A — the feature introduces no destructive actions. +- RBAC-UX global search: PASS — touched resources either already have view pages or explicitly disable global search; the feature must not create new search dead ends. +- Tenant isolation: PASS — tenant-owned references must enforce current tenant entitlement before exposing protected labels or links. +- Run observability: PASS / N/A — existing `OperationRun` records may be displayed as resolved references, but no new run creation or lifecycle mutation occurs. +- Ops-UX 3-surface feedback: PASS / N/A — no new operation-start or completion flow is introduced. +- Ops-UX lifecycle and summary counts: PASS / N/A — no `OperationRun.status` or `OperationRun.outcome` transitions are introduced. +- Ops-UX guards and system runs: PASS / N/A — existing operations behavior remains unchanged. +- Automation: PASS / N/A — no queued or scheduled workflow changes are required. +- Data minimization: PASS — reference presentation uses already-authorized labels, IDs, and source hints only; no secrets or raw payload dumps are introduced. +- Badge semantics (BADGE-001): PASS — if reference-state badges are added or refined, they must map through `app/Support/Badges/BadgeCatalog.php` and `app/Support/Badges/BadgeRenderer.php` instead of page-specific label/color logic. +- UI naming (UI-NAMING-001): PASS — operator-facing reference copy remains domain-first, such as “Policy,” “Baseline snapshot,” “Operation run,” “Group,” or “Role definition,” and technical ID terminology stays secondary. +- UI naming (UI-NAMING-001): PASS — operator-facing reference copy remains domain-first, such as “Policy,” “Baseline snapshot,” “Operation run,” “Group,” or “Role definition,” and technical ID terminology stays secondary across labels, helper text, link labels, empty states, and degraded-state copy. +- Filament UI Action Surface Contract: PASS — touched resources already expose list/view surfaces; the feature upgrades inspect affordances and related-context rows without introducing destructive or noisy action sprawl, and touched resources or relation managers must keep their `actionSurfaceDeclaration()` coverage current where applicable. +- Filament UI UX-001: PASS — modified detail pages will continue to use sectioned infolists or read-only layouts, while list surfaces keep search/sort/filter behavior and adopt compact shared reference rendering. +- Filament v5 / Livewire v4 compliance: PASS — the plan remains inside the existing Filament v5 / Livewire v4 application surfaces. +- Provider registration (`bootstrap/providers.php`): PASS — no new panel provider is introduced; existing providers remain registered in `bootstrap/providers.php`. +- Global search resource rule: PASS — `FindingResource`, `PolicyVersionResource`, `BackupSetResource`, and `EntraGroupResource` already have view pages; workspace-level resources touched by the feature that are not globally searchable remain disabled. +- Asset strategy: PASS — no heavy frontend asset bundle is required; shared Blade partials and existing Filament assets are sufficient, and deploy-time `php artisan filament:assets` behavior remains unchanged. + +## Project Structure + +### Documentation (this feature) + +```text +specs/132-guid-context-resolver/ +├── plan.md +├── research.md +├── data-model.md +├── quickstart.md +├── contracts/ +│ └── reference-presentation.openapi.yaml +├── checklists/ +│ └── requirements.md +└── tasks.md +``` + +### Source Code (repository root) + +```text +app/ +├── Filament/ +│ ├── Resources/ +│ │ ├── FindingResource.php # existing related-context + list action consumer +│ │ ├── PolicyVersionResource.php # existing related-context + policy lineage consumer +│ │ ├── BackupSetResource.php # existing related-context + run linkage consumer +│ │ ├── OperationRunResource.php # canonical run detail consumer +│ │ ├── EntraGroupResource.php # current GUID-heavy directory reference surface +│ │ ├── BaselineProfileResource/ +│ │ │ └── RelationManagers/ +│ │ │ └── BaselineTenantAssignmentsRelationManager.php # concrete assignment-like target surface +│ │ └── BaselineSnapshotResource/ +│ │ └── Pages/ViewBaselineSnapshot.php # snapshot detail related-context consumer +│ └── Widgets/ +│ └── Dashboard/ # reference-heavy summary surfaces to keep aligned later +├── Models/ +│ ├── Policy.php +│ ├── PolicyVersion.php +│ ├── BaselineProfile.php +│ ├── BaselineSnapshot.php +│ ├── BackupSet.php +│ ├── OperationRun.php +│ ├── Finding.php +│ ├── EntraGroup.php +│ ├── EntraRoleDefinition.php +│ └── Tenant.php +├── Services/ +│ ├── Baselines/ +│ │ └── SnapshotRendering/ # existing registry + DTO pattern to mirror +│ └── Directory/ +│ └── EntraGroupLabelResolver.php # existing narrow provider-backed label resolver +├── Support/ +│ ├── Navigation/ +│ │ ├── CrossResourceNavigationMatrix.php # existing source/surface matrix +│ │ ├── RelatedNavigationResolver.php # existing entry-point to evolve or wrap +│ │ ├── RelatedContextEntry.php # current shallow view payload model +│ │ └── CanonicalNavigationContext.php # canonical link context helper +│ ├── OperationRunLinks.php # canonical run route helper +│ └── References/ # NEW shared reference descriptor/registry/value objects +resources/ +└── views/ + └── filament/ + └── infolists/ + └── entries/ + ├── related-context.blade.php # current reusable related-context renderer + └── resolved-reference*.blade.php # NEW compact/detail variants +tests/ +├── Feature/ +│ ├── Filament/ # resource view/list rendering tests +│ ├── Monitoring/ # canonical operations link tests +│ └── Rbac/ # authorization-aware visibility/degradation tests +└── Unit/ + ├── Support/ + │ └── References/ # NEW resolver registry / DTO / state tests + └── Services/ + └── Directory/ # existing label resolver tests that may be upgraded +``` + +**Structure Decision**: Keep the feature inside the existing Laravel/Filament monolith and extend the current shared navigation support rather than creating a parallel page-only solution. Introduce a dedicated reference-resolution layer under `app/Support/References`, then adapt the current `RelatedNavigationResolver`, related-context partials, and target resources to consume the richer reference semantics. + +## Complexity Tracking + +> No Constitution Check violations. No justifications needed. + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| — | — | — | + +## Phase 0 — Research (DONE) + +Output: +- `specs/132-guid-context-resolver/research.md` + +Key findings captured: +- The repo already has a first-generation shared navigation layer through `RelatedNavigationResolver`, `CrossResourceNavigationMatrix`, `RelatedContextEntry`, and the shared `related-context` Blade partial, but that layer models only availability plus links, not explicit descriptor classes, multi-state resolution, or controlled technical detail presentation. +- `BaselineSnapshotPresenter`, `SnapshotTypeRendererRegistry`, and the rendered snapshot DTOs show a strong existing pattern for registry-driven resolution with immutable value objects and fallback behavior, which maps well to a shared reference resolver registry. +- `EntraGroupLabelResolver` proves provider-backed label enrichment is already needed, but today it collapses unresolved and resolved references into a single label string instead of returning structured reference states. +- `OperationRunLinks` and existing resource `getUrl()` helpers already encode canonical destinations, so Spec 132 should centralize richer label/state handling around those helpers instead of inventing new route families. +- Filament v5 supports custom `ViewEntry` renderers and rich table layouts, so compact and detailed reference variants can remain shared without publishing internal Filament views or introducing a heavy frontend rewrite. +- The highest-value target surfaces already expose related-context sections or reference-heavy infolist/table entries, making incremental adoption practical without a schema migration. + +## Phase 1 — Design & Contracts (DONE) + +Outputs: +- `specs/132-guid-context-resolver/data-model.md` +- `specs/132-guid-context-resolver/contracts/reference-presentation.openapi.yaml` +- `specs/132-guid-context-resolver/quickstart.md` + +Design highlights: +- Introduce `ReferenceDescriptor`, `ResolvedReference`, `ReferenceLinkTarget`, `ReferenceTechnicalDetail`, `ReferencePresentationVariant`, and a `ReferenceResolverRegistry` as the canonical shared contracts for reference semantics. +- Keep reference resolution DB-only and best-effort at render time by combining existing relations, local model lookups, stored source metadata, and narrow provider-backed label resolvers such as `EntraGroupLabelResolver`; no external provider call is allowed during page render. +- Extend or wrap the current `RelatedNavigationResolver` so it produces richer reference payloads instead of today’s shallow `value + secondaryValue + availability` model. +- Preserve canonical destinations through existing helpers like `OperationRunLinks` and resource `getUrl()` methods while making linkability capability-aware and non-ambiguous. +- Add shared compact and detailed reference renderers so list rows and detail sections preserve the same semantic order even when the visual density differs, and map reference-state badges through the shared badge system. +- Add shared compact and detailed reference renderers so list rows and detail sections preserve the same semantic order even when the visual density differs, map reference-state badges through the shared badge system, and preserve domain-consistent operator copy. +- Roll out incrementally by first covering internal model-backed references, then security/provider-backed references, and finally upgrading existing related-context sections and table cells on the target surfaces. + +## Phase 1 — Agent Context Update (DONE) + +Run: +- `.specify/scripts/bash/update-agent-context.sh copilot` + +## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`) + +### Step 1 — Define the shared reference contracts and registry + +Goal: implement FR-132-02, FR-132-03, FR-132-05, FR-132-16, and FR-132-23. + +Changes: +- Add the shared descriptor, state, technical-detail, and resolved-reference value objects. +- Add a registry or dispatcher that maps explicit reference classes to concrete resolvers. +- Define a fallback resolver for unsupported or unresolved classes that still keeps the reference visible. + +Tests: +- Add unit coverage for registry dispatch, unsupported-class fallback, value-object normalization, and reference-state serialization. + +### Step 2 — Implement the initial model-backed resolvers + +Goal: implement FR-132-01, FR-132-04, FR-132-09, FR-132-10, FR-132-13, and FR-132-21. + +Changes: +- Add resolvers for policies, policy versions, baseline profiles, baseline snapshots, operation runs, and backup sets. +- Resolve primary labels, type/context labels, canonical targets, and secondary technical details from local records and already-known relationships. +- Batch or eager-load where needed so detail and list surfaces avoid N+1 lookups. + +Tests: +- Add unit coverage for each core resolver, including resolved, partially resolved, missing, and unsupported cases. + +### Step 3 — Implement governance, security, and provider-backed resolvers + +Goal: implement FR-132-04, FR-132-08, FR-132-10, FR-132-14, and FR-132-28. + +Changes: +- Add resolvers for role definitions, Entra groups, principal-like references, assignment targets, and tenant-linked external object references that are already surfaced today. +- Upgrade narrow helpers like `EntraGroupLabelResolver` into structured resolver collaborators instead of raw label string formatters. +- Differentiate inaccessible, unresolved, deleted or missing, and external-only limited-context states explicitly. + +Tests: +- Add unit coverage for provider-backed label reuse, inaccessible-state degradation, fallback labels, and deleted-object behavior. + +### Step 4 — Create shared presentation variants and state vocabulary + +Goal: implement FR-132-06, FR-132-07, FR-132-08, FR-132-12, FR-132-18, FR-132-19, and FR-132-20. + +Changes: +- Add compact and detailed shared reference renderers for infolists, related-context sections, and dense tables. +- Keep the visual order consistent: label first, type/context second, state next, technical ID last. +- Centralize state badges and unavailable-state copy instead of letting each resource improvise labels. + +Tests: +- Add feature coverage proving technical IDs stay secondary, unresolved states remain visible, and clickable vs non-clickable rendering is unambiguous. + +### Step 5 — Refactor target surfaces to consume the shared reference layer + +Goal: implement FR-132-15, FR-132-24, FR-132-25, FR-132-26, and FR-132-27. + +Changes: +- Refactor findings, baseline snapshots, operation runs, policy versions, backup sets, the baseline tenant assignments relation manager, and assignment-evidence sections on finding, policy-version, and baseline-snapshot surfaces to use the shared resolver/presentation layer. +- Replace GUID-heavy infolist/table fields and ad hoc related-context arrays with structured resolved references. +- Ensure existing canonical links continue to flow through `OperationRunLinks` and resource route helpers while touched Filament resources preserve explicit inspect affordances, row-action caps, and documented exemptions. +- Ensure tenant-context entry into canonical workspace destinations preserves visible originating-tenant meaning via filters, context badges, or source-context metadata where relevant. + +Tests: +- Add or update Filament feature coverage for findings, baseline snapshots, operation runs, and assignment-like surfaces to prove label-first rendering and canonical links. + +### Step 6 — Enforce authorization-aware degradation and regression safety + +Goal: implement FR-132-11, FR-132-14, FR-132-17, FR-132-22, and the acceptance criteria around safe degradation. + +Changes: +- Ensure the shared layer differentiates non-member 404 semantics from in-scope capability denial and never leaks protected labels where policy forbids it. +- Keep unsupported or partially known references visible without breaking pages. +- Audit touched resources for remaining GUID-first output and remove page-specific formatting branches for supported classes. +- Lock the rollout boundary so the surfaces named in the spec fully adopt the shared pattern now, while dashboard summary widgets and other out-of-scope views are explicitly allowed to remain on older formatting until a later spec. +- Add regression coverage for domain-consistent operator copy and tenant-context carryover on migrated canonical destinations. + +Tests: +- Add positive and negative authorization coverage proving inaccessible references are non-clickable, protected destinations stay guarded, and unsupported resolvers do not hide the underlying reference. +- Add regression coverage preventing raw GUIDs from reappearing as the primary visible value on supported target surfaces. + +## Constitution Check (Post-Design) + +Re-check result: PASS. + +- Livewire v4.0+ compliance: preserved because the design stays inside the existing Filament v5 / Livewire v4 surfaces. +- Provider registration location: unchanged; existing panel providers remain registered in `bootstrap/providers.php`. +- Globally searchable resources: touched searchable resources already expose view pages, and non-searchable resources remain explicitly disabled, so the Filament global-search rule remains satisfied. +- Destructive actions: no new destructive actions are introduced; existing destructive behavior remains subject to current confirmation and authorization rules. +- Asset strategy: no new heavy assets are introduced; shared Blade partials and existing Filament view infrastructure are sufficient, and deploy-time `php artisan filament:assets` behavior remains unchanged. +- Testing plan: add focused Pest unit coverage for registry dispatch, resolver behavior, shared badge mapping, state normalization, and authorization-aware link generation, plus focused Filament feature coverage for findings, baseline snapshots, operation runs, the baseline tenant assignments relation manager, assignment-evidence surfaces, tenant-context carryover on canonical destinations, domain-consistent copy, and degraded-reference rendering. diff --git a/specs/132-guid-context-resolver/quickstart.md b/specs/132-guid-context-resolver/quickstart.md new file mode 100644 index 0000000..70afd40 --- /dev/null +++ b/specs/132-guid-context-resolver/quickstart.md @@ -0,0 +1,60 @@ +# Quickstart: GUID Context Resolver & Human-Readable Reference Presentation + +## Goal + +Implement a shared reference-resolution and rendering layer that makes supported references display as label-first, context-second, and technical-ID-last across findings, snapshots, operation runs, assignments, and related-context sections. + +## Preconditions + +1. Work on branch `132-guid-context-resolver`. +2. Ensure Sail is running: + +```bash +vendor/bin/sail up -d +``` + +3. Review the current shared navigation and rendering entry points: + - `app/Support/Navigation/RelatedNavigationResolver.php` + - `app/Support/Navigation/CrossResourceNavigationMatrix.php` + - `app/Support/Navigation/RelatedContextEntry.php` + - `resources/views/filament/infolists/entries/related-context.blade.php` + - `app/Support/OperationRunLinks.php` + - `app/Services/Directory/EntraGroupLabelResolver.php` + +## Implementation Sequence + +1. Add the shared reference contracts under `app/Support/References/`. +2. Implement the resolver registry and fallback resolver. +3. Add core internal resolvers for policy, policy version, baseline profile, baseline snapshot, operation run, and backup set. +4. Add governance and provider-backed resolvers for role definitions, groups, principals, and assignment-like targets that are already surfaced. +5. Upgrade the shared renderers with compact and detailed variants while keeping technical IDs secondary. +6. Refactor target surfaces to consume resolved references instead of ad hoc label or GUID formatting. +7. Preserve originating tenant meaning when a tenant-context source links to a canonical workspace destination by carrying visible filters, context badges, or source-context metadata where relevant. +8. Normalize operator-facing copy across labels, helper text, link labels, empty states, and degraded-state messaging. +9. Add unit and feature tests, then run focused verification. + +## Verification + +Run focused tests for the new support layer and target surfaces: + +```bash +vendor/bin/sail artisan test --compact --filter=Reference +vendor/bin/sail artisan test --compact --filter=Finding +vendor/bin/sail artisan test --compact --filter=BaselineSnapshot +vendor/bin/sail artisan test --compact --filter=OperationRun +``` + +Run formatting after code changes: + +```bash +vendor/bin/sail bin pint --dirty --format agent +``` + +## Expected Outcome + +- Supported references no longer render GUIDs as primary visible text on target surfaces. +- Unresolved, missing, partially resolved, and inaccessible states are visibly distinct. +- Canonical links remain authorization-aware and non-ambiguous. +- Canonical workspace destinations reached from tenant-context sources preserve visible tenant meaning where relevant. +- Operator-facing copy remains domain-consistent across labels, helper text, link text, empty states, and degraded-state messaging. +- Existing related-context consumers and future surfaces can reuse the same shared reference semantics. \ No newline at end of file diff --git a/specs/132-guid-context-resolver/research.md b/specs/132-guid-context-resolver/research.md new file mode 100644 index 0000000..ec8bc63 --- /dev/null +++ b/specs/132-guid-context-resolver/research.md @@ -0,0 +1,57 @@ +# Phase 0 Research: GUID Context Resolver & Human-Readable Reference Presentation + +## Decision 1: Extend the existing shared navigation layer instead of replacing it + +- Decision: Build the new reference semantics on top of the current shared related-context/navigation foundation rather than starting from a second unrelated abstraction. +- Rationale: The codebase already has `RelatedNavigationResolver`, `CrossResourceNavigationMatrix`, `RelatedContextEntry`, shared related-context Blade rendering, and canonical route helpers. Reusing those extension points reduces migration risk and keeps Spec 131 and Spec 132 aligned. +- Alternatives considered: + - Replace the navigation layer entirely with a new reference-only stack: rejected because it would duplicate route, availability, and action-label logic that already exists and is already wired into multiple resources. + - Keep page-level arrays and only restyle the Blade partial: rejected because it would not satisfy the spec’s requirement for explicit reference classes, shared semantics, and distinct degraded states. + +## Decision 2: Model references through explicit input and output contracts + +- Decision: Introduce a structured `ReferenceDescriptor` input contract and a normalized `ResolvedReference` output contract with explicit state, type, label, technical detail, and optional canonical target. +- Rationale: The current `RelatedContextEntry` payload is useful for immediate rendering but too shallow for the broader semantics required here. A descriptor plus resolved-reference pair makes support for partial resolution, unsupported classes, and future providers predictable and testable. +- Alternatives considered: + - Continue passing free-form arrays between resources and partials: rejected because it invites page-specific drift and makes regression testing weak. + - Resolve everything directly inside Blade partials: rejected because presentation should not own resolution logic or authorization decisions. + +## Decision 3: Keep render-time resolution DB-only and best-effort + +- Decision: Resolve references at render time using existing relations, local model lookups, current workspace or tenant context, stored fallback labels, and existing helper services only; do not make live Microsoft Graph or provider calls. +- Rationale: The constitution and current architecture keep monitoring and governance views DB-only at render time. Best-effort local resolution is sufficient for the target surfaces and avoids latency, availability, and authorization leakage problems. +- Alternatives considered: + - Make on-demand provider calls to improve labels: rejected because it violates the DB-only rendering expectation, adds latency, and complicates authorization. + - Cache external labels aggressively in new tables: rejected because the spec explicitly avoids introducing new persistent models solely for resolution. + +## Decision 4: Use two shared presentation variants with one semantic payload + +- Decision: Provide a compact variant for dense tables and a detailed variant for related-context or infolist sections, both powered by the same resolved-reference payload. +- Rationale: Some target surfaces are list-dense and cannot absorb a full detail block without becoming noisy, while detail pages need more explanatory context and degraded-state messaging. One semantic contract with two renderers keeps the hierarchy stable while fitting each surface. +- Alternatives considered: + - Force one heavy component everywhere: rejected because it would make list screens noisy and reduce scanability. + - Allow each page to design its own variant independently: rejected because it would recreate the inconsistency the spec is meant to solve. + +## Decision 5: Authorization controls linkability first, label disclosure second + +- Decision: Canonical targets become actionable only when current policy allows access; label and state disclosure must degrade to inaccessible or unresolved only to the extent policy allows. +- Rationale: The product’s 404-vs-403 semantics and deny-as-not-found rules are already explicit. The reference layer must not accidentally leak object existence or friendly labels just because it has enough data to resolve them internally. +- Alternatives considered: + - Hide every unauthorized reference completely: rejected because some surfaces legitimately need to preserve the existence of a reference without allowing navigation. + - Always show the resolved label even when navigation is forbidden: rejected because that can leak protected detail. + +## Decision 6: Upgrade narrow label helpers into structured resolver collaborators + +- Decision: Existing helpers such as `EntraGroupLabelResolver` should become collaborators that feed structured resolution outputs rather than emitting final UI labels. +- Rationale: Current helpers return a single string like `Name (…token)`, which is not enough to separate primary label, type/context, state, and technical detail. They remain useful, but inside a richer resolver contract. +- Alternatives considered: + - Leave helpers untouched and parse their formatted strings back into UI parts: rejected because it is brittle and loses state intent. + - Rewrite all provider-backed label logic from scratch: rejected because the current local lookup behavior is already useful and should be preserved. + +## Decision 7: Leverage the repo’s existing registry-and-DTO pattern + +- Decision: Mirror the `BaselineSnapshotPresenter` and `SnapshotTypeRendererRegistry` pattern by using immutable value objects, registry dispatch, and fallback behavior for references. +- Rationale: The repo already favors registry-driven support layers for normalization and rendering. Following that pattern keeps the reference system familiar, testable, and consistent with current architecture. +- Alternatives considered: + - Put the entire resolution switch in one large service class: rejected because explicit per-class resolvers are easier to extend and test. + - Encode resolution rules in configuration only: rejected because authorization-aware destination logic and best-effort lookup behavior require application code. \ No newline at end of file diff --git a/specs/132-guid-context-resolver/spec.md b/specs/132-guid-context-resolver/spec.md new file mode 100644 index 0000000..8af798b --- /dev/null +++ b/specs/132-guid-context-resolver/spec.md @@ -0,0 +1,203 @@ +# Feature Specification: GUID Context Resolver & Human-Readable Reference Presentation + +**Feature Branch**: `132-guid-context-resolver` +**Created**: 2026-03-10 +**Status**: Draft +**Input**: User description: "Spec 132 — GUID Context Resolver & Human-Readable Reference Presentation" + +## Spec Scope Fields *(mandatory)* + +- **Scope**: canonical-view +- **Primary Routes**: + - Workspace admin governance, findings, snapshot, assignment, and operations pages under `/admin/...` + - Tenant-context governance and related detail pages under `/admin/t/{tenant}/...` + - Canonical workspace destinations reached from tenant-context sources, where originating tenant meaning must remain visible through filters, badges, or source-context metadata + - Assignment-like surfaces already present in the product, including the baseline tenant assignments relation manager and assignment evidence sections on policy-version, finding, and baseline-snapshot views + - Shared related-context sections and reference-heavy list/detail surfaces that currently expose important IDs as primary text +- **Data Ownership**: + - Existing workspace-owned governance, operations, backup, and monitoring records remain the source of truth + - Existing tenant-scoped records and provider-linked identifiers remain unchanged; this feature standardizes how references to them are resolved and presented + - No new persistent domain model is introduced solely for this feature; the change is a shared presentation and resolution capability layered on top of existing records and source identifiers +- **RBAC**: + - Workspace membership remains the primary boundary for reference visibility across admin resources + - Tenant-scope entitlement remains required before showing tenant-context reference detail or navigation + - Capability checks remain required before rendering actionable navigation to protected related resources + - Reference presentation must preserve deny-as-not-found behavior for non-members and out-of-scope tenant access + +For canonical-view specs, the spec MUST define: + +- **Default filter behavior when tenant-context is active**: When a user enters a canonical workspace-level destination from a tenant-context source, the destination remains authoritative while preserving the originating tenant meaning through visible tenant filters, context badges, or source context metadata where relevant. +- **Explicit entitlement checks preventing cross-tenant leakage**: Reference resolution and rendering must enforce existing workspace membership and tenant-scope entitlements before exposing protected labels, canonical destinations, or context details. Non-members and users outside permitted tenant scope must not receive related-object labels, existence hints beyond allowed degraded states, or actionable links. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Read referenced objects without decoding IDs (Priority: P1) + +As an enterprise operator, I want important referenced objects to display as names and context first so I can understand findings, snapshots, runs, and assignments without manually decoding GUIDs. + +**Why this priority**: Reference-heavy screens lose most of their operational value when core objects appear only as opaque identifiers. + +**Independent Test**: Can be fully tested by opening a finding, snapshot, or run that includes supported references and verifying that the interface shows human-readable labels, type hints, and secondary technical IDs instead of GUID-first output. + +**Acceptance Scenarios**: + +1. **Given** a supported reference that resolves to a known internal or provider-backed object, **When** an authorized operator opens an in-scope screen, **Then** the reference displays a human-readable label first, contextual type second, and the technical ID only as secondary detail. +2. **Given** a screen with several different supported reference classes, **When** the operator reviews that screen, **Then** equivalent references follow the same information hierarchy instead of page-specific formatting. + +--- + +### User Story 2 - Understand degraded references safely (Priority: P1) + +As an enterprise operator, I want unresolved, deleted, partially known, or inaccessible references to degrade clearly so I can tell what the system knows and what follow-up is needed. + +**Why this priority**: Governance and troubleshooting flows break when every failure mode collapses into the same vague “Unknown” text. + +**Independent Test**: Can be fully tested by rendering supported references in resolved, partially resolved, missing, and inaccessible states and confirming that each state remains visible, distinct, and non-misleading. + +**Acceptance Scenarios**: + +1. **Given** a reference whose raw identifier is present but whose target cannot be fully matched, **When** the operator views the record, **Then** the interface keeps the reference visible and marks it as unresolved, partial, missing, or inaccessible using shared vocabulary. +2. **Given** a reference to a protected in-product resource, **When** the current user lacks destination access, **Then** the UI does not present that reference as clickable and does not reveal unauthorized detail beyond the allowed degraded state. + +--- + +### User Story 3 - Navigate from references when allowed (Priority: P2) + +As an authorized operator, I want resolved references to link to the correct canonical destination when that destination is meaningful and permitted so I can move from context to action quickly. + +**Why this priority**: Once a reference is understandable, the next operator need is fast drill-down to the authoritative related record. + +**Independent Test**: Can be fully tested by opening supported references on in-scope screens and verifying that only permitted destinations render as actionable links and that those links use canonical targets. + +**Acceptance Scenarios**: + +1. **Given** a resolved reference with a valid canonical destination and an entitled user, **When** the reference is rendered, **Then** the destination is clearly actionable and opens the canonical target for that object. +2. **Given** a resolved reference without a meaningful destination or without access, **When** the reference is rendered, **Then** it remains informative but non-clickable. + +--- + +### User Story 4 - Extend the same pattern to future surfaces (Priority: P3) + +As a product team member, I want new reference-heavy surfaces to reuse the same reference semantics so the product stops solving ID presentation differently on every page. + +**Why this priority**: The platform value comes from consistent reuse, not one-off cleanup on a few screens. + +**Independent Test**: Can be fully tested by adding a new supported reference class or a new target surface without rewriting every existing page’s formatting logic. + +**Acceptance Scenarios**: + +1. **Given** a newly supported reference class, **When** it is added to the shared reference presentation capability, **Then** existing rendering patterns can present it without page-specific reinvention. + +### Edge Cases + +- A reference may be syntactically valid but point to a record that has been deleted since capture. +- A reference may expose only a fallback label, a broad type, or source metadata, but no canonical target. +- A reference may be known to exist conceptually while the current user is not authorized to open the target record. +- Multiple different reference classes may appear in the same dense list or detail section and must remain readable without excessive visual noise. +- A surface may encounter a supported reference class with insufficient enrichment and must still avoid falling back to GUID-first output when partial context is available. +- A screen may receive an unsupported reference class and must keep the reference visible without breaking the page. + +## Requirements *(mandatory)* + +**Constitution alignment (required):** This feature is a read-oriented presentation and shared semantics improvement. It introduces no new Microsoft Graph calls, no new write/change workflows, and no new long-running or scheduled work. Existing governance, monitoring, backup, assignment, and tenant-linked records remain authoritative. The feature only standardizes how known references are enriched, classified, and rendered on user-facing surfaces. + +**Constitution alignment (OPS-UX):** This feature may display existing operation runs as resolved references, but it does not create, mutate, or observe `OperationRun` lifecycle behavior and does not alter toast, progress, or terminal-notification contracts. + +**Constitution alignment (RBAC-UX):** This feature affects workspace-admin `/admin` surfaces and tenant-context `/admin/t/{tenant}/...` surfaces that render or link to related records. Cross-plane access remains deny-as-not-found. Non-members and users outside entitled workspace or tenant scope must receive 404 semantics. Members within the correct scope who lack target-resource capability must receive 403 semantics when opening protected destinations. Reference presentation must remain capability-aware and must not use raw capability strings or role-name checks in feature code. At least one positive and one negative authorization test must protect reference visibility and linkability behavior. + +**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication-handshake behavior is introduced. + +**Constitution alignment (BADGE-001):** If this feature introduces or standardizes reference-state badges, those states must use centralized semantics and shared vocabulary through the existing badge system, using shared badge-domain mapping and shared badge rendering rather than page-local label/color logic. + +**Constitution alignment (UI-NAMING-001):** Operator-facing reference copy must use domain vocabulary such as “Policy,” “Policy version,” “Baseline snapshot,” “Operation run,” “Group,” “User,” and “Role definition” rather than implementation-first ID language. Actions and helper copy must preserve the same object naming across list rows, related-context sections, link labels, and empty or degraded states. + +**Constitution alignment (Filament Action Surfaces):** This feature modifies multiple Filament resources and detail pages by standardizing inspect affordances, related-context rows, and view actions for resolved references. The Action Surface Contract remains satisfied because this feature adds read-oriented reference display and navigation only; it introduces no destructive actions. + +**Constitution alignment (UX-001 — Layout & Information Architecture):** Modified Filament detail pages must present references in structured read-only sections or rows rather than raw field dumps. View pages remain infolist-first or equivalent read-only presentations. List and table screens continue to support search, sort, and filters for their core dimensions while reference-heavy values adopt the same label-first hierarchy. If a dense table cannot support the detailed reference variant without harming readability, it may use a compact variant that preserves the same meaning order. + +### Functional Requirements + +- **FR-132-01 Label-first presentation**: For supported reference classes, user-facing screens must not present raw GUIDs, raw IDs, or foreign keys as the primary visible value when a more human-readable label or fallback label is available. +- **FR-132-02 Shared reference capability**: The product must provide one shared reference-resolution and presentation capability that can be reused across multiple surfaces instead of page-specific formatting logic. +- **FR-132-03 Explicit reference classes**: Supported references must be classified into explicit reference classes rather than treated as generic strings. +- **FR-132-04 Initial class coverage**: Initial supported reference classes must cover, where surfaced today, policies, policy versions, baseline profiles, baseline snapshots, operation runs, backup-related artifacts, role definitions, principals, groups, service-principal-style references, and tenant-linked external object references relevant to current domains. +- **FR-132-05 Normalized reference output**: The shared capability must return a normalized presentation result that includes reference class, raw identifier, primary label, secondary context or type label, explicit resolution state, and optional canonical destination. +- **FR-132-06 Consistent hierarchy**: Resolved references must render with the same information order across surfaces: primary label, type or context, state, then technical ID. +- **FR-132-07 Secondary technical detail**: Technical identifiers must remain available for advanced troubleshooting, copying, or support workflows, but only as visually secondary detail. +- **FR-132-08 Distinct degraded states**: The UI must distinguish at minimum between resolved, partially resolved, unresolved, deleted or missing, inaccessible, and external-only limited-context references where those states are materially different. +- **FR-132-09 Safe degradation**: When a reference cannot be fully resolved, the interface must preserve the reference visibly, avoid false confidence, and show the best safe context available. +- **FR-132-10 Partial resolution support**: The product must support partially resolved references that have some useful context, such as fallback label, broad object type, or retained source metadata, even when a full destination or authoritative record is unavailable. +- **FR-132-11 Role-aware linkability**: References may render as actionable only when they have a meaningful canonical destination and the current user is permitted to open it. +- **FR-132-12 No ambiguous clickability**: References without a permitted or meaningful destination must not appear clickable. +- **FR-132-13 Canonical destinations**: When a supported reference links internally, it must use the canonical destination for that resource instead of page-specific ad hoc routes. +- **FR-132-14 No unauthorized disclosure**: Reference presentation must not broaden existence leakage, protected labels, or related-object detail beyond what existing authorization policy permits. +- **FR-132-15 Cross-surface reuse**: The same resolution and presentation semantics must be reusable across baseline snapshots, findings, operation runs, related-context sections, assignments, and equivalent governance artifacts. +- **FR-132-16 Centralized logic**: Pages must not reimplement bespoke “if GUID then format” branches for supported reference classes. +- **FR-132-17 Unsupported-class fallback**: If a reference class is unsupported or no enrichment is available, the page must still render the reference in a safe degraded form instead of failing or hiding it entirely. +- **FR-132-18 Dense-view variants**: The product may use compact and detailed reference variants, but both variants must preserve the same hierarchy and state semantics. +- **FR-132-19 Shared vocabulary**: Reference types and reference states must use a shared display vocabulary across all in-scope screens. +- **FR-132-20 Related-context readability**: Screens that expose many references must remain readable because each rendered reference is self-describing without requiring the operator to leave the page first. +- **FR-132-21 Best-effort enrichment**: The shared capability must support best-effort enrichment from available source context, fallback label, workspace or tenant context, and already-known relations. +- **FR-132-22 Incremental rollout compatibility**: During rollout, the target surfaces named in this spec must fully adopt the new pattern even if out-of-scope screens such as dashboard summary widgets or later inventory/reporting views temporarily still use older formatting. +- **FR-132-22 Incremental rollout compatibility**: During rollout, the target surfaces named in this spec must fully adopt the new pattern even if out-of-scope screens such as dashboard summary widgets or later inventory/reporting views temporarily still use older formatting. This includes tenant-context entry into canonical workspace destinations where originating tenant filters, context badges, or source-context metadata remain visible. +- **FR-132-23 Future extensibility**: Adding a newly supported reference class must not require rewriting all existing page templates or all existing reference renderers. +- **FR-132-24 Baseline snapshot adoption**: Baseline snapshot views and snapshot-related sections must use the shared reference presentation pattern for referenced policies, profiles, runs, and related governance objects in scope. +- **FR-132-25 Findings adoption**: Findings must render referenced policies, snapshots, runs, assignments, principals, and similar related entities using contextual references rather than raw IDs as primary text. +- **FR-132-26 Operation-run adoption**: Operation-run surfaces must render important target and related-object references in the shared human-readable format. +- **FR-132-27 Assignment-like adoption**: The baseline tenant assignments relation manager plus assignment-evidence and assignment-diff views that surface principals, groups, roles, or policy relationships must use the shared reference presentation pattern. +- **FR-132-28 Evidence preservation**: Even in degraded states, raw identifiers must remain available as supporting evidence when operationally useful. + +### Non-Goals + +- Full cross-resource navigation redesign +- New audit-log features +- Universal raw-payload transformation +- New persistent domain models created solely for reference resolution +- Global search redesign +- External directory synchronization redesign +- A full evidence-viewer redesign +- Changes to capture or snapshot semantics +- Guaranteed full resolution for every arbitrary external identifier + +### Assumptions + +- Existing records and source payloads already contain enough relationship hints to materially improve how important references are presented on the initial target surfaces. +- Some provider-backed references will remain partially known, and that is acceptable if the degraded state is explicit. +- Operators still need access to technical IDs for support and troubleshooting, but not as the dominant visual language. +- In-scope surfaces can adopt a compact reference variant where dense layouts would otherwise become noisy. + +### Dependencies + +- Existing workspace and tenant authorization rules +- Existing canonical destinations for supported internal resources +- Existing governance, monitoring, backup, assignment, and provider-linked domain relationships +- Existing display names, fallback labels, or type hints already stored or derivable from source context + +## UI Action Matrix *(mandatory when Filament is changed)* + +| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions | +|---|---|---|---|---|---|---|---|---|---|---| +| Findings | Workspace admin list and detail surfaces | None new beyond existing page actions | Reference-heavy columns and related-context rows must use shared label-first rendering | View related record, View run when permitted | None | Existing empty state remains; no new mutation CTA required | View related record, View snapshot or policy when most relevant | N/A | No | Read-only presentation upgrade only; no destructive actions | +| Baseline snapshots | Workspace admin list and detail surfaces | None new beyond existing view actions | Snapshot rows and detail related-context entries use shared reference presentation | View profile, View run when permitted | None | Existing empty state remains | View profile, View policy version when relevant | N/A | No | Snapshot readability is a primary target surface | +| Operation runs | Canonical workspace operations list and detail surfaces | None new beyond existing view actions | Run target and related-object references render label-first | View target record, View related artifact when permitted | None | Existing empty state remains | View related record, View source context when relevant | N/A | No | Links must stay canonical and capability-aware | +| Assignment-like views | Workspace admin and tenant-context assignment or relationship surfaces | None new beyond existing view actions | Principal, group, role, and target references use shared compact rendering in tables and detailed rendering on view pages | View principal or target, View role when permitted | None | Existing empty state remains | View target record, View related policy when relevant | N/A | No | Initial concrete targets are `BaselineTenantAssignmentsRelationManager` plus assignment-evidence sections on policy-version, finding, and baseline-snapshot views; no new mutations, only reference clarity and consistent linking | +| Related-context sections across governance artifacts | View pages under `/admin/...` and `/admin/t/{tenant}/...` | None | Structured related-context rows become the primary inspect affordance for secondary objects | At most the two most relevant permitted related links | None | N/A | Existing page header actions remain authoritative | N/A | No | Exemption: row-action cap may be enforced through section ordering rather than visible button count in some view-only layouts | + +### Key Entities *(include if feature involves data)* + +- **Reference Descriptor**: The structured description of a user-visible reference, including its class, raw identifier, available context, and any safe fallback label or source metadata. +- **Resolved Reference**: The normalized presentation result for a reference, including its primary label, contextual type, explicit state, technical identifier, and optional canonical destination. +- **Reference State**: The explicit condition of a reference, such as resolved, partially resolved, unresolved, missing, inaccessible, or limited-context external. +- **Reference Presentation Variant**: The compact or detailed rendering pattern that preserves the same meaning order while adapting to list-density or detail-page needs. +- **Canonical Destination**: The authoritative in-product destination for a resolved internal reference when the current user is allowed to navigate to it. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-132-01 Readability improvement on target surfaces**: In acceptance testing for the in-scope target surfaces, supported references are displayed label-first rather than GUID-first in 100% of covered cases. +- **SC-132-02 Distinct degraded states**: In regression tests for degraded-reference scenarios, 100% of covered resolved, partial, unresolved, missing, and inaccessible cases render visibly distinct states instead of collapsing into the same generic label. +- **SC-132-03 Secondary-ID compliance**: In target-surface review, technical IDs remain available as secondary detail for 100% of covered supported reference classes without becoming the primary displayed value. +- **SC-132-04 Canonical-link correctness**: In authorization-aware navigation tests, 100% of covered actionable references open canonical destinations only when the user is permitted to access them. +- **SC-132-05 Reuse across surfaces**: At least the baseline snapshot, findings, operation-run, baseline tenant assignments relation manager, and assignment-evidence target surfaces use the same shared reference semantics without page-specific ad hoc formatting rules for supported classes. +- **SC-132-06 Future extensibility**: In implementation review and regression coverage, adding a new supported reference class requires changes only to the shared reference capability and the intended target surface mappings, not wholesale rewrites of existing page templates. diff --git a/specs/132-guid-context-resolver/tasks.md b/specs/132-guid-context-resolver/tasks.md new file mode 100644 index 0000000..c1db6a7 --- /dev/null +++ b/specs/132-guid-context-resolver/tasks.md @@ -0,0 +1,232 @@ +# Tasks: GUID Context Resolver & Human-Readable Reference Presentation (132) + +**Input**: Design documents from `specs/132-guid-context-resolver/` (`spec.md`, `plan.md`, `research.md`, `data-model.md`, `contracts/`, `quickstart.md`) +**Prerequisites**: `specs/132-guid-context-resolver/plan.md` (required), `specs/132-guid-context-resolver/spec.md` (required for user stories) + +**Tests**: REQUIRED (Pest) for all runtime behavior changes in this repo. +**Operations**: No new `OperationRun` flow is introduced; this feature reuses existing operational records strictly as references and canonical destinations. +**RBAC**: Preserve workspace and tenant isolation, deny-as-not-found 404 for non-members, 403 for in-scope members missing capability, and capability-registry usage only for reference linkability. +**Filament UI**: This feature extends existing Filament resource and page surfaces only; keep inspect affordances explicit, visible row actions capped, and read-only detail rendering inside structured infolist or related-context sections. + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Reconfirm the exact target surfaces, shared helpers, and test neighborhoods before introducing the common reference layer. + +- [X] T001 Audit current reference-heavy seams in `app/Support/Navigation/RelatedNavigationResolver.php`, `app/Support/Navigation/CrossResourceNavigationMatrix.php`, `resources/views/filament/infolists/entries/related-context.blade.php`, `app/Support/OperationRunLinks.php`, and `app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php` +- [X] T002 [P] Audit current local lookup, label, and badge sources in `app/Services/Directory/EntraGroupLabelResolver.php`, `app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php`, `app/Services/Baselines/SnapshotRendering/RenderedSnapshotItem.php`, `app/Support/Badges/BadgeCatalog.php`, and `app/Support/Badges/BadgeRenderer.php` +- [X] T003 [P] Audit current reference-related and tenant-context test neighborhoods in `tests/Feature/PolicyVersionViewAssignmentsTest.php`, `tests/Feature/Drift/DriftFindingDetailShowsAssignmentsDiffTest.php`, `tests/Feature/Filament/BaselineSnapshotRbacRoleDefinitionsTest.php`, `tests/Feature/Filament/TenantRoleDefinitionsSelectorDbOnlyTest.php`, and `tests/Feature/Rbac/` + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Build the shared reference contracts, registry, adapters, and renderers that every user story depends on. + +**⚠️ CRITICAL**: No user story work should begin until this phase is complete. + +- [X] T004 Create shared reference value objects in `app/Support/References/ReferenceDescriptor.php`, `app/Support/References/ResolvedReference.php`, `app/Support/References/ReferenceLinkTarget.php`, `app/Support/References/ReferenceTechnicalDetail.php`, `app/Support/References/ReferencePresentationVariant.php`, and `app/Support/References/ReferenceResolutionState.php` +- [X] T005 Create resolver contracts and registry in `app/Support/References/Contracts/ReferenceResolver.php`, `app/Support/References/ReferenceResolverRegistry.php`, and `app/Support/References/Resolvers/FallbackReferenceResolver.php` +- [X] T006 Wire the shared reference layer into existing support seams in `app/Providers/AppServiceProvider.php`, `app/Support/Navigation/RelatedNavigationResolver.php`, and `app/Support/Navigation/RelatedContextEntry.php` +- [X] T007 [P] Create shared type and state presentation helpers backed by `app/Support/Badges/BadgeCatalog.php` and `app/Support/Badges/BadgeRenderer.php` in `app/Support/References/ReferenceTypeLabelCatalog.php` and `app/Support/References/ReferenceStatePresenter.php` +- [X] T008 [P] Create reusable reference renderers in `resources/views/filament/infolists/entries/resolved-reference-detail.blade.php`, `resources/views/filament/infolists/entries/resolved-reference-compact.blade.php`, and `resources/views/filament/infolists/entries/related-context.blade.php` +- [X] T009 [P] Add foundational unit coverage in `tests/Unit/Support/References/ReferenceResolverRegistryTest.php` and `tests/Unit/Support/References/ResolvedReferenceTest.php` +- [X] T010 [P] Add adapter, rendering, badge-mapping, and domain-copy smoke coverage in `tests/Feature/Filament/ResolvedReferenceRenderingSmokeTest.php`, `tests/Unit/Support/References/RelatedContextReferenceAdapterTest.php`, and `tests/Unit/Support/References/ReferenceStateBadgeMappingTest.php` + +**Checkpoint**: The repo has one shared reference contract, resolver registry, and rendering seam that all in-scope surfaces can consume consistently. + +--- + +## Phase 3: User Story 1 - Read referenced objects without decoding IDs (Priority: P1) 🎯 MVP + +**Goal**: Operators can read internal model-backed references as names and context first across the highest-value surfaces instead of decoding GUIDs manually. + +**Independent Test**: Open a finding, baseline snapshot, operation run, and backup set with supported internal references and verify the UI renders label-first references with contextual type and secondary technical IDs. + +### Tests for User Story 1 + +- [X] T011 [P] [US1] Add unit coverage for core model-backed resolvers in `tests/Unit/Support/References/ModelBackedReferenceResolverTest.php` +- [X] T012 [P] [US1] Add finding and baseline-snapshot feature coverage in `tests/Feature/Filament/FindingResolvedReferencePresentationTest.php` and `tests/Feature/Filament/BaselineSnapshotResolvedReferencePresentationTest.php` +- [X] T013 [P] [US1] Add operation-run and backup-set feature coverage in `tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php` and `tests/Feature/Filament/BackupSetResolvedReferencePresentationTest.php` + +### Implementation for User Story 1 + +- [X] T014 [US1] Implement model-backed resolvers in `app/Support/References/Resolvers/PolicyReferenceResolver.php`, `app/Support/References/Resolvers/PolicyVersionReferenceResolver.php`, `app/Support/References/Resolvers/BaselineProfileReferenceResolver.php`, `app/Support/References/Resolvers/BaselineSnapshotReferenceResolver.php`, `app/Support/References/Resolvers/OperationRunReferenceResolver.php`, and `app/Support/References/Resolvers/BackupSetReferenceResolver.php` +- [X] T015 [US1] Adapt internal reference descriptors and mappings in `app/Support/Navigation/CrossResourceNavigationMatrix.php` and `app/Support/Navigation/RelatedNavigationResolver.php` +- [X] T016 [US1] Refactor finding and operation-run detail surfaces to render resolved references in `app/Filament/Resources/FindingResource.php` and `app/Filament/Resources/OperationRunResource.php` +- [X] T017 [US1] Refactor baseline-snapshot and backup-set surfaces to render resolved references in `app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php` and `app/Filament/Resources/BackupSetResource.php` + +**Checkpoint**: User Story 1 is complete when the primary internal references on the target governance and operations screens are no longer GUID-first. + +--- + +## Phase 4: User Story 2 - Understand degraded references safely (Priority: P1) + +**Goal**: Operators can distinguish resolved, partial, missing, inaccessible, and limited-context provider-backed references without losing the underlying evidence. + +**Independent Test**: Render provider-backed and assignment-like references in resolved, partial, unresolved, missing, and inaccessible states and verify each state remains visible, distinct, and non-misleading. + +### Tests for User Story 2 + +- [X] T018 [P] [US2] Add degraded-state and shared badge-vocabulary unit coverage in `tests/Unit/Support/References/ReferenceResolutionStateTest.php`, `tests/Unit/Support/References/UnsupportedReferenceResolverTest.php`, and `tests/Unit/Support/References/ReferenceStateBadgeMappingTest.php` +- [X] T019 [P] [US2] Add provider-backed group and role reference coverage in `tests/Feature/Filament/EntraGroupResolvedReferencePresentationTest.php` and `tests/Feature/Filament/TenantRoleDefinitionsSelectorDbOnlyTest.php` +- [ ] T020 [P] [US2] Add degraded-state assignment and evidence coverage in `tests/Feature/PolicyVersionViewAssignmentsTest.php`, `tests/Feature/Drift/DriftFindingDetailShowsAssignmentsDiffTest.php`, `tests/Feature/Filament/BaselineSnapshotRbacRoleDefinitionsTest.php`, and `tests/Feature/Filament/BaselineTenantAssignmentsResolvedReferencePresentationTest.php` + +### Implementation for User Story 2 + +- [X] T021 [US2] Implement provider-backed and governance resolvers in `app/Support/References/Resolvers/EntraGroupReferenceResolver.php`, `app/Support/References/Resolvers/EntraRoleDefinitionReferenceResolver.php`, `app/Support/References/Resolvers/PrincipalReferenceResolver.php`, and `app/Support/References/Resolvers/AssignmentTargetReferenceResolver.php` +- [X] T022 [US2] Refactor local group-label enrichment into structured resolution support in `app/Services/Directory/EntraGroupLabelResolver.php` and `app/Support/References/Resolvers/EntraGroupReferenceResolver.php` +- [X] T023 [US2] Implement shared degraded-state presentation, shared badge-domain mapping, and secondary technical-detail handling in `app/Support/References/ReferenceStatePresenter.php`, `resources/views/filament/infolists/entries/resolved-reference-detail.blade.php`, and `resources/views/filament/infolists/entries/resolved-reference-compact.blade.php` +- [ ] T024 [US2] Upgrade GUID-heavy directory and assignment evidence surfaces in `app/Filament/Resources/EntraGroupResource.php`, `app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php`, `app/Services/Baselines/SnapshotRendering/BaselineSnapshotPresenter.php`, and `app/Services/Baselines/SnapshotRendering/RenderedSnapshotItem.php` + +**Checkpoint**: User Story 2 is complete when degraded references look intentionally different from fully resolved ones and still preserve technical evidence secondarily. + +--- + +## Phase 5: User Story 3 - Navigate from references when allowed (Priority: P2) + +**Goal**: Authorized operators can follow resolved references to canonical destinations, while unauthorized or non-actionable references remain informative but non-clickable. + +**Independent Test**: Open supported references from in-scope screens and verify that only permitted references are actionable and that every actionable link resolves to the canonical destination for that object. + +### Tests for User Story 3 + +- [X] T025 [P] [US3] Add authorization-aware link generation unit coverage in `tests/Unit/Support/References/ReferenceLinkTargetTest.php` and `tests/Unit/Support/References/CapabilityAwareReferenceResolverTest.php` +- [X] T026 [P] [US3] Add clickable versus non-clickable RBAC coverage, including assignment-like relation-manager cases and tenant-context entry to canonical destinations, in `tests/Feature/Rbac/ResolvedReferenceAuthorizationTest.php` +- [X] T027 [P] [US3] Add canonical destination and tenant-context carryover coverage in `tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php`, `tests/Feature/Filament/PolicyVersionResolvedReferenceLinksTest.php`, and `tests/Feature/Filament/TenantContextResolvedReferenceCarryoverTest.php` + +### Implementation for User Story 3 + +- [ ] T028 [US3] Add capability-aware canonical link generation in `app/Support/References/ReferenceLinkBuilder.php` and `app/Support/OperationRunLinks.php` +- [ ] T029 [US3] Refactor shared navigation mapping to consume canonical link targets in `app/Support/Navigation/RelatedNavigationResolver.php` and `app/Support/Navigation/CrossResourceNavigationMatrix.php` +- [ ] T030 [US3] Upgrade policy-version and finding row/detail actions to use resolved reference links while preserving explicit inspect affordances and row-action limits in `app/Filament/Resources/PolicyVersionResource.php` and `app/Filament/Resources/FindingResource.php` +- [ ] T031 [US3] Upgrade operation-run, baseline-snapshot, and baseline tenant assignment contextual links to use shared canonical destinations while keeping documented action-surface exemptions current and preserving tenant-context filters, badges, or source-context metadata on canonical destinations in `app/Filament/Resources/OperationRunResource.php`, `app/Filament/Resources/BaselineSnapshotResource/Pages/ViewBaselineSnapshot.php`, `app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php`, and `app/Support/Navigation/CanonicalNavigationContext.php` + +**Checkpoint**: User Story 3 is complete when canonical linking is role-aware, predictable, and never ambiguous about clickability. + +--- + +## Phase 6: User Story 4 - Extend the same pattern to future surfaces (Priority: P3) + +**Goal**: The product can add new reference classes and new reference-heavy surfaces without reintroducing page-specific formatting logic. + +**Independent Test**: Register an unsupported or future reference class through the shared layer and verify the page degrades safely without rewriting existing target templates. + +### Tests for User Story 4 + +- [X] T032 [P] [US4] Add extensibility and unsupported-class regression coverage in `tests/Unit/Support/References/ReferenceResolverRegistryExtensibilityTest.php` and `tests/Feature/Filament/ResolvedReferenceUnsupportedClassTest.php` +- [ ] T033 [P] [US4] Add regression coverage preventing GUID-first rendering from returning in `tests/Feature/Filament/FindingResolvedReferencePresentationTest.php`, `tests/Feature/Filament/BaselineSnapshotResolvedReferencePresentationTest.php`, and `tests/Feature/Filament/EntraGroupResolvedReferencePresentationTest.php` + +### Implementation for User Story 4 + +- [X] T034 [US4] Add reusable reference registration seams in `app/Support/References/ReferenceClass.php`, `app/Support/References/ReferenceResolverRegistry.php`, and `app/Providers/AppServiceProvider.php` +- [ ] T035 [US4] Replace remaining page-specific GUID formatting branches, refresh action-surface declarations, and normalize domain-consistent operator copy across labels, helper text, link text, empty states, and degraded-state copy on touched Filament surfaces in `app/Filament/Resources/BackupSetResource.php`, `app/Filament/Resources/EntraGroupResource.php`, `app/Filament/Resources/BaselineProfileResource/RelationManagers/BaselineTenantAssignmentsRelationManager.php`, and `resources/views/filament/infolists/entries/related-context.blade.php` +- [X] T036 [US4] Add reusable compact/detail presentation adapters for future surfaces in `app/Support/References/ResolvedReferencePresenter.php` and `app/Support/References/RelatedContextReferenceAdapter.php` + +**Checkpoint**: User Story 4 is complete when a new supported reference class can be added through the shared layer without touching every existing target surface. + +--- + +## Phase 7: Polish & Cross-Cutting Concerns + +**Purpose**: Final verification, formatting, and cross-surface cleanup after all user stories are implemented. + +- [X] T037 [P] Run focused Pest verification from `specs/132-guid-context-resolver/quickstart.md` +- [X] T038 [P] Run formatting for changed files with `vendor/bin/sail bin pint --dirty --format agent` +- [ ] T039 Validate the manual QA scenarios, tenant-context carryover behavior, domain-consistent operator copy, and rollout boundary from `specs/132-guid-context-resolver/quickstart.md`, confirming the named in-scope surfaces are migrated while out-of-scope dashboards and later summary views are explicitly deferred + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies; can start immediately. +- **Foundational (Phase 2)**: Depends on Setup; blocks all user stories. +- **User Story 1 (Phase 3)**: Depends on Foundational completion. +- **User Story 2 (Phase 4)**: Depends on Foundational completion and can proceed independently of US1 once the shared layer exists. +- **User Story 3 (Phase 5)**: Depends on Foundational completion and benefits from US1 and US2 because the same resolved-reference contracts and degraded-state vocabulary will already be in place. +- **User Story 4 (Phase 6)**: Depends on Foundational completion and should land after the main surfaces prove the shared pattern works. +- **Polish (Phase 7)**: Depends on all desired user stories being complete. + +### User Story Dependencies + +- **User Story 1 (P1)**: First MVP slice; no dependency on other user stories. +- **User Story 2 (P1)**: Independent after Foundational, though it reuses the same registry, renderers, and target surfaces established by US1. +- **User Story 3 (P2)**: Independent after Foundational, but gains efficiency once US1 and US2 establish the core reference payloads and degraded-state rules. +- **User Story 4 (P3)**: Independent after Foundational but should follow the main surface rollout so extensibility is shaped by proven behavior rather than theory. + +### Within Each User Story + +- Tests should be added before or alongside implementation and must fail before the story is considered complete. +- Resolver registration and value-object work should land before surface wiring. +- Shared rendering and degraded-state presentation should be complete before final linkability or row-action cleanup. +- Authorization-aware behavior must be enforced before story verification is treated as complete. + +### Parallel Opportunities + +- Setup tasks `T002` and `T003` can run in parallel. +- In Foundational, `T007`, `T008`, `T009`, and `T010` can run in parallel after the core file layout from `T004` through `T006` is agreed. +- In US1, `T011`, `T012`, and `T013` can run in parallel. +- In US2, `T018`, `T019`, and `T020` can run in parallel. +- In US3, `T025`, `T026`, and `T027` can run in parallel. +- In US4, `T032` and `T033` can run in parallel. + +--- + +## Parallel Example: User Story 1 + +```bash +# Launch US1 test work in parallel: +T011 tests/Unit/Support/References/ModelBackedReferenceResolverTest.php +T012 tests/Feature/Filament/FindingResolvedReferencePresentationTest.php + tests/Feature/Filament/BaselineSnapshotResolvedReferencePresentationTest.php +T013 tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php + tests/Feature/Filament/BackupSetResolvedReferencePresentationTest.php +``` + +## Parallel Example: User Story 2 + +```bash +# Launch US2 test work in parallel: +T018 tests/Unit/Support/References/ReferenceResolutionStateTest.php + tests/Unit/Support/References/UnsupportedReferenceResolverTest.php +T019 tests/Feature/Filament/EntraGroupResolvedReferencePresentationTest.php + tests/Feature/Filament/TenantRoleDefinitionsSelectorDbOnlyTest.php +T020 tests/Feature/PolicyVersionViewAssignmentsTest.php + tests/Feature/Drift/DriftFindingDetailShowsAssignmentsDiffTest.php + tests/Feature/Filament/BaselineSnapshotRbacRoleDefinitionsTest.php +``` + +## Parallel Example: User Story 3 + +```bash +# Launch US3 test work in parallel: +T025 tests/Unit/Support/References/ReferenceLinkTargetTest.php + tests/Unit/Support/References/CapabilityAwareReferenceResolverTest.php +T026 tests/Feature/Rbac/ResolvedReferenceAuthorizationTest.php +T027 tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php + tests/Feature/Filament/PolicyVersionResolvedReferenceLinksTest.php +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1 Only) + +1. Complete Phase 1: Setup. +2. Complete Phase 2: Foundational. +3. Complete Phase 3: User Story 1. +4. Validate the label-first internal-reference behavior on findings, snapshots, runs, and backup sets before expanding further. + +### Incremental Delivery + +1. Ship US1 to eliminate GUID-first rendering for the primary internal references. +2. Add US2 to make degraded and provider-backed references explicit and safe. +3. Add US3 to make canonical linking capability-aware and predictable. +4. Add US4 to lock in extensibility and prevent a return to page-specific formatting logic. + +### Suggested MVP Scope + +- MVP = Phases 1 through 3, then run the focused verification from `specs/132-guid-context-resolver/quickstart.md`. + +--- + +## Format Validation + +- Every task follows the checklist format `- [ ] T### [P?] [US?] Description with file path`. +- Setup, Foundational, and Polish phases intentionally omit story labels. +- User story phases use `[US1]`, `[US2]`, `[US3]`, and `[US4]` labels. +- Parallel markers are used only where tasks can proceed independently without conflicting incomplete prerequisites. diff --git a/tests/Feature/Drift/DriftFindingDetailShowsAssignmentsDiffTest.php b/tests/Feature/Drift/DriftFindingDetailShowsAssignmentsDiffTest.php index 97a4f5a..d957135 100644 --- a/tests/Feature/Drift/DriftFindingDetailShowsAssignmentsDiffTest.php +++ b/tests/Feature/Drift/DriftFindingDetailShowsAssignmentsDiffTest.php @@ -6,7 +6,6 @@ use App\Models\InventoryItem; use App\Models\Policy; use App\Models\PolicyVersion; -use App\Services\Directory\EntraGroupLabelResolver; test('finding detail shows an assignments diff with DB-only group label resolution', function () { bindFailHardGraphClient(); @@ -131,10 +130,6 @@ 'display_name' => 'My Policy 456', ]); - $expectedGroup1 = EntraGroupLabelResolver::formatLabel('Group One', $group1); - $expectedGroup2 = EntraGroupLabelResolver::formatLabel(null, $group2); - $expectedGroup3 = EntraGroupLabelResolver::formatLabel('Group Three', $group3); - $this->actingAs($user) ->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant)) ->assertOk() @@ -142,9 +137,9 @@ ->assertSee('1 added') ->assertSee('1 removed') ->assertSee('1 changed') - ->assertSee($expectedGroup1) - ->assertSee($expectedGroup2) - ->assertSee($expectedGroup3) + ->assertSee('Group One') + ->assertSee('Group Three') + ->assertSee('Unresolved') ->assertSee('include') ->assertSee('none'); }); diff --git a/tests/Feature/Filament/BackupSetResolvedReferencePresentationTest.php b/tests/Feature/Filament/BackupSetResolvedReferencePresentationTest.php new file mode 100644 index 0000000..853b77b --- /dev/null +++ b/tests/Feature/Filament/BackupSetResolvedReferencePresentationTest.php @@ -0,0 +1,30 @@ +actingAs($user); + + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'name' => 'Nightly backup', + ]); + + $run = OperationRun::factory()->for($tenant)->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'backup_set.add_policies', + 'context' => [ + 'backup_set_id' => (int) $backupSet->getKey(), + ], + ]); + + $this->get(BackupSetResource::getUrl('view', ['record' => $backupSet], tenant: $tenant)) + ->assertOk() + ->assertSee(OperationCatalog::label((string) $run->type)) + ->assertSee('Run #'.$run->getKey()); +}); diff --git a/tests/Feature/Filament/BaselineSnapshotResolvedReferencePresentationTest.php b/tests/Feature/Filament/BaselineSnapshotResolvedReferencePresentationTest.php new file mode 100644 index 0000000..55a4afa --- /dev/null +++ b/tests/Feature/Filament/BaselineSnapshotResolvedReferencePresentationTest.php @@ -0,0 +1,37 @@ +actingAs($user); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'name' => 'Security Baseline', + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + OperationRun::factory()->for($tenant)->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'baseline_compare', + 'context' => [ + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + ], + ]); + + $this->get(BaselineSnapshotResource::getUrl('view', ['record' => $snapshot], panel: 'admin')) + ->assertOk() + ->assertSee('Security Baseline') + ->assertSee('Baseline profile #'.$profile->getKey()) + ->assertSee('Baseline compare'); +}); diff --git a/tests/Feature/Filament/EntraGroupResolvedReferencePresentationTest.php b/tests/Feature/Filament/EntraGroupResolvedReferencePresentationTest.php new file mode 100644 index 0000000..da16c48 --- /dev/null +++ b/tests/Feature/Filament/EntraGroupResolvedReferencePresentationTest.php @@ -0,0 +1,73 @@ +actingAs($user); + + $groupId = '11111111-2222-3333-4444-555555555555'; + + EntraGroup::factory()->for($tenant)->create([ + 'entra_id' => strtolower($groupId), + 'display_name' => 'Group One', + 'security_enabled' => true, + ]); + + $policy = Policy::factory()->for($tenant)->create(); + + $version = PolicyVersion::factory()->for($tenant)->create([ + 'policy_id' => (int) $policy->getKey(), + 'assignments' => [ + [ + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => $groupId, + ], + ], + [ + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.allDevicesAssignmentTarget', + ], + ], + ], + ]); + + $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)) + ->assertOk() + ->assertSee('Group One') + ->assertDontSee('Resolved') + ->assertSee('All devices'); +}); + +it('renders uncached group targets as external-only references when source context exists', function (): void { + [$user, $tenant] = createUserWithTenant(role: 'owner'); + $this->actingAs($user); + + $policy = Policy::factory()->for($tenant)->create(); + + $version = PolicyVersion::factory()->for($tenant)->create([ + 'policy_id' => (int) $policy->getKey(), + 'assignments' => [ + [ + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + 'group_display_name' => 'Source-only group', + ], + ], + ], + ]); + + $this->get(\App\Filament\Resources\PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)) + ->assertOk() + ->assertSee('Source-only group') + ->assertSee('Provider-only'); +}); diff --git a/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php b/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php new file mode 100644 index 0000000..1ac62a6 --- /dev/null +++ b/tests/Feature/Filament/FindingResolvedReferencePresentationTest.php @@ -0,0 +1,65 @@ +actingAs($user); + Filament::setTenant($tenant, true); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'name' => 'Security Baseline', + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $policy = Policy::factory()->for($tenant)->create([ + 'display_name' => 'Windows Lockdown', + ]); + + $version = PolicyVersion::factory()->for($tenant)->create([ + 'policy_id' => (int) $policy->getKey(), + 'version_number' => 3, + ]); + + $run = OperationRun::factory()->for($tenant)->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'baseline_compare', + ]); + + $finding = Finding::factory()->for($tenant)->create([ + 'current_operation_run_id' => (int) $run->getKey(), + 'evidence_jsonb' => [ + 'current' => [ + 'policy_version_id' => (int) $version->getKey(), + ], + 'provenance' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'compare_operation_run_id' => (int) $run->getKey(), + ], + ], + ]); + + $this->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant)) + ->assertOk() + ->assertSee('Security Baseline') + ->assertSee('Baseline snapshot #'.$snapshot->getKey()) + ->assertSee('Windows Lockdown') + ->assertSee('Version 3') + ->assertSee('Baseline compare') + ->assertSee('Run #'.$run->getKey()); +}); diff --git a/tests/Feature/Filament/PolicyVersionResolvedReferenceLinksTest.php b/tests/Feature/Filament/PolicyVersionResolvedReferenceLinksTest.php new file mode 100644 index 0000000..6ffcb77 --- /dev/null +++ b/tests/Feature/Filament/PolicyVersionResolvedReferenceLinksTest.php @@ -0,0 +1,41 @@ +actingAs($user); + + $groupId = '33333333-4444-5555-6666-777777777777'; + + $group = EntraGroup::factory()->for($tenant)->create([ + 'entra_id' => strtolower($groupId), + 'display_name' => 'Scoped group', + ]); + + $policy = Policy::factory()->for($tenant)->create(); + + $version = PolicyVersion::factory()->for($tenant)->create([ + 'policy_id' => (int) $policy->getKey(), + 'assignments' => [ + [ + 'intent' => 'apply', + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => $groupId, + ], + ], + ], + ]); + + $this->get(PolicyVersionResource::getUrl('view', ['record' => $version], tenant: $tenant)) + ->assertOk() + ->assertSee(EntraGroupResource::getUrl('view', ['record' => $group], tenant: $tenant), false) + ->assertSee('Scoped group'); +}); diff --git a/tests/Feature/Filament/ResolvedReferenceRenderingSmokeTest.php b/tests/Feature/Filament/ResolvedReferenceRenderingSmokeTest.php new file mode 100644 index 0000000..6997ec0 --- /dev/null +++ b/tests/Feature/Filament/ResolvedReferenceRenderingSmokeTest.php @@ -0,0 +1,38 @@ + \$reference])", + [ + 'reference' => [ + 'primaryLabel' => 'Windows Lockdown', + 'secondaryLabel' => 'Version 3', + 'stateLabel' => 'Resolved', + 'stateColor' => 'success', + 'stateIcon' => 'heroicon-m-check-circle', + 'stateDescription' => null, + 'showStateBadge' => false, + 'isLinkable' => true, + 'linkTarget' => [ + 'url' => '/admin/t/1/policy-versions/42', + 'actionLabel' => 'View policy version', + 'contextBadge' => 'Tenant', + ], + 'technicalDetail' => [ + 'displayId' => '42', + 'fullId' => '42', + 'sourceHint' => 'Captured from drift evidence', + ], + ], + ], + ); + + expect($html)->toContain('Windows Lockdown') + ->and($html)->toContain('Version 3') + ->and($html)->not->toContain('Resolved') + ->and($html)->toContain('Captured from drift evidence'); +}); diff --git a/tests/Feature/Filament/ResolvedReferenceUnsupportedClassTest.php b/tests/Feature/Filament/ResolvedReferenceUnsupportedClassTest.php new file mode 100644 index 0000000..ddd9fc7 --- /dev/null +++ b/tests/Feature/Filament/ResolvedReferenceUnsupportedClassTest.php @@ -0,0 +1,30 @@ + \$reference])", + [ + 'reference' => [ + 'primaryLabel' => 'Legacy provider object', + 'secondaryLabel' => 'External object', + 'stateLabel' => 'Unresolved', + 'stateColor' => 'gray', + 'stateIcon' => 'heroicon-m-question-mark-circle', + 'showStateBadge' => true, + 'isLinkable' => false, + 'linkTarget' => null, + 'technicalDetail' => [ + 'displayId' => '…abcd1234', + ], + ], + ], + ); + + expect($html)->toContain('Legacy provider object') + ->and($html)->toContain('Unresolved') + ->and($html)->toContain('…abcd1234'); +}); diff --git a/tests/Feature/Filament/TenantContextResolvedReferenceCarryoverTest.php b/tests/Feature/Filament/TenantContextResolvedReferenceCarryoverTest.php new file mode 100644 index 0000000..6b2cb1c --- /dev/null +++ b/tests/Feature/Filament/TenantContextResolvedReferenceCarryoverTest.php @@ -0,0 +1,46 @@ +actingAs($user); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'name' => 'Security Baseline', + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $run = OperationRun::factory()->for($tenant)->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'baseline_compare', + ]); + + $finding = Finding::factory()->for($tenant)->create([ + 'current_operation_run_id' => (int) $run->getKey(), + 'evidence_jsonb' => [ + 'provenance' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + 'compare_operation_run_id' => (int) $run->getKey(), + ], + ], + ]); + + $response = $this->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant)); + + $response->assertOk() + ->assertSee('nav%5Bsource_surface%5D=finding.detail_section', false) + ->assertSee('nav%5Btenant_id%5D='.(int) $tenant->getKey(), false); +}); diff --git a/tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php b/tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php new file mode 100644 index 0000000..9fe5902 --- /dev/null +++ b/tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php @@ -0,0 +1,29 @@ +actingAs($user); + + $backupSet = BackupSet::factory()->for($tenant)->create([ + 'name' => 'Nightly backup', + ]); + + $run = OperationRun::factory()->for($tenant)->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'type' => 'backup_set.add_policies', + 'context' => [ + 'backup_set_id' => (int) $backupSet->getKey(), + ], + ]); + + $this->get(OperationRunLinks::tenantlessView($run)) + ->assertOk() + ->assertSee('Nightly backup') + ->assertSee('Backup set #'.$backupSet->getKey()); +}); diff --git a/tests/Feature/PolicyVersionViewAssignmentsTest.php b/tests/Feature/PolicyVersionViewAssignmentsTest.php index e34739c..916a078 100644 --- a/tests/Feature/PolicyVersionViewAssignmentsTest.php +++ b/tests/Feature/PolicyVersionViewAssignmentsTest.php @@ -125,6 +125,38 @@ $response->assertSee('No assignments found for this version'); }); +it('shows a dedicated RBAC explanation instead of a Graph error for role definitions', function () { + $policy = Policy::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'policy_type' => 'intuneRoleDefinition', + ]); + + $version = PolicyVersion::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'policy_id' => $policy->id, + 'version_number' => 1, + 'policy_type' => 'intuneRoleDefinition', + 'assignments' => null, + 'metadata' => [ + 'assignments_fetch_failed' => true, + 'assignments_fetch_error' => "Parsing OData Select and Expand failed: Could not find a property named 'assignments' on type 'microsoft.graph.roleDefinition'.", + ], + ]); + + $this->actingAs($this->user); + + $response = $this->get(route('filament.admin.resources.policy-versions.view', array_merge( + filamentTenantRouteParams($this->tenant), + ['record' => $version], + ))); + + $response->assertOk(); + $response->assertSee('Standard policy assignments do not apply to Intune RBAC role definitions.'); + $response->assertSee('Role memberships and scope are modeled separately as Intune RBAC role assignments.'); + $response->assertDontSee('Assignments could not be fetched from Microsoft Graph.'); + $response->assertDontSee("Could not find a property named 'assignments' on type 'microsoft.graph.roleDefinition'."); +}); + it('shows compliance notifications when present', function () { $version = PolicyVersion::factory()->create([ 'tenant_id' => $this->tenant->id, diff --git a/tests/Feature/Rbac/CrossResourceNavigationAuthorizationTest.php b/tests/Feature/Rbac/CrossResourceNavigationAuthorizationTest.php index 09f1e09..034cddb 100644 --- a/tests/Feature/Rbac/CrossResourceNavigationAuthorizationTest.php +++ b/tests/Feature/Rbac/CrossResourceNavigationAuthorizationTest.php @@ -40,6 +40,6 @@ $this->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant)) ->assertOk() - ->assertSee('Unavailable') + ->assertSee('Access denied') ->assertSee('current scope'); }); diff --git a/tests/Feature/Rbac/ResolvedReferenceAuthorizationTest.php b/tests/Feature/Rbac/ResolvedReferenceAuthorizationTest.php new file mode 100644 index 0000000..e560bf2 --- /dev/null +++ b/tests/Feature/Rbac/ResolvedReferenceAuthorizationTest.php @@ -0,0 +1,44 @@ +actingAs($user); + + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'name' => 'Security Baseline', + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $finding = Finding::factory()->for($tenant)->create([ + 'evidence_jsonb' => [ + 'provenance' => [ + 'baseline_profile_id' => (int) $profile->getKey(), + 'baseline_snapshot_id' => (int) $snapshot->getKey(), + ], + ], + ]); + + $resolver = \Mockery::mock(WorkspaceCapabilityResolver::class); + $resolver->shouldReceive('isMember')->andReturnTrue(); + $resolver->shouldReceive('can')->andReturnFalse(); + app()->instance(WorkspaceCapabilityResolver::class, $resolver); + + $response = $this->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant)); + + $response->assertOk() + ->assertSee('Access denied') + ->assertDontSee('/admin/baseline-snapshots/'.$snapshot->getKey(), false); +}); diff --git a/tests/Unit/AssignmentBackupServiceTest.php b/tests/Unit/AssignmentBackupServiceTest.php index a04b6cf..6c7b4d1 100644 --- a/tests/Unit/AssignmentBackupServiceTest.php +++ b/tests/Unit/AssignmentBackupServiceTest.php @@ -30,6 +30,11 @@ ]; $this->mock(AssignmentFetcher::class, function (MockInterface $mock) { + $mock->shouldReceive('supportsStandardAssignments') + ->once() + ->with('settingsCatalogPolicy', null) + ->andReturnTrue(); + $mock->shouldReceive('fetch') ->once() ->andReturn([ @@ -91,3 +96,66 @@ ->and($updated->assignments[0]['target']['deviceAndAppManagementAssignmentFilterType'])->toBe('include') ->and($updated->assignments[0]['target']['assignment_filter_name'])->toBe('Targeted Devices'); }); + +it('marks role definitions as not using standard policy assignments', function () { + $tenant = Tenant::factory()->create([ + 'tenant_id' => 'tenant-123', + 'external_id' => 'tenant-123', + ]); + + ensureDefaultProviderConnection($tenant, 'microsoft'); + + $backupItem = BackupItem::factory()->create([ + 'tenant_id' => $tenant->id, + 'metadata' => [ + 'assignments_fetch_failed' => true, + 'assignments_fetch_error' => 'old error', + ], + 'assignments' => null, + ]); + + $this->mock(AssignmentFetcher::class, function (MockInterface $mock) { + $mock->shouldReceive('supportsStandardAssignments') + ->once() + ->with('intuneRoleDefinition', '#microsoft.graph.roleDefinition') + ->andReturnFalse(); + + $mock->shouldReceive('fetch')->never(); + }); + + $this->mock(ScopeTagResolver::class, function (MockInterface $mock) use ($tenant) { + $mock->shouldReceive('resolve') + ->once() + ->with(['0'], $tenant) + ->andReturn([ + ['id' => '0', 'displayName' => 'Default'], + ]); + }); + + $this->mock(GroupResolver::class, function (MockInterface $mock) { + $mock->shouldReceive('resolveGroupIds')->never(); + }); + + $this->mock(AssignmentFilterResolver::class, function (MockInterface $mock) { + $mock->shouldReceive('resolve')->never(); + }); + + $service = app(AssignmentBackupService::class); + $updated = $service->enrichWithAssignments( + backupItem: $backupItem, + tenant: $tenant, + policyType: 'intuneRoleDefinition', + policyId: 'role-def-1', + policyPayload: [ + '@odata.type' => '#microsoft.graph.roleDefinition', + 'roleScopeTagIds' => ['0'], + ], + includeAssignments: true, + ); + + expect($updated->assignments)->toBe([]) + ->and(data_get($updated->metadata, 'assignments_not_applicable'))->toBeTrue() + ->and(data_get($updated->metadata, 'assignment_capture_reason'))->toBe('separate_role_assignments') + ->and(data_get($updated->metadata, 'assignments_fetch_failed'))->toBeFalse() + ->and(data_get($updated->metadata, 'assignments_fetch_error'))->toBeNull(); +}); diff --git a/tests/Unit/AssignmentFetcherTest.php b/tests/Unit/AssignmentFetcherTest.php index 21dc5f5..3b82751 100644 --- a/tests/Unit/AssignmentFetcherTest.php +++ b/tests/Unit/AssignmentFetcherTest.php @@ -73,6 +73,20 @@ expect($result)->toBe($assignments); }); +test('returns empty without calling Graph when standard assignments do not apply', function () { + $tenantId = 'tenant-123'; + $policyId = 'role-def-456'; + $policyType = 'intuneRoleDefinition'; + + $this->graphClient + ->shouldReceive('request') + ->never(); + + $result = $this->fetcher->fetch($policyType, $tenantId, $policyId, [], true, '#microsoft.graph.roleDefinition'); + + expect($result)->toBe([]); +}); + test('does not use fallback when primary succeeds with empty assignments', function () { $tenantId = 'tenant-123'; $policyId = 'policy-456'; diff --git a/tests/Unit/Support/References/CapabilityAwareReferenceResolverTest.php b/tests/Unit/Support/References/CapabilityAwareReferenceResolverTest.php new file mode 100644 index 0000000..b656770 --- /dev/null +++ b/tests/Unit/Support/References/CapabilityAwareReferenceResolverTest.php @@ -0,0 +1,55 @@ +actingAs($user); + Filament::setTenant($tenant, true); + + $group = EntraGroup::factory()->for($tenant)->create([ + 'entra_id' => '11111111-1111-1111-1111-111111111111', + 'display_name' => 'Policy Operators', + ]); + + $resolved = app(EntraGroupReferenceResolver::class)->resolve(new ReferenceDescriptor( + referenceClass: ReferenceClass::Group, + rawIdentifier: (string) $group->entra_id, + tenantId: (int) $tenant->getKey(), + )); + + expect($resolved->state)->toBe(ReferenceResolutionState::Resolved) + ->and($resolved->isLinkable())->toBeTrue() + ->and($resolved->linkTarget?->actionLabel)->toBe('View group'); +}); + +it('suppresses group links when the actor is outside the tenant scope', function (): void { + [$owner, $tenant] = createUserWithTenant(role: 'owner'); + $group = EntraGroup::factory()->for($tenant)->create([ + 'entra_id' => '22222222-2222-2222-2222-222222222222', + 'display_name' => 'Device Targets', + ]); + + $outsider = \App\Models\User::factory()->create(); + $this->actingAs($outsider); + Filament::setTenant($tenant, true); + + $resolved = app(EntraGroupReferenceResolver::class)->resolve(new ReferenceDescriptor( + referenceClass: ReferenceClass::Group, + rawIdentifier: (string) $group->entra_id, + tenantId: (int) $tenant->getKey(), + )); + + expect($resolved->state)->toBe(ReferenceResolutionState::Inaccessible) + ->and($resolved->linkTarget)->toBeNull(); +}); diff --git a/tests/Unit/Support/References/ModelBackedReferenceResolverTest.php b/tests/Unit/Support/References/ModelBackedReferenceResolverTest.php new file mode 100644 index 0000000..6f96707 --- /dev/null +++ b/tests/Unit/Support/References/ModelBackedReferenceResolverTest.php @@ -0,0 +1,70 @@ +actingAs($user); + Filament::setTenant($tenant, true); + + $policy = Policy::factory()->for($tenant)->create([ + 'display_name' => 'Windows Lockdown', + ]); + + $version = PolicyVersion::factory()->for($tenant)->create([ + 'policy_id' => $policy->getKey(), + 'version_number' => 3, + ]); + + $resolved = app(PolicyVersionReferenceResolver::class)->resolve(new ReferenceDescriptor( + referenceClass: ReferenceClass::PolicyVersion, + rawIdentifier: (string) $version->getKey(), + tenantId: (int) $tenant->getKey(), + linkedModelId: (int) $version->getKey(), + )); + + expect($resolved->state)->toBe(ReferenceResolutionState::Resolved) + ->and($resolved->primaryLabel)->toBe('Windows Lockdown') + ->and($resolved->secondaryLabel)->toContain('Version 3') + ->and($resolved->linkTarget?->actionLabel)->toBe('View policy version'); +}); + +it('marks workspace references as inaccessible when the actor lacks workspace access', function (): void { + [$owner, $tenant] = createUserWithTenant(role: 'owner'); + $profile = BaselineProfile::factory()->active()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'name' => 'Security Baseline', + ]); + + $snapshot = BaselineSnapshot::factory()->create([ + 'workspace_id' => (int) $tenant->workspace_id, + 'baseline_profile_id' => (int) $profile->getKey(), + ]); + + $outsider = \App\Models\User::factory()->create(); + $this->actingAs($outsider); + + $resolved = app(BaselineSnapshotReferenceResolver::class)->resolve(new ReferenceDescriptor( + referenceClass: ReferenceClass::BaselineSnapshot, + rawIdentifier: (string) $snapshot->getKey(), + workspaceId: (int) $tenant->workspace_id, + linkedModelId: (int) $snapshot->getKey(), + )); + + expect($resolved->state)->toBe(ReferenceResolutionState::Inaccessible) + ->and($resolved->linkTarget)->toBeNull(); +}); diff --git a/tests/Unit/Support/References/ReferenceLinkTargetTest.php b/tests/Unit/Support/References/ReferenceLinkTargetTest.php new file mode 100644 index 0000000..6fe6b53 --- /dev/null +++ b/tests/Unit/Support/References/ReferenceLinkTargetTest.php @@ -0,0 +1,21 @@ +toArray())->toBe([ + 'targetKind' => 'policy_version', + 'url' => '/admin/t/1/policy-versions/42', + 'actionLabel' => 'View policy version', + 'contextBadge' => 'Tenant', + ]); +}); diff --git a/tests/Unit/Support/References/ReferenceResolutionStateTest.php b/tests/Unit/Support/References/ReferenceResolutionStateTest.php new file mode 100644 index 0000000..c099c08 --- /dev/null +++ b/tests/Unit/Support/References/ReferenceResolutionStateTest.php @@ -0,0 +1,17 @@ +isDegraded())->toBe($isDegraded) + ->and($state->isLinkable())->toBe($isLinkable); +})->with([ + [ReferenceResolutionState::Resolved, false, true], + [ReferenceResolutionState::PartiallyResolved, true, true], + [ReferenceResolutionState::Unresolved, true, false], + [ReferenceResolutionState::DeletedOrMissing, true, false], + [ReferenceResolutionState::Inaccessible, true, false], + [ReferenceResolutionState::ExternalLimitedContext, true, false], +]); diff --git a/tests/Unit/Support/References/ReferenceResolverRegistryExtensibilityTest.php b/tests/Unit/Support/References/ReferenceResolverRegistryExtensibilityTest.php new file mode 100644 index 0000000..6232601 --- /dev/null +++ b/tests/Unit/Support/References/ReferenceResolverRegistryExtensibilityTest.php @@ -0,0 +1,48 @@ +referenceClass, + rawIdentifier: $descriptor->rawIdentifier, + primaryLabel: 'Automation app', + secondaryLabel: 'Service principal', + state: ReferenceResolutionState::Resolved, + stateLabel: null, + linkTarget: null, + technicalDetail: ReferenceTechnicalDetail::forIdentifier($descriptor->rawIdentifier), + ); + } + }; + + $registry = new ReferenceResolverRegistry( + resolvers: [$customResolver], + fallbackResolver: new FallbackReferenceResolver, + ); + + $resolved = $registry->resolve(new ReferenceDescriptor( + referenceClass: ReferenceClass::ServicePrincipal, + rawIdentifier: 'spn-1', + )); + + expect($resolved->primaryLabel)->toBe('Automation app'); +}); diff --git a/tests/Unit/Support/References/ReferenceResolverRegistryTest.php b/tests/Unit/Support/References/ReferenceResolverRegistryTest.php new file mode 100644 index 0000000..11b7918 --- /dev/null +++ b/tests/Unit/Support/References/ReferenceResolverRegistryTest.php @@ -0,0 +1,64 @@ +referenceClass, + rawIdentifier: $descriptor->rawIdentifier, + primaryLabel: 'Matched policy', + secondaryLabel: null, + state: ReferenceResolutionState::Resolved, + stateLabel: null, + linkTarget: null, + technicalDetail: ReferenceTechnicalDetail::forIdentifier($descriptor->rawIdentifier), + ); + } + }; + + $registry = new ReferenceResolverRegistry( + resolvers: [$resolver], + fallbackResolver: new FallbackReferenceResolver, + ); + + $resolved = $registry->resolve(new ReferenceDescriptor( + referenceClass: ReferenceClass::Policy, + rawIdentifier: '17', + )); + + expect($resolved->primaryLabel)->toBe('Matched policy'); +}); + +it('falls back for unsupported classes', function (): void { + $registry = new ReferenceResolverRegistry( + resolvers: [], + fallbackResolver: new FallbackReferenceResolver, + ); + + $resolved = $registry->resolve(new ReferenceDescriptor( + referenceClass: ReferenceClass::Unsupported, + rawIdentifier: 'raw-guid', + fallbackLabel: 'Stored fallback', + )); + + expect($resolved->state)->toBe(ReferenceResolutionState::PartiallyResolved) + ->and($resolved->primaryLabel)->toBe('Stored fallback'); +}); diff --git a/tests/Unit/Support/References/ReferenceStateBadgeMappingTest.php b/tests/Unit/Support/References/ReferenceStateBadgeMappingTest.php new file mode 100644 index 0000000..ac17b38 --- /dev/null +++ b/tests/Unit/Support/References/ReferenceStateBadgeMappingTest.php @@ -0,0 +1,20 @@ +badgeSpec($state); + + expect($badge->label)->toBe($label) + ->and($badge->color)->toBe($color); +})->with([ + [ReferenceResolutionState::Resolved, 'Resolved', 'success'], + [ReferenceResolutionState::PartiallyResolved, 'Partially linked', 'warning'], + [ReferenceResolutionState::Unresolved, 'Unresolved', 'gray'], + [ReferenceResolutionState::DeletedOrMissing, 'Missing', 'gray'], + [ReferenceResolutionState::Inaccessible, 'Access denied', 'danger'], + [ReferenceResolutionState::ExternalLimitedContext, 'Provider-only', 'info'], +]); diff --git a/tests/Unit/Support/References/RelatedContextReferenceAdapterTest.php b/tests/Unit/Support/References/RelatedContextReferenceAdapterTest.php new file mode 100644 index 0000000..5f9d85f --- /dev/null +++ b/tests/Unit/Support/References/RelatedContextReferenceAdapterTest.php @@ -0,0 +1,70 @@ +adapt( + new NavigationMatrixRule('finding', 'detail_section', 'source_run', 'operation_run', 'canonical_page', 10), + new ResolvedReference( + referenceClass: ReferenceClass::OperationRun, + rawIdentifier: '44', + primaryLabel: 'Baseline compare', + secondaryLabel: 'Run #44', + state: ReferenceResolutionState::Resolved, + stateLabel: null, + linkTarget: new ReferenceLinkTarget( + targetKind: ReferenceClass::OperationRun->value, + url: '/admin/operations/44', + actionLabel: 'View run', + contextBadge: 'Tenant context', + ), + technicalDetail: ReferenceTechnicalDetail::forIdentifier('44'), + ), + ); + + expect($entry)->not->toBeNull() + ->and($entry?->targetUrl)->toBe('/admin/operations/44') + ->and($entry?->reference)->not->toBeNull() + ->and($entry?->reference['stateLabel'])->toBe('Resolved') + ->and($entry?->reference['showStateBadge'])->toBeFalse(); +}); + +it('respects hide policies for degraded references', function (): void { + $adapter = new RelatedContextReferenceAdapter( + new RelatedActionLabelCatalog, + new ResolvedReferencePresenter(new ReferenceTypeLabelCatalog, new ReferenceStatePresenter), + ); + + $entry = $adapter->adapt( + new NavigationMatrixRule('finding', 'list_row', 'baseline_snapshot', 'baseline_snapshot', 'direct_record', 10, missingStatePolicy: 'hide'), + new ResolvedReference( + referenceClass: ReferenceClass::BaselineSnapshot, + rawIdentifier: '88', + primaryLabel: 'Baseline snapshot', + secondaryLabel: null, + state: ReferenceResolutionState::Inaccessible, + stateLabel: null, + linkTarget: null, + technicalDetail: ReferenceTechnicalDetail::forIdentifier('88'), + ), + ); + + expect($entry)->toBeNull(); +}); diff --git a/tests/Unit/Support/References/ResolvedReferenceTest.php b/tests/Unit/Support/References/ResolvedReferenceTest.php new file mode 100644 index 0000000..1ce19dd --- /dev/null +++ b/tests/Unit/Support/References/ResolvedReferenceTest.php @@ -0,0 +1,70 @@ +value, + url: '/admin/t/1/policy-versions/42', + actionLabel: 'View policy version', + contextBadge: 'Tenant', + ), + technicalDetail: ReferenceTechnicalDetail::forIdentifier('42', 'Captured from drift evidence'), + meta: ['foo' => 'bar'], + ); + + expect($reference->isLinkable())->toBeTrue() + ->and($reference->toArray())->toMatchArray([ + 'referenceClass' => 'policy_version', + 'rawIdentifier' => '42', + 'primaryLabel' => 'Windows Lockdown', + 'secondaryLabel' => 'Version 3', + 'state' => 'resolved', + 'linkTarget' => [ + 'targetKind' => 'policy_version', + 'url' => '/admin/t/1/policy-versions/42', + 'actionLabel' => 'View policy version', + 'contextBadge' => 'Tenant', + ], + 'technicalDetail' => [ + 'displayId' => '42', + 'fullId' => '42', + 'sourceHint' => 'Captured from drift evidence', + 'copyable' => true, + 'defaultCollapsed' => true, + ], + 'meta' => ['foo' => 'bar'], + ]); +}); + +it('drops linkability when the link target is removed', function (): void { + $reference = new ResolvedReference( + referenceClass: ReferenceClass::Policy, + rawIdentifier: '13', + primaryLabel: 'Baseline policy', + secondaryLabel: null, + state: ReferenceResolutionState::Resolved, + stateLabel: null, + linkTarget: new ReferenceLinkTarget( + targetKind: ReferenceClass::Policy->value, + url: '/admin/t/1/policies/13', + actionLabel: 'View policy', + ), + technicalDetail: ReferenceTechnicalDetail::forIdentifier('13'), + ); + + expect($reference->withLinkTarget(null)->isLinkable())->toBeFalse(); +}); diff --git a/tests/Unit/Support/References/UnsupportedReferenceResolverTest.php b/tests/Unit/Support/References/UnsupportedReferenceResolverTest.php new file mode 100644 index 0000000..9f3bd54 --- /dev/null +++ b/tests/Unit/Support/References/UnsupportedReferenceResolverTest.php @@ -0,0 +1,30 @@ +resolve(new ReferenceDescriptor( + referenceClass: ReferenceClass::Unsupported, + rawIdentifier: 'guid-123', + fallbackLabel: 'Provider object', + )); + + expect($resolved->state)->toBe(ReferenceResolutionState::PartiallyResolved) + ->and($resolved->primaryLabel)->toBe('Provider object') + ->and($resolved->linkTarget)->toBeNull(); +}); + +it('keeps unsupported references visible when no fallback label exists', function (): void { + $resolved = app(FallbackReferenceResolver::class)->resolve(new ReferenceDescriptor( + referenceClass: ReferenceClass::Unsupported, + rawIdentifier: 'guid-123', + )); + + expect($resolved->state)->toBe(ReferenceResolutionState::Unresolved) + ->and($resolved->primaryLabel)->toBe('Unresolved reference'); +});