Compare commits
1 Commits
dev
...
codex/132-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0415e9af88 |
3
.github/agents/copilot-instructions.md
vendored
3
.github/agents/copilot-instructions.md
vendored
@ -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 -->
|
||||
|
||||
@ -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);
|
||||
$cached = $resolver->lookupMany($tenant, $groupIds);
|
||||
|
||||
$labels = [];
|
||||
|
||||
$groupDescriptions = $resolver->describeMany($tenant, $groupIds, $sourceNames);
|
||||
} else {
|
||||
foreach ($groupIds as $groupId) {
|
||||
$cachedName = $cached[strtolower($groupId)] ?? null;
|
||||
$labels[$groupId] = EntraGroupLabelResolver::formatLabel($cachedName ?? ($sourceNames[$groupId] ?? null), $groupId);
|
||||
$groupDescriptions[$groupId] = [
|
||||
'display_name' => $sourceNames[$groupId] ?? null,
|
||||
'resolved' => false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $labels;
|
||||
$references = [];
|
||||
$assignmentTargetResolver = app(AssignmentTargetReferenceResolver::class);
|
||||
$presenter = app(ResolvedReferencePresenter::class);
|
||||
|
||||
foreach ($assignments as $index => $assignment) {
|
||||
if (! is_array($assignment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$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 $references;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'] ?? '');
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
28
app/Support/Badges/Domains/ReferenceResolutionStateBadge.php
Normal file
28
app/Support/Badges/Domains/ReferenceResolutionStateBadge.php
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
if (! $backupSet instanceof BackupSet) {
|
||||
return $this->unavailableEntry($rule, (string) $backupSetId, 'missing');
|
||||
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 (! $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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
16
app/Support/References/Contracts/ReferenceResolver.php
Normal file
16
app/Support/References/Contracts/ReferenceResolver.php
Normal 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;
|
||||
}
|
||||
21
app/Support/References/ReferenceClass.php
Normal file
21
app/Support/References/ReferenceClass.php
Normal 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';
|
||||
}
|
||||
29
app/Support/References/ReferenceDescriptor.php
Normal file
29
app/Support/References/ReferenceDescriptor.php
Normal 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);
|
||||
}
|
||||
}
|
||||
28
app/Support/References/ReferenceLinkTarget.php
Normal file
28
app/Support/References/ReferenceLinkTarget.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
11
app/Support/References/ReferencePresentationVariant.php
Normal file
11
app/Support/References/ReferencePresentationVariant.php
Normal file
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\References;
|
||||
|
||||
enum ReferencePresentationVariant: string
|
||||
{
|
||||
case Compact = 'compact';
|
||||
case Detail = 'detail';
|
||||
}
|
||||
25
app/Support/References/ReferenceResolutionState.php
Normal file
25
app/Support/References/ReferenceResolutionState.php
Normal 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;
|
||||
}
|
||||
}
|
||||
37
app/Support/References/ReferenceResolverRegistry.php
Normal file
37
app/Support/References/ReferenceResolverRegistry.php
Normal 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);
|
||||
}
|
||||
}
|
||||
39
app/Support/References/ReferenceStatePresenter.php
Normal file
39
app/Support/References/ReferenceStatePresenter.php
Normal 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.",
|
||||
};
|
||||
}
|
||||
}
|
||||
60
app/Support/References/ReferenceTechnicalDetail.php
Normal file
60
app/Support/References/ReferenceTechnicalDetail.php
Normal 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);
|
||||
}
|
||||
}
|
||||
35
app/Support/References/ReferenceTypeLabelCatalog.php
Normal file
35
app/Support/References/ReferenceTypeLabelCatalog.php
Normal 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));
|
||||
}
|
||||
}
|
||||
33
app/Support/References/RelatedContextReferenceAdapter.php
Normal file
33
app/Support/References/RelatedContextReferenceAdapter.php
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
71
app/Support/References/ResolvedReference.php
Normal file
71
app/Support/References/ResolvedReference.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
60
app/Support/References/ResolvedReferencePresenter.php
Normal file
60
app/Support/References/ResolvedReferencePresenter.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
209
app/Support/References/Resolvers/BaseReferenceResolver.php
Normal file
209
app/Support/References/Resolvers/BaseReferenceResolver.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
116
app/Support/References/Resolvers/EntraGroupReferenceResolver.php
Normal file
116
app/Support/References/Resolvers/EntraGroupReferenceResolver.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
@ -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'),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
77
app/Support/References/Resolvers/PolicyReferenceResolver.php
Normal file
77
app/Support/References/Resolvers/PolicyReferenceResolver.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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">
|
||||
@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">
|
||||
@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">
|
||||
@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>
|
||||
|
||||
@ -11,15 +11,20 @@
|
||||
@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="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' }}
|
||||
@ -66,6 +71,8 @@ class="text-xs font-medium text-primary-600 hover:text-primary-500 hover:underli
|
||||
@endunless
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endforeach
|
||||
@endif
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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,
|
||||
);
|
||||
$targetReference = $assignmentReferences[$loop->index] ?? null;
|
||||
@endphp
|
||||
|
||||
@if(is_array($targetReference))
|
||||
<span class="text-gray-600 dark:text-gray-400">:</span>
|
||||
<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>
|
||||
@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 }}
|
||||
{{ $groupName ?: $groupId }}
|
||||
</span>
|
||||
@else
|
||||
<span class="text-gray-700 dark:text-gray-300">
|
||||
{{ $groupId }}
|
||||
</span>
|
||||
@endif
|
||||
@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>
|
||||
|
||||
36
specs/132-guid-context-resolver/checklists/requirements.md
Normal file
36
specs/132-guid-context-resolver/checklists/requirements.md
Normal 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`.
|
||||
@ -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
|
||||
123
specs/132-guid-context-resolver/data-model.md
Normal file
123
specs/132-guid-context-resolver/data-model.md
Normal 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.
|
||||
260
specs/132-guid-context-resolver/plan.md
Normal file
260
specs/132-guid-context-resolver/plan.md
Normal 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 today’s shallow `value + secondaryValue + availability` model.
|
||||
- Preserve canonical destinations through existing helpers like `OperationRunLinks` and resource `getUrl()` methods while making linkability capability-aware and non-ambiguous.
|
||||
- Add shared compact and detailed reference renderers so list rows and detail sections preserve the same semantic order even when the visual density differs, and map reference-state badges through the shared badge system.
|
||||
- Add shared compact and detailed reference renderers so list rows and detail sections preserve the same semantic order even when the visual density differs, map reference-state badges through the shared badge system, and preserve domain-consistent operator copy.
|
||||
- Roll out incrementally by first covering internal model-backed references, then security/provider-backed references, and finally upgrading existing related-context sections and table cells on the target surfaces.
|
||||
|
||||
## Phase 1 — Agent Context Update (DONE)
|
||||
|
||||
Run:
|
||||
- `.specify/scripts/bash/update-agent-context.sh copilot`
|
||||
|
||||
## Phase 2 — Implementation Outline (tasks created in `/speckit.tasks`)
|
||||
|
||||
### Step 1 — Define the shared reference contracts and registry
|
||||
|
||||
Goal: implement FR-132-02, FR-132-03, FR-132-05, FR-132-16, and FR-132-23.
|
||||
|
||||
Changes:
|
||||
- Add the shared descriptor, state, technical-detail, and resolved-reference value objects.
|
||||
- Add a registry or dispatcher that maps explicit reference classes to concrete resolvers.
|
||||
- Define a fallback resolver for unsupported or unresolved classes that still keeps the reference visible.
|
||||
|
||||
Tests:
|
||||
- Add unit coverage for registry dispatch, unsupported-class fallback, value-object normalization, and reference-state serialization.
|
||||
|
||||
### Step 2 — Implement the initial model-backed resolvers
|
||||
|
||||
Goal: implement FR-132-01, FR-132-04, FR-132-09, FR-132-10, FR-132-13, and FR-132-21.
|
||||
|
||||
Changes:
|
||||
- Add resolvers for policies, policy versions, baseline profiles, baseline snapshots, operation runs, and backup sets.
|
||||
- Resolve primary labels, type/context labels, canonical targets, and secondary technical details from local records and already-known relationships.
|
||||
- Batch or eager-load where needed so detail and list surfaces avoid N+1 lookups.
|
||||
|
||||
Tests:
|
||||
- Add unit coverage for each core resolver, including resolved, partially resolved, missing, and unsupported cases.
|
||||
|
||||
### Step 3 — Implement governance, security, and provider-backed resolvers
|
||||
|
||||
Goal: implement FR-132-04, FR-132-08, FR-132-10, FR-132-14, and FR-132-28.
|
||||
|
||||
Changes:
|
||||
- Add resolvers for role definitions, Entra groups, principal-like references, assignment targets, and tenant-linked external object references that are already surfaced today.
|
||||
- Upgrade narrow helpers like `EntraGroupLabelResolver` into structured resolver collaborators instead of raw label string formatters.
|
||||
- Differentiate inaccessible, unresolved, deleted or missing, and external-only limited-context states explicitly.
|
||||
|
||||
Tests:
|
||||
- Add unit coverage for provider-backed label reuse, inaccessible-state degradation, fallback labels, and deleted-object behavior.
|
||||
|
||||
### Step 4 — Create shared presentation variants and state vocabulary
|
||||
|
||||
Goal: implement FR-132-06, FR-132-07, FR-132-08, FR-132-12, FR-132-18, FR-132-19, and FR-132-20.
|
||||
|
||||
Changes:
|
||||
- Add compact and detailed shared reference renderers for infolists, related-context sections, and dense tables.
|
||||
- Keep the visual order consistent: label first, type/context second, state next, technical ID last.
|
||||
- Centralize state badges and unavailable-state copy instead of letting each resource improvise labels.
|
||||
|
||||
Tests:
|
||||
- Add feature coverage proving technical IDs stay secondary, unresolved states remain visible, and clickable vs non-clickable rendering is unambiguous.
|
||||
|
||||
### Step 5 — Refactor target surfaces to consume the shared reference layer
|
||||
|
||||
Goal: implement FR-132-15, FR-132-24, FR-132-25, FR-132-26, and FR-132-27.
|
||||
|
||||
Changes:
|
||||
- Refactor findings, baseline snapshots, operation runs, policy versions, backup sets, the baseline tenant assignments relation manager, and assignment-evidence sections on finding, policy-version, and baseline-snapshot surfaces to use the shared resolver/presentation layer.
|
||||
- Replace GUID-heavy infolist/table fields and ad hoc related-context arrays with structured resolved references.
|
||||
- Ensure existing canonical links continue to flow through `OperationRunLinks` and resource route helpers while touched Filament resources preserve explicit inspect affordances, row-action caps, and documented exemptions.
|
||||
- Ensure tenant-context entry into canonical workspace destinations preserves visible originating-tenant meaning via filters, context badges, or source-context metadata where relevant.
|
||||
|
||||
Tests:
|
||||
- Add or update Filament feature coverage for findings, baseline snapshots, operation runs, and assignment-like surfaces to prove label-first rendering and canonical links.
|
||||
|
||||
### Step 6 — Enforce authorization-aware degradation and regression safety
|
||||
|
||||
Goal: implement FR-132-11, FR-132-14, FR-132-17, FR-132-22, and the acceptance criteria around safe degradation.
|
||||
|
||||
Changes:
|
||||
- Ensure the shared layer differentiates non-member 404 semantics from in-scope capability denial and never leaks protected labels where policy forbids it.
|
||||
- Keep unsupported or partially known references visible without breaking pages.
|
||||
- Audit touched resources for remaining GUID-first output and remove page-specific formatting branches for supported classes.
|
||||
- Lock the rollout boundary so the surfaces named in the spec fully adopt the shared pattern now, while dashboard summary widgets and other out-of-scope views are explicitly allowed to remain on older formatting until a later spec.
|
||||
- Add regression coverage for domain-consistent operator copy and tenant-context carryover on migrated canonical destinations.
|
||||
|
||||
Tests:
|
||||
- Add positive and negative authorization coverage proving inaccessible references are non-clickable, protected destinations stay guarded, and unsupported resolvers do not hide the underlying reference.
|
||||
- Add regression coverage preventing raw GUIDs from reappearing as the primary visible value on supported target surfaces.
|
||||
|
||||
## Constitution Check (Post-Design)
|
||||
|
||||
Re-check result: PASS.
|
||||
|
||||
- Livewire v4.0+ compliance: preserved because the design stays inside the existing Filament v5 / Livewire v4 surfaces.
|
||||
- Provider registration location: unchanged; existing panel providers remain registered in `bootstrap/providers.php`.
|
||||
- Globally searchable resources: touched searchable resources already expose view pages, and non-searchable resources remain explicitly disabled, so the Filament global-search rule remains satisfied.
|
||||
- Destructive actions: no new destructive actions are introduced; existing destructive behavior remains subject to current confirmation and authorization rules.
|
||||
- Asset strategy: no new heavy assets are introduced; shared Blade partials and existing Filament view infrastructure are sufficient, and deploy-time `php artisan filament:assets` behavior remains unchanged.
|
||||
- Testing plan: add focused Pest unit coverage for registry dispatch, resolver behavior, shared badge mapping, state normalization, and authorization-aware link generation, plus focused Filament feature coverage for findings, baseline snapshots, operation runs, the baseline tenant assignments relation manager, assignment-evidence surfaces, tenant-context carryover on canonical destinations, domain-consistent copy, and degraded-reference rendering.
|
||||
60
specs/132-guid-context-resolver/quickstart.md
Normal file
60
specs/132-guid-context-resolver/quickstart.md
Normal 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.
|
||||
57
specs/132-guid-context-resolver/research.md
Normal file
57
specs/132-guid-context-resolver/research.md
Normal 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 spec’s requirement for explicit reference classes, shared semantics, and distinct degraded states.
|
||||
|
||||
## Decision 2: Model references through explicit input and output contracts
|
||||
|
||||
- Decision: Introduce a structured `ReferenceDescriptor` input contract and a normalized `ResolvedReference` output contract with explicit state, type, label, technical detail, and optional canonical target.
|
||||
- Rationale: The current `RelatedContextEntry` payload is useful for immediate rendering but too shallow for the broader semantics required here. A descriptor plus resolved-reference pair makes support for partial resolution, unsupported classes, and future providers predictable and testable.
|
||||
- Alternatives considered:
|
||||
- Continue passing free-form arrays between resources and partials: rejected because it invites page-specific drift and makes regression testing weak.
|
||||
- Resolve everything directly inside Blade partials: rejected because presentation should not own resolution logic or authorization decisions.
|
||||
|
||||
## Decision 3: Keep render-time resolution DB-only and best-effort
|
||||
|
||||
- Decision: Resolve references at render time using existing relations, local model lookups, current workspace or tenant context, stored fallback labels, and existing helper services only; do not make live Microsoft Graph or provider calls.
|
||||
- Rationale: The constitution and current architecture keep monitoring and governance views DB-only at render time. Best-effort local resolution is sufficient for the target surfaces and avoids latency, availability, and authorization leakage problems.
|
||||
- Alternatives considered:
|
||||
- Make on-demand provider calls to improve labels: rejected because it violates the DB-only rendering expectation, adds latency, and complicates authorization.
|
||||
- Cache external labels aggressively in new tables: rejected because the spec explicitly avoids introducing new persistent models solely for resolution.
|
||||
|
||||
## Decision 4: Use two shared presentation variants with one semantic payload
|
||||
|
||||
- Decision: Provide a compact variant for dense tables and a detailed variant for related-context or infolist sections, both powered by the same resolved-reference payload.
|
||||
- Rationale: Some target surfaces are list-dense and cannot absorb a full detail block without becoming noisy, while detail pages need more explanatory context and degraded-state messaging. One semantic contract with two renderers keeps the hierarchy stable while fitting each surface.
|
||||
- Alternatives considered:
|
||||
- Force one heavy component everywhere: rejected because it would make list screens noisy and reduce scanability.
|
||||
- Allow each page to design its own variant independently: rejected because it would recreate the inconsistency the spec is meant to solve.
|
||||
|
||||
## Decision 5: Authorization controls linkability first, label disclosure second
|
||||
|
||||
- Decision: Canonical targets become actionable only when current policy allows access; label and state disclosure must degrade to inaccessible or unresolved only to the extent policy allows.
|
||||
- Rationale: The product’s 404-vs-403 semantics and deny-as-not-found rules are already explicit. The reference layer must not accidentally leak object existence or friendly labels just because it has enough data to resolve them internally.
|
||||
- Alternatives considered:
|
||||
- Hide every unauthorized reference completely: rejected because some surfaces legitimately need to preserve the existence of a reference without allowing navigation.
|
||||
- Always show the resolved label even when navigation is forbidden: rejected because that can leak protected detail.
|
||||
|
||||
## Decision 6: Upgrade narrow label helpers into structured resolver collaborators
|
||||
|
||||
- Decision: Existing helpers such as `EntraGroupLabelResolver` should become collaborators that feed structured resolution outputs rather than emitting final UI labels.
|
||||
- Rationale: Current helpers return a single string like `Name (…token)`, which is not enough to separate primary label, type/context, state, and technical detail. They remain useful, but inside a richer resolver contract.
|
||||
- Alternatives considered:
|
||||
- Leave helpers untouched and parse their formatted strings back into UI parts: rejected because it is brittle and loses state intent.
|
||||
- Rewrite all provider-backed label logic from scratch: rejected because the current local lookup behavior is already useful and should be preserved.
|
||||
|
||||
## Decision 7: Leverage the repo’s existing registry-and-DTO pattern
|
||||
|
||||
- Decision: Mirror the `BaselineSnapshotPresenter` and `SnapshotTypeRendererRegistry` pattern by using immutable value objects, registry dispatch, and fallback behavior for references.
|
||||
- Rationale: The repo already favors registry-driven support layers for normalization and rendering. Following that pattern keeps the reference system familiar, testable, and consistent with current architecture.
|
||||
- Alternatives considered:
|
||||
- Put the entire resolution switch in one large service class: rejected because explicit per-class resolvers are easier to extend and test.
|
||||
- Encode resolution rules in configuration only: rejected because authorization-aware destination logic and best-effort lookup behavior require application code.
|
||||
203
specs/132-guid-context-resolver/spec.md
Normal file
203
specs/132-guid-context-resolver/spec.md
Normal 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 page’s formatting logic.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a newly supported reference class, **When** it is added to the shared reference presentation capability, **Then** existing rendering patterns can present it without page-specific reinvention.
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- A reference may be syntactically valid but point to a record that has been deleted since capture.
|
||||
- A reference may expose only a fallback label, a broad type, or source metadata, but no canonical target.
|
||||
- A reference may be known to exist conceptually while the current user is not authorized to open the target record.
|
||||
- Multiple different reference classes may appear in the same dense list or detail section and must remain readable without excessive visual noise.
|
||||
- A surface may encounter a supported reference class with insufficient enrichment and must still avoid falling back to GUID-first output when partial context is available.
|
||||
- A screen may receive an unsupported reference class and must keep the reference visible without breaking the page.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature is a read-oriented presentation and shared semantics improvement. It introduces no new Microsoft Graph calls, no new write/change workflows, and no new long-running or scheduled work. Existing governance, monitoring, backup, assignment, and tenant-linked records remain authoritative. The feature only standardizes how known references are enriched, classified, and rendered on user-facing surfaces.
|
||||
|
||||
**Constitution alignment (OPS-UX):** This feature may display existing operation runs as resolved references, but it does not create, mutate, or observe `OperationRun` lifecycle behavior and does not alter toast, progress, or terminal-notification contracts.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature affects workspace-admin `/admin` surfaces and tenant-context `/admin/t/{tenant}/...` surfaces that render or link to related records. Cross-plane access remains deny-as-not-found. Non-members and users outside entitled workspace or tenant scope must receive 404 semantics. Members within the correct scope who lack target-resource capability must receive 403 semantics when opening protected destinations. Reference presentation must remain capability-aware and must not use raw capability strings or role-name checks in feature code. At least one positive and one negative authorization test must protect reference visibility and linkability behavior.
|
||||
|
||||
**Constitution alignment (OPS-EX-AUTH-001):** Not applicable. No authentication-handshake behavior is introduced.
|
||||
|
||||
**Constitution alignment (BADGE-001):** If this feature introduces or standardizes reference-state badges, those states must use centralized semantics and shared vocabulary through the existing badge system, using shared badge-domain mapping and shared badge rendering rather than page-local label/color logic.
|
||||
|
||||
**Constitution alignment (UI-NAMING-001):** Operator-facing reference copy must use domain vocabulary such as “Policy,” “Policy version,” “Baseline snapshot,” “Operation run,” “Group,” “User,” and “Role definition” rather than implementation-first ID language. Actions and helper copy must preserve the same object naming across list rows, related-context sections, link labels, and empty or degraded states.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** This feature modifies multiple Filament resources and detail pages by standardizing inspect affordances, related-context rows, and view actions for resolved references. The Action Surface Contract remains satisfied because this feature adds read-oriented reference display and navigation only; it introduces no destructive actions.
|
||||
|
||||
**Constitution alignment (UX-001 — Layout & Information Architecture):** Modified Filament detail pages must present references in structured read-only sections or rows rather than raw field dumps. View pages remain infolist-first or equivalent read-only presentations. List and table screens continue to support search, sort, and filters for their core dimensions while reference-heavy values adopt the same label-first hierarchy. If a dense table cannot support the detailed reference variant without harming readability, it may use a compact variant that preserves the same meaning order.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-132-01 Label-first presentation**: For supported reference classes, user-facing screens must not present raw GUIDs, raw IDs, or foreign keys as the primary visible value when a more human-readable label or fallback label is available.
|
||||
- **FR-132-02 Shared reference capability**: The product must provide one shared reference-resolution and presentation capability that can be reused across multiple surfaces instead of page-specific formatting logic.
|
||||
- **FR-132-03 Explicit reference classes**: Supported references must be classified into explicit reference classes rather than treated as generic strings.
|
||||
- **FR-132-04 Initial class coverage**: Initial supported reference classes must cover, where surfaced today, policies, policy versions, baseline profiles, baseline snapshots, operation runs, backup-related artifacts, role definitions, principals, groups, service-principal-style references, and tenant-linked external object references relevant to current domains.
|
||||
- **FR-132-05 Normalized reference output**: The shared capability must return a normalized presentation result that includes reference class, raw identifier, primary label, secondary context or type label, explicit resolution state, and optional canonical destination.
|
||||
- **FR-132-06 Consistent hierarchy**: Resolved references must render with the same information order across surfaces: primary label, type or context, state, then technical ID.
|
||||
- **FR-132-07 Secondary technical detail**: Technical identifiers must remain available for advanced troubleshooting, copying, or support workflows, but only as visually secondary detail.
|
||||
- **FR-132-08 Distinct degraded states**: The UI must distinguish at minimum between resolved, partially resolved, unresolved, deleted or missing, inaccessible, and external-only limited-context references where those states are materially different.
|
||||
- **FR-132-09 Safe degradation**: When a reference cannot be fully resolved, the interface must preserve the reference visibly, avoid false confidence, and show the best safe context available.
|
||||
- **FR-132-10 Partial resolution support**: The product must support partially resolved references that have some useful context, such as fallback label, broad object type, or retained source metadata, even when a full destination or authoritative record is unavailable.
|
||||
- **FR-132-11 Role-aware linkability**: References may render as actionable only when they have a meaningful canonical destination and the current user is permitted to open it.
|
||||
- **FR-132-12 No ambiguous clickability**: References without a permitted or meaningful destination must not appear clickable.
|
||||
- **FR-132-13 Canonical destinations**: When a supported reference links internally, it must use the canonical destination for that resource instead of page-specific ad hoc routes.
|
||||
- **FR-132-14 No unauthorized disclosure**: Reference presentation must not broaden existence leakage, protected labels, or related-object detail beyond what existing authorization policy permits.
|
||||
- **FR-132-15 Cross-surface reuse**: The same resolution and presentation semantics must be reusable across baseline snapshots, findings, operation runs, related-context sections, assignments, and equivalent governance artifacts.
|
||||
- **FR-132-16 Centralized logic**: Pages must not reimplement bespoke “if GUID then format” branches for supported reference classes.
|
||||
- **FR-132-17 Unsupported-class fallback**: If a reference class is unsupported or no enrichment is available, the page must still render the reference in a safe degraded form instead of failing or hiding it entirely.
|
||||
- **FR-132-18 Dense-view variants**: The product may use compact and detailed reference variants, but both variants must preserve the same hierarchy and state semantics.
|
||||
- **FR-132-19 Shared vocabulary**: Reference types and reference states must use a shared display vocabulary across all in-scope screens.
|
||||
- **FR-132-20 Related-context readability**: Screens that expose many references must remain readable because each rendered reference is self-describing without requiring the operator to leave the page first.
|
||||
- **FR-132-21 Best-effort enrichment**: The shared capability must support best-effort enrichment from available source context, fallback label, workspace or tenant context, and already-known relations.
|
||||
- **FR-132-22 Incremental rollout compatibility**: During rollout, the target surfaces named in this spec must fully adopt the new pattern even if out-of-scope screens such as dashboard summary widgets or later inventory/reporting views temporarily still use older formatting.
|
||||
- **FR-132-22 Incremental rollout compatibility**: During rollout, the target surfaces named in this spec must fully adopt the new pattern even if out-of-scope screens such as dashboard summary widgets or later inventory/reporting views temporarily still use older formatting. This includes tenant-context entry into canonical workspace destinations where originating tenant filters, context badges, or source-context metadata remain visible.
|
||||
- **FR-132-23 Future extensibility**: Adding a newly supported reference class must not require rewriting all existing page templates or all existing reference renderers.
|
||||
- **FR-132-24 Baseline snapshot adoption**: Baseline snapshot views and snapshot-related sections must use the shared reference presentation pattern for referenced policies, profiles, runs, and related governance objects in scope.
|
||||
- **FR-132-25 Findings adoption**: Findings must render referenced policies, snapshots, runs, assignments, principals, and similar related entities using contextual references rather than raw IDs as primary text.
|
||||
- **FR-132-26 Operation-run adoption**: Operation-run surfaces must render important target and related-object references in the shared human-readable format.
|
||||
- **FR-132-27 Assignment-like adoption**: The baseline tenant assignments relation manager plus assignment-evidence and assignment-diff views that surface principals, groups, roles, or policy relationships must use the shared reference presentation pattern.
|
||||
- **FR-132-28 Evidence preservation**: Even in degraded states, raw identifiers must remain available as supporting evidence when operationally useful.
|
||||
|
||||
### Non-Goals
|
||||
|
||||
- Full cross-resource navigation redesign
|
||||
- New audit-log features
|
||||
- Universal raw-payload transformation
|
||||
- New persistent domain models created solely for reference resolution
|
||||
- Global search redesign
|
||||
- External directory synchronization redesign
|
||||
- A full evidence-viewer redesign
|
||||
- Changes to capture or snapshot semantics
|
||||
- Guaranteed full resolution for every arbitrary external identifier
|
||||
|
||||
### Assumptions
|
||||
|
||||
- Existing records and source payloads already contain enough relationship hints to materially improve how important references are presented on the initial target surfaces.
|
||||
- Some provider-backed references will remain partially known, and that is acceptable if the degraded state is explicit.
|
||||
- Operators still need access to technical IDs for support and troubleshooting, but not as the dominant visual language.
|
||||
- In-scope surfaces can adopt a compact reference variant where dense layouts would otherwise become noisy.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Existing workspace and tenant authorization rules
|
||||
- Existing canonical destinations for supported internal resources
|
||||
- Existing governance, monitoring, backup, assignment, and provider-linked domain relationships
|
||||
- Existing display names, fallback labels, or type hints already stored or derivable from source context
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Findings | Workspace admin list and detail surfaces | None new beyond existing page actions | Reference-heavy columns and related-context rows must use shared label-first rendering | View related record, View run when permitted | None | Existing empty state remains; no new mutation CTA required | View related record, View snapshot or policy when most relevant | N/A | No | Read-only presentation upgrade only; no destructive actions |
|
||||
| Baseline snapshots | Workspace admin list and detail surfaces | None new beyond existing view actions | Snapshot rows and detail related-context entries use shared reference presentation | View profile, View run when permitted | None | Existing empty state remains | View profile, View policy version when relevant | N/A | No | Snapshot readability is a primary target surface |
|
||||
| Operation runs | Canonical workspace operations list and detail surfaces | None new beyond existing view actions | Run target and related-object references render label-first | View target record, View related artifact when permitted | None | Existing empty state remains | View related record, View source context when relevant | N/A | No | Links must stay canonical and capability-aware |
|
||||
| Assignment-like views | Workspace admin and tenant-context assignment or relationship surfaces | None new beyond existing view actions | Principal, group, role, and target references use shared compact rendering in tables and detailed rendering on view pages | View principal or target, View role when permitted | None | Existing empty state remains | View target record, View related policy when relevant | N/A | No | Initial concrete targets are `BaselineTenantAssignmentsRelationManager` plus assignment-evidence sections on policy-version, finding, and baseline-snapshot views; no new mutations, only reference clarity and consistent linking |
|
||||
| Related-context sections across governance artifacts | View pages under `/admin/...` and `/admin/t/{tenant}/...` | None | Structured related-context rows become the primary inspect affordance for secondary objects | At most the two most relevant permitted related links | None | N/A | Existing page header actions remain authoritative | N/A | No | Exemption: row-action cap may be enforced through section ordering rather than visible button count in some view-only layouts |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Reference Descriptor**: The structured description of a user-visible reference, including its class, raw identifier, available context, and any safe fallback label or source metadata.
|
||||
- **Resolved Reference**: The normalized presentation result for a reference, including its primary label, contextual type, explicit state, technical identifier, and optional canonical destination.
|
||||
- **Reference State**: The explicit condition of a reference, such as resolved, partially resolved, unresolved, missing, inaccessible, or limited-context external.
|
||||
- **Reference Presentation Variant**: The compact or detailed rendering pattern that preserves the same meaning order while adapting to list-density or detail-page needs.
|
||||
- **Canonical Destination**: The authoritative in-product destination for a resolved internal reference when the current user is allowed to navigate to it.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-132-01 Readability improvement on target surfaces**: In acceptance testing for the in-scope target surfaces, supported references are displayed label-first rather than GUID-first in 100% of covered cases.
|
||||
- **SC-132-02 Distinct degraded states**: In regression tests for degraded-reference scenarios, 100% of covered resolved, partial, unresolved, missing, and inaccessible cases render visibly distinct states instead of collapsing into the same generic label.
|
||||
- **SC-132-03 Secondary-ID compliance**: In target-surface review, technical IDs remain available as secondary detail for 100% of covered supported reference classes without becoming the primary displayed value.
|
||||
- **SC-132-04 Canonical-link correctness**: In authorization-aware navigation tests, 100% of covered actionable references open canonical destinations only when the user is permitted to access them.
|
||||
- **SC-132-05 Reuse across surfaces**: At least the baseline snapshot, findings, operation-run, baseline tenant assignments relation manager, and assignment-evidence target surfaces use the same shared reference semantics without page-specific ad hoc formatting rules for supported classes.
|
||||
- **SC-132-06 Future extensibility**: In implementation review and regression coverage, adding a new supported reference class requires changes only to the shared reference capability and the intended target surface mappings, not wholesale rewrites of existing page templates.
|
||||
232
specs/132-guid-context-resolver/tasks.md
Normal file
232
specs/132-guid-context-resolver/tasks.md
Normal 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.
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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());
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
@ -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());
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
@ -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());
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -40,6 +40,6 @@
|
||||
|
||||
$this->get(FindingResource::getUrl('view', ['record' => $finding], tenant: $tenant))
|
||||
->assertOk()
|
||||
->assertSee('Unavailable')
|
||||
->assertSee('Access denied')
|
||||
->assertSee('current scope');
|
||||
});
|
||||
|
||||
44
tests/Feature/Rbac/ResolvedReferenceAuthorizationTest.php
Normal file
44
tests/Feature/Rbac/ResolvedReferenceAuthorizationTest.php
Normal 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);
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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();
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
21
tests/Unit/Support/References/ReferenceLinkTargetTest.php
Normal file
21
tests/Unit/Support/References/ReferenceLinkTargetTest.php
Normal 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',
|
||||
]);
|
||||
});
|
||||
@ -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],
|
||||
]);
|
||||
@ -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');
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
@ -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'],
|
||||
]);
|
||||
@ -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();
|
||||
});
|
||||
70
tests/Unit/Support/References/ResolvedReferenceTest.php
Normal file
70
tests/Unit/Support/References/ResolvedReferenceTest.php
Normal 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();
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user