Compare commits

...

1 Commits

Author SHA1 Message Date
Ahmed Darrazi
0415e9af88 feat: add resolved reference presentation layer 2026-03-10 19:51:41 +01:00
75 changed files with 4332 additions and 261 deletions

View File

@ -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
<!-- MANUAL ADDITIONS START -->
<!-- MANUAL ADDITIONS END -->

View File

@ -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<string, string>
* @return array<int, array<string, mixed>>
*/
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;
}
/**

View File

@ -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<int, ReferenceResolver> $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,

View File

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

View File

@ -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<int, string> $groupIds
* @param array<string, string> $fallbackLabels
* @return array<string, array{display_name: ?string, resolved: bool}>
*/
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<int, string> $groupIds
* @return array<string, string> Map of groupId (lowercased UUID) => display_name

View File

@ -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<int, array<string, mixed>> $added
* @param array<int, array<string, mixed>> $removed
* @param array<int, array<string, mixed>> $changed
* @return array<string, string>
* @return array<string, array{display_name: ?string, resolved: bool}>
*/
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<string, mixed> $assignment
* @param array<string, string> $groupLabels
* @param array<string, array{display_name: ?string, resolved: bool}> $groupDescriptions
* @return array<string, mixed>
*/
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,
);
}
}

View File

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

View File

@ -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,
];
/**

View File

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

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Support\Badges\Domains;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeMapper;
use App\Support\Badges\BadgeSpec;
use App\Support\References\ReferenceResolutionState;
final class ReferenceResolutionStateBadge implements BadgeMapper
{
public function spec(mixed $value): BadgeSpec
{
$state = ReferenceResolutionState::tryFrom((string) BadgeCatalog::normalizeState($value));
return match ($state) {
ReferenceResolutionState::Resolved => 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(),
};
}
}

View File

@ -6,6 +6,9 @@
final readonly class RelatedContextEntry
{
/**
* @param array<string, mixed>|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<string, mixed> $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<string, mixed>|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,
];
}
}

View File

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

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Support\References\Contracts;
use App\Support\References\ReferenceClass;
use App\Support\References\ReferenceDescriptor;
use App\Support\References\ResolvedReference;
interface ReferenceResolver
{
public function referenceClass(): ReferenceClass;
public function resolve(ReferenceDescriptor $descriptor): ResolvedReference;
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Support\References;
enum ReferenceClass: string
{
case Policy = 'policy';
case PolicyVersion = 'policy_version';
case BaselineProfile = 'baseline_profile';
case BaselineSnapshot = 'baseline_snapshot';
case OperationRun = 'operation_run';
case BackupSet = 'backup_set';
case RoleDefinition = 'role_definition';
case Principal = 'principal';
case Group = 'group';
case ServicePrincipal = 'service_principal';
case TenantExternalObject = 'tenant_external_object';
case Unsupported = 'unsupported';
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Support\References;
final readonly class ReferenceDescriptor
{
/**
* @param array<string, mixed> $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);
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Support\References;
final readonly class ReferenceLinkTarget
{
public function __construct(
public string $targetKind,
public string $url,
public string $actionLabel,
public ?string $contextBadge = null,
) {}
/**
* @return array{targetKind: string, url: string, actionLabel: string, contextBadge: ?string}
*/
public function toArray(): array
{
return [
'targetKind' => $this->targetKind,
'url' => $this->url,
'actionLabel' => $this->actionLabel,
'contextBadge' => $this->contextBadge,
];
}
}

View File

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace App\Support\References;
enum ReferencePresentationVariant: string
{
case Compact = 'compact';
case Detail = 'detail';
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Support\References;
enum ReferenceResolutionState: string
{
case Resolved = 'resolved';
case PartiallyResolved = 'partially_resolved';
case Unresolved = 'unresolved';
case DeletedOrMissing = 'deleted_or_missing';
case Inaccessible = 'inaccessible';
case ExternalLimitedContext = 'external_limited_context';
public function isDegraded(): bool
{
return $this !== self::Resolved;
}
public function isLinkable(): bool
{
return $this === self::Resolved || $this === self::PartiallyResolved;
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Support\References;
use App\Support\References\Contracts\ReferenceResolver;
final class ReferenceResolverRegistry
{
/**
* @param iterable<int, ReferenceResolver> $resolvers
*/
public function __construct(
iterable $resolvers,
private readonly ReferenceResolver $fallbackResolver,
) {
foreach ($resolvers as $resolver) {
$this->resolvers[$resolver->referenceClass()->value] = $resolver;
}
}
/**
* @var array<string, ReferenceResolver>
*/
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);
}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Support\References;
use App\Support\Badges\BadgeDomain;
use App\Support\Badges\BadgeRenderer;
use App\Support\Badges\BadgeSpec;
final class ReferenceStatePresenter
{
public function badgeSpec(ReferenceResolutionState|string $state): BadgeSpec
{
$normalized = $state instanceof ReferenceResolutionState ? $state->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.",
};
}
}

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Support\References;
final readonly class ReferenceTechnicalDetail
{
public function __construct(
public ?string $displayId,
public string $fullId,
public ?string $sourceHint = null,
public bool $copyable = true,
public bool $defaultCollapsed = true,
) {}
public static function forIdentifier(
string $fullId,
?string $sourceHint = null,
bool $copyable = true,
bool $defaultCollapsed = true,
): self {
return new self(
displayId: self::displayId($fullId),
fullId: $fullId,
sourceHint: $sourceHint,
copyable: $copyable,
defaultCollapsed: $defaultCollapsed,
);
}
/**
* @return array{displayId: ?string, fullId: string, sourceHint: ?string, copyable: bool, defaultCollapsed: bool}
*/
public function toArray(): array
{
return [
'displayId' => $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);
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Support\References;
use Illuminate\Support\Str;
final class ReferenceTypeLabelCatalog
{
/**
* @var array<string, string>
*/
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));
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Support\References;
use App\Support\Navigation\NavigationMatrixRule;
use App\Support\Navigation\RelatedActionLabelCatalog;
use App\Support\Navigation\RelatedContextEntry;
final class RelatedContextReferenceAdapter
{
public function __construct(
private readonly RelatedActionLabelCatalog $labels,
private readonly ResolvedReferencePresenter $presenter,
) {}
public function adapt(NavigationMatrixRule $rule, ResolvedReference $reference): ?RelatedContextEntry
{
if ($rule->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),
);
}
}

View File

@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Support\References;
final readonly class ResolvedReference
{
/**
* @param array<string, mixed> $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<string, mixed>
* }
*/
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,
];
}
}

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Support\References;
final class ResolvedReferencePresenter
{
public function __construct(
private readonly ReferenceTypeLabelCatalog $typeLabels,
private readonly ReferenceStatePresenter $statePresenter,
) {}
/**
* @return array{
* referenceClass: string,
* typeLabel: string,
* primaryLabel: string,
* secondaryLabel: ?string,
* state: string,
* stateLabel: string,
* stateColor: string,
* stateIcon: ?string,
* stateIconColor: ?string,
* stateDescription: ?string,
* showStateBadge: bool,
* 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,
* isDegraded: bool,
* variant: string,
* meta: array<string, mixed>
* }
*/
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,
];
}
}

View File

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Support\References\Resolvers;
use App\Support\References\ReferenceClass;
use App\Support\References\ReferenceDescriptor;
use App\Support\References\ReferenceResolutionState;
use App\Support\References\ReferenceResolverRegistry;
use App\Support\References\ReferenceTechnicalDetail;
use App\Support\References\ResolvedReference;
final class AssignmentTargetReferenceResolver
{
public function __construct(
private readonly ReferenceResolverRegistry $registry,
) {}
/**
* @param array<string, mixed> $target
* @param array<string, mixed> $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',
],
));
}
}

View File

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Support\References\Resolvers;
use App\Filament\Resources\BackupSetResource;
use App\Models\BackupSet;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\References\ReferenceClass;
use App\Support\References\ReferenceDescriptor;
use App\Support\References\ReferenceLinkTarget;
use App\Support\References\ResolvedReference;
final class BackupSetReferenceResolver extends BaseReferenceResolver
{
public function __construct(
\App\Support\References\ReferenceTypeLabelCatalog $typeLabels,
private readonly CapabilityResolver $capabilityResolver,
) {
parent::__construct($typeLabels);
}
public function referenceClass(): ReferenceClass
{
return ReferenceClass::BackupSet;
}
public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
{
$backupSetId = $this->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);
}
}

View File

@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace App\Support\References\Resolvers;
use App\Support\References\Contracts\ReferenceResolver;
use App\Support\References\ReferenceDescriptor;
use App\Support\References\ReferenceLinkTarget;
use App\Support\References\ReferenceResolutionState;
use App\Support\References\ReferenceTechnicalDetail;
use App\Support\References\ReferenceTypeLabelCatalog;
use App\Support\References\ResolvedReference;
abstract class BaseReferenceResolver implements ReferenceResolver
{
public function __construct(
protected readonly ReferenceTypeLabelCatalog $typeLabels,
) {}
protected function technicalDetail(ReferenceDescriptor $descriptor, ?string $sourceHint = null): ReferenceTechnicalDetail
{
$hint = $sourceHint;
if (! is_string($hint) || trim($hint) === '') {
$hint = is_string($descriptor->contextValue('source_hint')) ? $descriptor->contextValue('source_hint') : null;
}
return ReferenceTechnicalDetail::forIdentifier(
fullId: $descriptor->rawIdentifier,
sourceHint: $hint,
);
}
/**
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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;
}
}

View File

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Support\References\Resolvers;
use App\Filament\Resources\BaselineProfileResource;
use App\Models\BaselineProfile;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\References\ReferenceClass;
use App\Support\References\ReferenceDescriptor;
use App\Support\References\ReferenceLinkTarget;
use App\Support\References\ResolvedReference;
final class BaselineProfileReferenceResolver extends BaseReferenceResolver
{
public function __construct(
\App\Support\References\ReferenceTypeLabelCatalog $typeLabels,
private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
) {
parent::__construct($typeLabels);
}
public function referenceClass(): ReferenceClass
{
return ReferenceClass::BaselineProfile;
}
public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
{
$profileId = $this->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);
}
}

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Support\References\Resolvers;
use App\Filament\Resources\BaselineSnapshotResource;
use App\Models\BaselineSnapshot;
use App\Models\User;
use App\Models\Workspace;
use App\Services\Auth\WorkspaceCapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\References\ReferenceClass;
use App\Support\References\ReferenceDescriptor;
use App\Support\References\ReferenceLinkTarget;
use App\Support\References\ResolvedReference;
final class BaselineSnapshotReferenceResolver extends BaseReferenceResolver
{
public function __construct(
\App\Support\References\ReferenceTypeLabelCatalog $typeLabels,
private readonly WorkspaceCapabilityResolver $workspaceCapabilityResolver,
) {
parent::__construct($typeLabels);
}
public function referenceClass(): ReferenceClass
{
return ReferenceClass::BaselineSnapshot;
}
public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
{
$snapshotId = $this->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);
}
}

View File

@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Support\References\Resolvers;
use App\Filament\Resources\EntraGroupResource;
use App\Models\EntraGroup;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Directory\EntraGroupLabelResolver;
use App\Support\Auth\Capabilities;
use App\Support\References\ReferenceClass;
use App\Support\References\ReferenceDescriptor;
use App\Support\References\ReferenceLinkTarget;
use App\Support\References\ResolvedReference;
final class EntraGroupReferenceResolver extends BaseReferenceResolver
{
public function __construct(
\App\Support\References\ReferenceTypeLabelCatalog $typeLabels,
private readonly CapabilityResolver $capabilityResolver,
private readonly EntraGroupLabelResolver $groupLabelResolver,
) {
parent::__construct($typeLabels);
}
public function referenceClass(): ReferenceClass
{
return ReferenceClass::Group;
}
public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
{
$tenantId = $descriptor->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';
}
}

View File

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Support\References\Resolvers;
use App\Models\EntraRoleDefinition;
use App\Support\References\ReferenceClass;
use App\Support\References\ReferenceDescriptor;
use App\Support\References\ResolvedReference;
final class EntraRoleDefinitionReferenceResolver extends BaseReferenceResolver
{
public function __construct(
\App\Support\References\ReferenceTypeLabelCatalog $typeLabels,
) {
parent::__construct($typeLabels);
}
public function referenceClass(): ReferenceClass
{
return ReferenceClass::RoleDefinition;
}
public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
{
$tenantId = $descriptor->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');
}
}

View File

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Support\References\Resolvers;
use App\Support\References\Contracts\ReferenceResolver;
use App\Support\References\ReferenceClass;
use App\Support\References\ReferenceDescriptor;
use App\Support\References\ReferenceResolutionState;
use App\Support\References\ReferenceTechnicalDetail;
use App\Support\References\ResolvedReference;
final class FallbackReferenceResolver implements ReferenceResolver
{
public function referenceClass(): ReferenceClass
{
return ReferenceClass::Unsupported;
}
public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
{
$primaryLabel = $descriptor->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'),
],
);
}
}

View File

@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Support\References\Resolvers;
use App\Models\OperationRun;
use App\Models\User;
use App\Support\Navigation\CanonicalNavigationContext;
use App\Support\OperationCatalog;
use App\Support\OperationRunLinks;
use App\Support\References\ReferenceClass;
use App\Support\References\ReferenceDescriptor;
use App\Support\References\ReferenceLinkTarget;
use App\Support\References\ResolvedReference;
final class OperationRunReferenceResolver extends BaseReferenceResolver
{
public function __construct(
\App\Support\References\ReferenceTypeLabelCatalog $typeLabels,
) {
parent::__construct($typeLabels);
}
public function referenceClass(): ReferenceClass
{
return ReferenceClass::OperationRun;
}
public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
{
$runId = $this->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);
}
}

View File

@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Support\References\Resolvers;
use App\Filament\Resources\PolicyResource;
use App\Models\Policy;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\References\ReferenceClass;
use App\Support\References\ReferenceDescriptor;
use App\Support\References\ReferenceLinkTarget;
use App\Support\References\ResolvedReference;
final class PolicyReferenceResolver extends BaseReferenceResolver
{
public function __construct(
\App\Support\References\ReferenceTypeLabelCatalog $typeLabels,
private readonly CapabilityResolver $capabilityResolver,
) {
parent::__construct($typeLabels);
}
public function referenceClass(): ReferenceClass
{
return ReferenceClass::Policy;
}
public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
{
$policyId = $this->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);
}
}

View File

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Support\References\Resolvers;
use App\Filament\Resources\PolicyVersionResource;
use App\Models\PolicyVersion;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Support\Auth\Capabilities;
use App\Support\References\ReferenceClass;
use App\Support\References\ReferenceDescriptor;
use App\Support\References\ReferenceLinkTarget;
use App\Support\References\ResolvedReference;
final class PolicyVersionReferenceResolver extends BaseReferenceResolver
{
public function __construct(
\App\Support\References\ReferenceTypeLabelCatalog $typeLabels,
private readonly CapabilityResolver $capabilityResolver,
) {
parent::__construct($typeLabels);
}
public function referenceClass(): ReferenceClass
{
return ReferenceClass::PolicyVersion;
}
public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
{
$policyVersionId = $this->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);
}
}

View File

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Support\References\Resolvers;
use App\Support\References\ReferenceClass;
use App\Support\References\ReferenceDescriptor;
use App\Support\References\ResolvedReference;
use Illuminate\Support\Str;
final class PrincipalReferenceResolver extends BaseReferenceResolver
{
public function __construct(
\App\Support\References\ReferenceTypeLabelCatalog $typeLabels,
) {
parent::__construct($typeLabels);
}
public function referenceClass(): ReferenceClass
{
return ReferenceClass::Principal;
}
public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
{
$principalType = $this->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);
}
}

View File

@ -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
<div class="rounded-lg border border-gray-200/70 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
<div class="font-medium">
{{ $to['target_label'] }}
</div>
@if (is_array($to['target_reference']))
@include('filament.infolists.entries.resolved-reference-compact', ['reference' => $to['target_reference']])
@else
<div class="font-medium">
{{ $to['target_label'] }}
</div>
@endif
<div class="mt-2 grid gap-2 text-sm md:grid-cols-2">
<div>
@ -85,7 +90,11 @@
@php $row = $renderRow(is_array($row) ? $row : []); @endphp
<div class="rounded-lg border border-gray-200/70 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-900">
<div class="font-medium">{{ $row['target_label'] }}</div>
@if (is_array($row['target_reference']))
@include('filament.infolists.entries.resolved-reference-compact', ['reference' => $row['target_reference']])
@else
<div class="font-medium">{{ $row['target_label'] }}</div>
@endif
<div class="mt-1 text-gray-600 dark:text-gray-300">
{{ $row['include_exclude'] }} · filter: {{ $row['filter_type'] }}
</div>
@ -102,7 +111,11 @@
@php $row = $renderRow(is_array($row) ? $row : []); @endphp
<div class="rounded-lg border border-gray-200/70 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-900">
<div class="font-medium">{{ $row['target_label'] }}</div>
@if (is_array($row['target_reference']))
@include('filament.infolists.entries.resolved-reference-compact', ['reference' => $row['target_reference']])
@else
<div class="font-medium">{{ $row['target_label'] }}</div>
@endif
<div class="mt-1 text-gray-600 dark:text-gray-300">
{{ $row['include_exclude'] }} · filter: {{ $row['filter_type'] }}
</div>

View File

@ -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
<div class="rounded-xl border border-gray-200 bg-white/80 px-4 py-3 shadow-sm dark:border-gray-800 dark:bg-gray-900/60">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 space-y-1">
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
{{ $entry['label'] ?? 'Related record' }}
<div class="space-y-2">
<div class="text-xs font-medium uppercase tracking-[0.18em] text-gray-500 dark:text-gray-400">
{{ $entry['label'] ?? 'Related record' }}
</div>
@if ($reference !== null)
@include('filament.infolists.entries.resolved-reference-detail', ['reference' => $reference])
@else
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 space-y-1">
@if ($isAvailable)
<a href="{{ $entry['targetUrl'] }}" class="text-sm font-semibold text-primary-600 hover:text-primary-500 hover:underline dark:text-primary-400">
{{ $entry['value'] ?? 'Open related record' }}
</a>
@else
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $entry['value'] ?? 'Unavailable' }}
</div>
@endif
@if (filled($entry['secondaryValue'] ?? null))
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $entry['secondaryValue'] }}
</div>
@endif
@if (filled($entry['unavailableReason'] ?? null))
<div class="text-xs text-gray-600 dark:text-gray-300">
{{ $entry['unavailableReason'] }}
</div>
@endif
</div>
<div class="flex shrink-0 items-center gap-2">
@if ($isAvailable && filled($entry['actionLabel'] ?? null))
<a
href="{{ $entry['targetUrl'] }}"
class="text-xs font-medium text-primary-600 hover:text-primary-500 hover:underline dark:text-primary-400"
>
{{ $entry['actionLabel'] }}
</a>
@endif
@if (filled($entry['contextBadge'] ?? null))
<x-filament::badge color="gray" size="sm">
{{ $entry['contextBadge'] }}
</x-filament::badge>
@endif
@unless ($isAvailable)
<x-filament::badge color="gray" size="sm">
Unavailable
</x-filament::badge>
@endunless
</div>
</div>
@if ($isAvailable)
<a href="{{ $entry['targetUrl'] }}" class="text-sm font-semibold text-primary-600 hover:text-primary-500 hover:underline dark:text-primary-400">
{{ $entry['value'] ?? 'Open related record' }}
</a>
@else
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $entry['value'] ?? 'Unavailable' }}
</div>
@endif
@if (filled($entry['secondaryValue'] ?? null))
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $entry['secondaryValue'] }}
</div>
@endif
@if (filled($entry['unavailableReason'] ?? null))
<div class="text-xs text-gray-600 dark:text-gray-300">
{{ $entry['unavailableReason'] }}
</div>
@endif
</div>
<div class="flex shrink-0 items-center gap-2">
@if ($isAvailable && filled($entry['actionLabel'] ?? null))
<a
href="{{ $entry['targetUrl'] }}"
class="text-xs font-medium text-primary-600 hover:text-primary-500 hover:underline dark:text-primary-400"
>
{{ $entry['actionLabel'] }}
</a>
@endif
@if (filled($entry['contextBadge'] ?? null))
<x-filament::badge color="gray" size="sm">
{{ $entry['contextBadge'] }}
</x-filament::badge>
@endif
@unless ($isAvailable)
<x-filament::badge color="gray" size="sm">
Unavailable
</x-filament::badge>
@endunless
</div>
@endif
</div>
</div>
@endforeach

View File

@ -0,0 +1,34 @@
<div class="space-y-1">
@if (($reference['isLinkable'] ?? false) === true && filled(data_get($reference, 'linkTarget.url')))
<a
href="{{ data_get($reference, 'linkTarget.url') }}"
class="font-medium text-primary-600 hover:text-primary-500 hover:underline dark:text-primary-400"
>
{{ $reference['primaryLabel'] ?? 'Reference' }}
</a>
@else
<div class="font-medium text-gray-900 dark:text-white">
{{ $reference['primaryLabel'] ?? 'Reference' }}
</div>
@endif
<div class="flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
@if (filled($reference['secondaryLabel'] ?? null))
<span>{{ $reference['secondaryLabel'] }}</span>
@endif
@if ((data_get($reference, 'showStateBadge', false)) === true)
<x-filament::badge
:color="data_get($reference, 'stateColor', 'gray')"
:icon="data_get($reference, 'stateIcon')"
size="sm"
>
{{ data_get($reference, 'stateLabel', 'Unknown') }}
</x-filament::badge>
@endif
@if (filled(data_get($reference, 'technicalDetail.displayId')))
<span>ID {{ data_get($reference, 'technicalDetail.displayId') }}</span>
@endif
</div>
</div>

View File

@ -0,0 +1,65 @@
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 space-y-2">
@if (($reference['isLinkable'] ?? false) === true && filled(data_get($reference, 'linkTarget.url')))
<a
href="{{ data_get($reference, 'linkTarget.url') }}"
class="text-sm font-semibold text-primary-600 hover:text-primary-500 hover:underline dark:text-primary-400"
>
{{ $reference['primaryLabel'] ?? 'Reference' }}
</a>
@else
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $reference['primaryLabel'] ?? 'Reference' }}
</div>
@endif
@if (filled($reference['secondaryLabel'] ?? null))
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $reference['secondaryLabel'] }}
</div>
@endif
@if (filled($reference['stateDescription'] ?? null))
<div class="text-xs text-gray-600 dark:text-gray-300">
{{ $reference['stateDescription'] }}
</div>
@endif
@if (filled(data_get($reference, 'technicalDetail.fullId')))
<div class="text-xs text-gray-500 dark:text-gray-400">
ID: {{ data_get($reference, 'technicalDetail.displayId') ?: data_get($reference, 'technicalDetail.fullId') }}
@if (filled(data_get($reference, 'technicalDetail.sourceHint')))
<span class="ml-2">{{ data_get($reference, 'technicalDetail.sourceHint') }}</span>
@endif
</div>
@endif
</div>
<div class="flex shrink-0 items-center gap-2">
@if ((data_get($reference, 'showStateBadge', false)) === true)
<x-filament::badge
:color="data_get($reference, 'stateColor', 'gray')"
:icon="data_get($reference, 'stateIcon')"
size="sm"
>
{{ data_get($reference, 'stateLabel', 'Unknown') }}
</x-filament::badge>
@endif
@if (filled(data_get($reference, 'linkTarget.contextBadge')))
<x-filament::badge color="gray" size="sm">
{{ data_get($reference, 'linkTarget.contextBadge') }}
</x-filament::badge>
@endif
@if (($reference['isLinkable'] ?? false) === true && filled(data_get($reference, 'linkTarget.actionLabel')))
<a
href="{{ data_get($reference, 'linkTarget.url') }}"
class="text-xs font-medium text-primary-600 hover:text-primary-500 hover:underline dark:text-primary-400"
>
{{ data_get($reference, 'linkTarget.actionLabel') }}
</a>
@endif
</div>
</div>

View File

@ -65,27 +65,20 @@
<span class="text-gray-600 dark:text-gray-400"></span>
<span class="font-medium text-gray-900 dark:text-white">{{ $typeName }}</span>
@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))
<span class="text-gray-600 dark:text-gray-400">:</span>
@if($groupOrphaned)
<span class="text-warning-600 dark:text-warning-400">
⚠️ {{ $groupLabel }}
</span>
@elseif($groupLabel)
<span class="text-gray-700 dark:text-gray-300">
{{ $groupLabel }}
</span>
@else
<span class="text-gray-700 dark:text-gray-300">
{{ $groupId }}
</span>
@endif
<div class="min-w-0">
@include('filament.infolists.entries.resolved-reference-compact', ['reference' => $targetReference])
</div>
@elseif($groupId)
<span class="text-gray-600 dark:text-gray-400">:</span>
<span class="text-gray-700 dark:text-gray-300">
{{ $groupName ?: $groupId }}
</span>
@endif
@if($filterLabel)
@ -104,12 +97,20 @@
@else
<x-filament::section heading="Assignments">
@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)
<p class="text-sm text-gray-500 dark:text-gray-400">
Standard policy assignments do not apply to Intune RBAC role definitions.
</p>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-500">
Role memberships and scope are modeled separately as Intune RBAC role assignments.
</p>
@elseif($assignmentsFetchFailed)
<p class="text-sm text-gray-500 dark:text-gray-400">
Assignments could not be fetched from Microsoft Graph.
</p>

View File

@ -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`.

View File

@ -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

View File

@ -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<string, mixed> | 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<string, mixed> | 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.

View File

@ -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 todays 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.

View File

@ -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.

View File

@ -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 specs 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 products 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 repos 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.

View File

@ -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 pages 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.

View File

@ -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.

View File

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

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BackupSetResource;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Support\OperationCatalog;
it('renders backup set related context with the source operation label and id', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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());
});

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\BaselineSnapshotResource;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\OperationRun;
it('renders baseline snapshot related context with profile and run details', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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');
});

View File

@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
use App\Models\EntraGroup;
use App\Models\Policy;
use App\Models\PolicyVersion;
it('renders cached group targets as resolved compact references on policy versions', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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');
});

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\FindingResource;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use Filament\Facades\Filament;
it('renders finding related context with human labels before technical ids', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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());
});

View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\EntraGroupResource;
use App\Filament\Resources\PolicyVersionResource;
use App\Models\EntraGroup;
use App\Models\Policy;
use App\Models\PolicyVersion;
it('renders canonical group links for resolved assignment targets', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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');
});

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Blade;
it('renders the resolved reference detail partial', function (): void {
$html = Blade::render(
"@include('filament.infolists.entries.resolved-reference-detail', ['reference' => \$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');
});

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Blade;
it('renders unsupported references without hiding the preserved identifier', function (): void {
$html = Blade::render(
"@include('filament.infolists.entries.resolved-reference-compact', ['reference' => \$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');
});

View File

@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\FindingResource;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\Finding;
use App\Models\OperationRun;
it('preserves tenant-context carryover on finding-to-run links', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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);
});

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Support\OperationRunLinks;
it('renders operation run related context with backup set details', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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());
});

View File

@ -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,

View File

@ -40,6 +40,6 @@
$this->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
->assertOk()
->assertSee('Unavailable')
->assertSee('Access denied')
->assertSee('current scope');
});

View File

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
use App\Filament\Resources\FindingResource;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\Finding;
use App\Services\Auth\WorkspaceCapabilityResolver;
it('renders inaccessible references without an actionable link', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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);
});

View File

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

View File

@ -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';

View File

@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use App\Models\EntraGroup;
use App\Support\References\ReferenceClass;
use App\Support\References\ReferenceDescriptor;
use App\Support\References\ReferenceResolutionState;
use App\Support\References\Resolvers\EntraGroupReferenceResolver;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('returns linkable group references for in-scope tenant members', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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();
});

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Support\References\ReferenceClass;
use App\Support\References\ReferenceDescriptor;
use App\Support\References\ReferenceResolutionState;
use App\Support\References\Resolvers\BaselineSnapshotReferenceResolver;
use App\Support\References\Resolvers\PolicyVersionReferenceResolver;
use Filament\Facades\Filament;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('resolves policy versions as label-first tenant references', function (): void {
[$user, $tenant] = createUserWithTenant(role: 'owner');
$this->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();
});

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
use App\Support\References\ReferenceLinkTarget;
it('serializes reference link targets', function (): void {
$target = new ReferenceLinkTarget(
targetKind: 'policy_version',
url: '/admin/t/1/policy-versions/42',
actionLabel: 'View policy version',
contextBadge: 'Tenant',
);
expect($target->toArray())->toBe([
'targetKind' => 'policy_version',
'url' => '/admin/t/1/policy-versions/42',
'actionLabel' => 'View policy version',
'contextBadge' => 'Tenant',
]);
});

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
use App\Support\References\ReferenceResolutionState;
it('tracks degraded and linkable states consistently', function (ReferenceResolutionState $state, bool $isDegraded, bool $isLinkable): void {
expect($state->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],
]);

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use App\Support\References\Contracts\ReferenceResolver;
use App\Support\References\ReferenceClass;
use App\Support\References\ReferenceDescriptor;
use App\Support\References\ReferenceResolutionState;
use App\Support\References\ReferenceResolverRegistry;
use App\Support\References\ReferenceTechnicalDetail;
use App\Support\References\ResolvedReference;
use App\Support\References\Resolvers\FallbackReferenceResolver;
it('supports registering additional resolvers without affecting existing ones', function (): void {
$customResolver = new class implements ReferenceResolver
{
public function referenceClass(): ReferenceClass
{
return ReferenceClass::ServicePrincipal;
}
public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
{
return new ResolvedReference(
referenceClass: $descriptor->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');
});

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
use App\Support\References\Contracts\ReferenceResolver;
use App\Support\References\ReferenceClass;
use App\Support\References\ReferenceDescriptor;
use App\Support\References\ReferenceResolutionState;
use App\Support\References\ReferenceResolverRegistry;
use App\Support\References\ReferenceTechnicalDetail;
use App\Support\References\ResolvedReference;
use App\Support\References\Resolvers\FallbackReferenceResolver;
it('dispatches to the matching resolver', function (): void {
$resolver = new class implements ReferenceResolver
{
public function referenceClass(): ReferenceClass
{
return ReferenceClass::Policy;
}
public function resolve(ReferenceDescriptor $descriptor): ResolvedReference
{
return new ResolvedReference(
referenceClass: $descriptor->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');
});

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
use App\Support\References\ReferenceResolutionState;
use App\Support\References\ReferenceStatePresenter;
it('maps reference states to badge vocabulary', function (ReferenceResolutionState $state, string $label, string $color): void {
$badge = app(ReferenceStatePresenter::class)->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'],
]);

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
use App\Support\Navigation\NavigationMatrixRule;
use App\Support\Navigation\RelatedActionLabelCatalog;
use App\Support\References\ReferenceClass;
use App\Support\References\ReferenceLinkTarget;
use App\Support\References\ReferenceResolutionState;
use App\Support\References\ReferenceStatePresenter;
use App\Support\References\ReferenceTechnicalDetail;
use App\Support\References\ReferenceTypeLabelCatalog;
use App\Support\References\RelatedContextReferenceAdapter;
use App\Support\References\ResolvedReference;
use App\Support\References\ResolvedReferencePresenter;
it('adapts a resolved reference into a related context entry payload', function (): void {
$adapter = new RelatedContextReferenceAdapter(
new RelatedActionLabelCatalog,
new ResolvedReferencePresenter(new ReferenceTypeLabelCatalog, new ReferenceStatePresenter),
);
$entry = $adapter->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();
});

View File

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
use App\Support\References\ReferenceClass;
use App\Support\References\ReferenceLinkTarget;
use App\Support\References\ReferenceResolutionState;
use App\Support\References\ReferenceTechnicalDetail;
use App\Support\References\ResolvedReference;
it('serializes a resolved reference with link and technical detail', function (): void {
$reference = new ResolvedReference(
referenceClass: ReferenceClass::PolicyVersion,
rawIdentifier: '42',
primaryLabel: 'Windows Lockdown',
secondaryLabel: 'Version 3',
state: ReferenceResolutionState::Resolved,
stateLabel: null,
linkTarget: new ReferenceLinkTarget(
targetKind: ReferenceClass::PolicyVersion->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();
});

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use App\Support\References\ReferenceClass;
use App\Support\References\ReferenceDescriptor;
use App\Support\References\ReferenceResolutionState;
use App\Support\References\Resolvers\FallbackReferenceResolver;
it('renders unsupported references in a safe degraded form', function (): void {
$resolved = app(FallbackReferenceResolver::class)->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');
});