From 254c78b4b188b2be1a939bd9d4c9a9cfe3c67577 Mon Sep 17 00:00:00 2001 From: Ahmed Darrazi Date: Sat, 10 Jan 2026 13:33:04 +0100 Subject: [PATCH] feat: dependency target resolver + map --- .../Resources/InventoryItemResource.php | 5 +- .../DependencyTargets/DependencyTargetDto.php | 124 ++++++++++++ .../DependencyTargetResolver.php | 189 ++++++++++++++++++ .../DependencyTargets/FoundationTypeMap.php | 74 +++++++ .../components/dependency-edges.blade.php | 16 +- .../042-inventory-dependencies-graph/spec.md | 12 ++ .../042-inventory-dependencies-graph/tasks.md | 17 ++ .../Feature/InventoryItemDependenciesTest.php | 74 ++++++- tests/Unit/DependencyTargetResolverTest.php | 58 ++++++ 9 files changed, 562 insertions(+), 7 deletions(-) create mode 100644 app/Services/Inventory/DependencyTargets/DependencyTargetDto.php create mode 100644 app/Services/Inventory/DependencyTargets/DependencyTargetResolver.php create mode 100644 app/Services/Inventory/DependencyTargets/FoundationTypeMap.php create mode 100644 tests/Unit/DependencyTargetResolverTest.php diff --git a/app/Filament/Resources/InventoryItemResource.php b/app/Filament/Resources/InventoryItemResource.php index 90ccf71..265254f 100644 --- a/app/Filament/Resources/InventoryItemResource.php +++ b/app/Filament/Resources/InventoryItemResource.php @@ -6,6 +6,7 @@ use App\Models\InventoryItem; use App\Models\Tenant; use App\Services\Inventory\DependencyQueryService; +use App\Services\Inventory\DependencyTargets\DependencyTargetResolver; use App\Support\Enums\RelationshipType; use BackedEnum; use Filament\Actions; @@ -87,6 +88,8 @@ public static function infolist(Schema $schema): Schema : RelationshipType::tryFrom($relationshipType)?->value; $service = app(DependencyQueryService::class); + $resolver = app(DependencyTargetResolver::class); + $tenant = Tenant::current(); $edges = collect(); if ($direction === 'inbound' || $direction === 'all') { @@ -96,7 +99,7 @@ public static function infolist(Schema $schema): Schema $edges = $edges->merge($service->getOutboundEdges($record, $relationshipType)); } - return $edges->take(100); // both directions combined + return $resolver->attachRenderedTargets($edges->take(100), $tenant); // both directions combined }) ->columnSpanFull(), ]) diff --git a/app/Services/Inventory/DependencyTargets/DependencyTargetDto.php b/app/Services/Inventory/DependencyTargets/DependencyTargetDto.php new file mode 100644 index 0000000..00a27bd --- /dev/null +++ b/app/Services/Inventory/DependencyTargets/DependencyTargetDto.php @@ -0,0 +1,124 @@ + $inventoryItemId], tenant: $tenant) : null; + + return new self( + targetLabel: $label, + displayName: $displayName, + maskedId: $maskedId, + resolved: true, + linkUrl: $url, + foundationType: $foundationType, + reasonUnresolved: null, + badgeText: "{$label}: {$displayName} ({$maskedId})", + ); + } + + public static function externalReference(string $targetId, ?string $foundationType = null): self + { + $maskedId = static::mask($targetId); + $label = $foundationType ? "External ref ({$foundationType})" : 'External reference'; + + return new self( + targetLabel: $label, + displayName: null, + maskedId: $maskedId, + resolved: false, + linkUrl: null, + foundationType: $foundationType, + reasonUnresolved: 'unsupported_foundation_type', + badgeText: "{$label}: {$maskedId}", + ); + } + + public function toArray(): array + { + return [ + 'target_label' => $this->targetLabel, + 'display_name' => $this->displayName, + 'masked_id' => $this->maskedId, + 'resolved' => $this->resolved, + 'link_url' => $this->linkUrl, + 'foundation_type' => $this->foundationType, + 'reason_unresolved' => $this->reasonUnresolved, + 'badge_text' => $this->badgeText, + ]; + } + + private static function mask(string $id): string + { + return substr($id, 0, 6).'…'; + } +} diff --git a/app/Services/Inventory/DependencyTargets/DependencyTargetResolver.php b/app/Services/Inventory/DependencyTargets/DependencyTargetResolver.php new file mode 100644 index 0000000..8acc2d6 --- /dev/null +++ b/app/Services/Inventory/DependencyTargets/DependencyTargetResolver.php @@ -0,0 +1,189 @@ + $edges + * @return Collection> + */ + public function attachRenderedTargets(Collection $edges, Tenant $tenant): Collection + { + $edgeRows = $edges + ->map(fn ($edge) => $edge instanceof Model ? $edge->toArray() : (array) $edge) + ->values(); + + $targetIdsByFoundationType = []; + + foreach ($edgeRows as $edge) { + $targetType = Arr::get($edge, 'target_type'); + if ($targetType !== 'foundation_object') { + continue; + } + + $foundationType = Arr::get($edge, 'metadata.foundation_type'); + $targetId = Arr::get($edge, 'target_id'); + + if (! is_string($foundationType) || $foundationType === '') { + continue; + } + + if (! is_string($targetId) || $targetId === '') { + continue; + } + + $targetIdsByFoundationType[$foundationType] ??= []; + $targetIdsByFoundationType[$foundationType][] = $targetId; + } + + $resolvedMaps = $this->resolveFoundationTargetsFromDb($tenant, $targetIdsByFoundationType); + + return $edgeRows->map(function (array $edge) use ($tenant, $resolvedMaps) { + $targetType = Arr::get($edge, 'target_type'); + + if ($targetType === 'missing') { + $edge['rendered_target'] = DependencyTargetDto::missing()->toArray(); + + return $edge; + } + + if ($targetType === 'foundation_object') { + $foundationType = Arr::get($edge, 'metadata.foundation_type'); + $targetId = Arr::get($edge, 'target_id'); + + if (! is_string($targetId) || $targetId === '') { + $edge['rendered_target'] = DependencyTargetDto::externalReference('')->toArray(); + + return $edge; + } + + $foundationType = is_string($foundationType) ? $foundationType : null; + $mapRow = $this->foundationTypeMap->get($foundationType); + + if ($foundationType === 'aad_group') { + $label = $mapRow['label'] ?? 'Group (external)'; + $edge['rendered_target'] = DependencyTargetDto::externalGroupWithLabel($targetId, $label)->toArray(); + + return $edge; + } + + if (! $mapRow) { + $edge['rendered_target'] = DependencyTargetDto::externalReference($targetId, $foundationType)->toArray(); + + return $edge; + } + + $label = $mapRow['label'] ?? 'External reference'; + $resolved = $foundationType ? ($resolvedMaps[$foundationType][$targetId] ?? null) : null; + + if (! is_array($resolved) || ! array_key_exists('inventory_item_id', $resolved)) { + $edge['rendered_target'] = DependencyTargetDto::unresolvedFoundation($label, $foundationType, $targetId)->toArray(); + + return $edge; + } + + $displayName = $resolved['display_name'] ?? null; + if (! is_string($displayName) || $displayName === '') { + $edge['rendered_target'] = DependencyTargetDto::unresolvedFoundation($label, $foundationType, $targetId)->toArray(); + + return $edge; + } + + $linkable = (bool) ($mapRow['linkable'] ?? false); + + $edge['rendered_target'] = DependencyTargetDto::resolvedFoundation( + $label, + $foundationType, + $targetId, + $displayName, + $linkable ? (int) $resolved['inventory_item_id'] : null, + $tenant, + )->toArray(); + + return $edge; + } + + $targetId = Arr::get($edge, 'target_id'); + if (is_string($targetId) && $targetId !== '') { + $edge['rendered_target'] = DependencyTargetDto::externalReference($targetId, $targetType)->toArray(); + + return $edge; + } + + $edge['rendered_target'] = DependencyTargetDto::externalReference('', $targetType)->toArray(); + + return $edge; + }); + } + + /** + * @param array> $targetIdsByFoundationType + * @return array> + */ + private function resolveFoundationTargetsFromDb(Tenant $tenant, array $targetIdsByFoundationType): array + { + $resolvableTypes = $this->foundationTypeMap->resolvableFoundationTypes(); + $policyTypeToFoundationType = $this->foundationTypeMap->policyTypeToFoundationType(); + + $policyTypes = []; + $allExternalIds = []; + + foreach ($targetIdsByFoundationType as $foundationType => $targetIds) { + if (! in_array($foundationType, $resolvableTypes, true)) { + continue; + } + + $row = $this->foundationTypeMap->get($foundationType); + $policyType = $row['inventory_policy_type'] ?? null; + if (! is_string($policyType) || $policyType === '') { + continue; + } + + $policyTypes[] = $policyType; + foreach ($targetIds as $id) { + if (is_string($id) && $id !== '') { + $allExternalIds[] = $id; + } + } + } + + $policyTypes = array_values(array_unique($policyTypes)); + $allExternalIds = array_values(array_unique($allExternalIds)); + + if ($policyTypes === [] || $allExternalIds === []) { + return []; + } + + $items = InventoryItem::query() + ->where('tenant_id', $tenant->getKey()) + ->whereIn('policy_type', $policyTypes) + ->whereIn('external_id', $allExternalIds) + ->get(['id', 'tenant_id', 'policy_type', 'external_id', 'display_name']); + + $resolved = []; + + foreach ($items as $item) { + $foundationType = $policyTypeToFoundationType[$item->policy_type] ?? null; + if (! is_string($foundationType) || $foundationType === '') { + continue; + } + + $resolved[$foundationType] ??= []; + $resolved[$foundationType][$item->external_id] = [ + 'inventory_item_id' => (int) $item->getKey(), + 'display_name' => is_string($item->display_name) ? $item->display_name : null, + ]; + } + + return $resolved; + } +} diff --git a/app/Services/Inventory/DependencyTargets/FoundationTypeMap.php b/app/Services/Inventory/DependencyTargets/FoundationTypeMap.php new file mode 100644 index 0000000..7cb163c --- /dev/null +++ b/app/Services/Inventory/DependencyTargets/FoundationTypeMap.php @@ -0,0 +1,74 @@ + + */ + public function all(): array + { + return [ + 'scope_tag' => [ + 'inventory_policy_type' => 'roleScopeTag', + 'label' => 'Scope Tag', + 'linkable' => true, + 'resolvable_via_db' => true, + ], + 'assignment_filter' => [ + 'inventory_policy_type' => 'assignmentFilter', + 'label' => 'Assignment Filter', + 'linkable' => true, + 'resolvable_via_db' => true, + ], + 'aad_group' => [ + 'inventory_policy_type' => null, + 'label' => 'Group (external)', + 'linkable' => false, + 'resolvable_via_db' => false, + ], + ]; + } + + /** + * @return array{inventory_policy_type:?string,label:string,linkable:bool,resolvable_via_db:bool}|null + */ + public function get(?string $foundationType): ?array + { + if (! is_string($foundationType) || $foundationType === '') { + return null; + } + + return $this->all()[$foundationType] ?? null; + } + + /** + * @return list + */ + public function resolvableFoundationTypes(): array + { + return collect($this->all()) + ->filter(fn (array $row) => ($row['resolvable_via_db'] ?? false) === true) + ->keys() + ->values() + ->all(); + } + + /** + * @return array + */ + public function policyTypeToFoundationType(): array + { + $map = []; + + foreach ($this->all() as $foundationType => $row) { + $policyType = $row['inventory_policy_type'] ?? null; + if (is_string($policyType) && $policyType !== '') { + $map[$policyType] = $foundationType; + } + } + + return $map; + } +} diff --git a/resources/views/filament/components/dependency-edges.blade.php b/resources/views/filament/components/dependency-edges.blade.php index fcaec87..47d4bdd 100644 --- a/resources/views/filament/components/dependency-edges.blade.php +++ b/resources/views/filament/components/dependency-edges.blade.php @@ -35,13 +35,15 @@ @foreach ($group as $edge) @php $isMissing = ($edge['target_type'] ?? null) === 'missing'; - $name = $edge['metadata']['last_known_name'] ?? null; $targetId = $edge['target_id'] ?? null; - $display = $name ?: ($targetId ? ("ID: ".substr($targetId,0,6)."…") : 'Unknown'); + $rendered = $edge['rendered_target'] ?? []; + $badgeText = is_array($rendered) ? ($rendered['badge_text'] ?? null) : null; + $linkUrl = is_array($rendered) ? ($rendered['link_url'] ?? null) : null; $missingTitle = 'Missing target'; - if (is_string($name) && $name !== '') { - $missingTitle .= ". Last known: {$name}"; + $lastKnownName = $edge['metadata']['last_known_name'] ?? null; + if (is_string($lastKnownName) && $lastKnownName !== '') { + $missingTitle .= ". Last known: {$lastKnownName}"; } $rawRef = $edge['metadata']['raw_ref'] ?? null; if ($rawRef !== null) { @@ -52,7 +54,11 @@ } @endphp
  • - {{ $display }} + @if (is_string($linkUrl) && $linkUrl !== '') + {{ is_string($badgeText) && $badgeText !== '' ? $badgeText : 'External reference' }} + @else + {{ is_string($badgeText) && $badgeText !== '' ? $badgeText : 'External reference' }} + @endif @if ($isMissing) Missing @endif diff --git a/specs/042-inventory-dependencies-graph/spec.md b/specs/042-inventory-dependencies-graph/spec.md index 655336f..d9e879f 100644 --- a/specs/042-inventory-dependencies-graph/spec.md +++ b/specs/042-inventory-dependencies-graph/spec.md @@ -20,6 +20,11 @@ ### Session 2026-01-10 - Q: Should `foundation_object` edges always store `metadata.foundation_type`? → A: Yes (required). - Q: Should the UI show 50 edges total or 50 per direction? → A: 50 per direction (up to 100 total when showing both directions). +### Session 2026-01-10 (042.2) + +- Q: Should the Dependencies UI do Entra/Graph lookups to resolve names (e.g., Groups)? → A: No. UI resolution is DB-only. +- Q: How should the UI avoid "Unknown" targets long-term? → A: Render a stable, typed DTO per edge target via a resolver layer (batch queries, no N+1). + **Definitions**: - **Blast radius**: All resources directly affected by a change to a given item (outbound edges only; no transitive traversal in MVP). - **Prerequisite**: A hard dependency required for an item to function; missing prerequisites are explicitly surfaced. @@ -48,6 +53,13 @@ ### Scenario 4: Filter dependencies by relationship type - When the user filters by relationship type (single-select dropdown, default: "All") - Then only matching edges are shown (empty selection = all edges visible) +### Scenario 6 (042.2): Resolve target names when available +- Given dependency edges to foundation objects (scope tags, assignment filters, groups) +- When the user views dependencies +- Then each edge target is labelled deterministically (no "Unknown") +- And names are resolved only when the target exists in the local DB (no UI Graph lookups) +- And external references (AAD groups) are rendered as external refs without links + ### Scenario 5: Only missing prerequisites - Given an item where all referenced targets are unresolved (no matching inventory or foundation objects) - When the user opens the dependencies view and selects "Outbound" or "All" diff --git a/specs/042-inventory-dependencies-graph/tasks.md b/specs/042-inventory-dependencies-graph/tasks.md index 75c4481..8bc4356 100644 --- a/specs/042-inventory-dependencies-graph/tasks.md +++ b/specs/042-inventory-dependencies-graph/tasks.md @@ -145,6 +145,23 @@ ## Phase 8: Consistency & Security Coverage (Cross-Cutting) --- +## Phase 9: 042.2 — Dependency Target Name Resolution (Resolver + Batch) 🎯 + +**Goal**: Remove UI-level "Unknown" logic by rendering a stable DTO per edge target, and resolve names from the local DB when possible. + +**Constraints**: +- No Entra/Graph lookups in the Dependencies UI (DB-only). +- No new tables. +- Deterministic, tenant-scoped, batch queries (no N+1). + +- [x] T038 Introduce DTO + resolver registry (batch) for dependency targets (e.g., `DependencyTargetDto`, `DependencyTargetResolver`) +- [x] T039 Implement DB-based resolvers for scope tags and assignment filters; keep AAD groups as external refs (no resolution) +- [x] T040 Wire resolver into the Dependencies view (attach DTO to each edge) and simplify Blade rendering to DTO-only +- [x] T041 Add linking for resolved targets (tenant-scoped Inventory Item view URL) +- [x] T042 Add tests: unit (batch + determinism + tenant isolation) and feature/UI (resolved names + external group label) + +--- + ## Dependencies & Execution Order ### Phase Dependencies diff --git a/tests/Feature/InventoryItemDependenciesTest.php b/tests/Feature/InventoryItemDependenciesTest.php index a4ecd0c..bcf8f07 100644 --- a/tests/Feature/InventoryItemDependenciesTest.php +++ b/tests/Feature/InventoryItemDependenciesTest.php @@ -165,13 +165,85 @@ 'relationship_type' => 'assigned_to', 'metadata' => [ 'last_known_name' => null, + 'foundation_type' => 'aad_group', ], ]); $url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant); $this->get($url) ->assertOk() - ->assertSee('ID: 123456…'); + ->assertSee('Group (external): 123456…'); +}); + +it('resolves scope tag and assignment filter names from local inventory when available and labels groups as external', function () { + [$user, $tenant] = createUserWithTenant(); + $this->actingAs($user); + + /** @var InventoryItem $item */ + $item = InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'external_id' => (string) Str::uuid(), + ]); + + $scopeTag = InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'policy_type' => 'roleScopeTag', + 'external_id' => '6', + 'display_name' => 'Finance', + ]); + + $assignmentFilter = InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'policy_type' => 'assignmentFilter', + 'external_id' => '62fb77f0-0000-0000-0000-000000000000', + 'display_name' => 'VIP Devices', + ]); + + InventoryLink::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'source_type' => 'inventory_item', + 'source_id' => $item->external_id, + 'target_type' => 'foundation_object', + 'target_id' => $scopeTag->external_id, + 'relationship_type' => 'scoped_by', + 'metadata' => [ + 'last_known_name' => null, + 'foundation_type' => 'scope_tag', + ], + ]); + + InventoryLink::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'source_type' => 'inventory_item', + 'source_id' => $item->external_id, + 'target_type' => 'foundation_object', + 'target_id' => $assignmentFilter->external_id, + 'relationship_type' => 'uses_assignment_filter', + 'metadata' => [ + 'last_known_name' => null, + 'foundation_type' => 'assignment_filter', + ], + ]); + + InventoryLink::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'source_type' => 'inventory_item', + 'source_id' => $item->external_id, + 'target_type' => 'foundation_object', + 'target_id' => '428f24c0-0000-0000-0000-000000000000', + 'relationship_type' => 'assigned_to_include', + 'metadata' => [ + 'last_known_name' => null, + 'foundation_type' => 'aad_group', + ], + ]); + + $url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant); + $this->get($url) + ->assertOk() + ->assertSee('Scope Tag: Finance (6…)') + ->assertSee('Assignment Filter: VIP Devices (62fb77…)') + ->assertSee('Group (external): 428f24…'); }); it('blocks guest access to inventory item dependencies view', function () { diff --git a/tests/Unit/DependencyTargetResolverTest.php b/tests/Unit/DependencyTargetResolverTest.php new file mode 100644 index 0000000..154b8ed --- /dev/null +++ b/tests/Unit/DependencyTargetResolverTest.php @@ -0,0 +1,58 @@ +actingAs($user); + + $resolver = app(DependencyTargetResolver::class); + + /** @var InventoryItem $item */ + $item = InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'external_id' => (string) Str::uuid(), + ]); + + $scopeTag = InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'policy_type' => 'roleScopeTag', + 'external_id' => '6', + 'display_name' => 'Finance', + ]); + + // Same external_id exists in another tenant; must never resolve across tenants. + $otherTenant = \App\Models\Tenant::factory()->create(); + InventoryItem::factory()->create([ + 'tenant_id' => $otherTenant->getKey(), + 'policy_type' => 'roleScopeTag', + 'external_id' => '6', + 'display_name' => 'Other Finance', + ]); + + $edge = InventoryLink::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'source_type' => 'inventory_item', + 'source_id' => $item->external_id, + 'target_type' => 'foundation_object', + 'target_id' => $scopeTag->external_id, + 'relationship_type' => 'scoped_by', + 'metadata' => [ + 'last_known_name' => null, + 'foundation_type' => 'scope_tag', + ], + ]); + + $resolved = $resolver->attachRenderedTargets(collect([$edge]), $tenant)->first(); + + expect($resolved) + ->toBeArray() + ->and($resolved['rendered_target']['resolved'])->toBeTrue() + ->and($resolved['rendered_target']['badge_text'])->toBe('Scope Tag: Finance (6…)'); +});