diff --git a/app/Filament/Resources/InventoryItemResource.php b/app/Filament/Resources/InventoryItemResource.php index 90ccf71..564c73f 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,11 @@ public static function infolist(Schema $schema): Schema $edges = $edges->merge($service->getOutboundEdges($record, $relationshipType)); } + return $resolver->attachRenderedTargets($edges->take(100), $tenant); // both directions combined + return $edges->take(100); // both directions combined + + return $resolver->attachRenderedTargets($edges->take(100), $tenant); // both directions combined }) ->columnSpanFull(), ]) diff --git a/app/Services/Inventory/DependencyExtractionService.php b/app/Services/Inventory/DependencyExtractionService.php index 54ad580..90aed57 100644 --- a/app/Services/Inventory/DependencyExtractionService.php +++ b/app/Services/Inventory/DependencyExtractionService.php @@ -22,15 +22,30 @@ public function extractForPolicyData(InventoryItem $item, array $policyData): ar $warnings = []; $edges = collect(); - $edges = $edges->merge($this->extractAssignedTo($item, $policyData, $warnings)); + $edges = $edges->merge($this->extractAssignments($item, $policyData, $warnings)); $edges = $edges->merge($this->extractScopedBy($item, $policyData)); + // Prevent PostgreSQL cardinality violation on upsert by deduplicating payload rows. + $edges = $edges + ->unique(fn (array $e) => implode('|', [ + (string) ($e['tenant_id'] ?? ''), + (string) ($e['source_type'] ?? ''), + (string) ($e['source_id'] ?? ''), + (string) ($e['target_type'] ?? ''), + (string) ($e['target_id'] ?? ''), + (string) ($e['relationship_type'] ?? ''), + ])) + ->values(); + // Enforce max 50 outbound edges by priority: assigned_to > scoped_by > others $priorities = [ - RelationshipType::AssignedTo->value => 1, - RelationshipType::ScopedBy->value => 2, - RelationshipType::Targets->value => 3, - RelationshipType::DependsOn->value => 4, + RelationshipType::AssignedToInclude->value => 1, + RelationshipType::AssignedToExclude->value => 2, + RelationshipType::AssignedTo->value => 3, // legacy + RelationshipType::UsesAssignmentFilter->value => 4, + RelationshipType::ScopedBy->value => 5, + RelationshipType::Targets->value => 6, + RelationshipType::DependsOn->value => 7, ]; /** @var Collection $sorted */ @@ -67,7 +82,7 @@ public function extractForPolicyData(InventoryItem $item, array $policyData): ar * @param array $policyData * @return Collection> */ - private function extractAssignedTo(InventoryItem $item, array $policyData, array &$warnings): Collection + private function extractAssignments(InventoryItem $item, array $policyData, array &$warnings): Collection { $assignments = Arr::get($policyData, 'assignments'); if (! is_array($assignments)) { @@ -81,32 +96,77 @@ private function extractAssignedTo(InventoryItem $item, array $policyData, array continue; } - // Known shapes: ['target' => ['groupId' => '...']] or direct ['groupId' => '...'] + $policyId = (string) ($policyData['id'] ?? $item->external_id); + + $target = Arr::get($assignment, 'target'); + $odataType = is_array($target) ? (Arr::get($target, '@odata.type') ?? Arr::get($target, '@OData.Type')) : null; + $odataType = is_string($odataType) ? strtolower($odataType) : null; + $groupId = Arr::get($assignment, 'target.groupId') ?? Arr::get($assignment, 'groupId'); + $groupId = is_string($groupId) ? trim($groupId) : null; + + $filterId = Arr::get($assignment, 'deviceAndAppManagementAssignmentFilterId') + ?? Arr::get($assignment, 'assignmentFilterId') + ?? Arr::get($assignment, 'target.deviceAndAppManagementAssignmentFilterId'); + $filterId = is_string($filterId) ? trim($filterId) : null; + + $filterType = Arr::get($assignment, 'deviceAndAppManagementAssignmentFilterType') + ?? Arr::get($assignment, 'assignmentFilterType') + ?? Arr::get($assignment, 'target.deviceAndAppManagementAssignmentFilterType'); + $filterType = is_string($filterType) ? strtolower(trim($filterType)) : null; + $filterMode = in_array($filterType, ['include', 'exclude'], true) ? $filterType : null; + + if (is_string($filterId) && $filterId !== '') { + $edges[] = [ + 'tenant_id' => (int) $item->tenant_id, + 'source_type' => 'inventory_item', + 'source_id' => (string) $item->external_id, + 'target_type' => 'foundation_object', + 'target_id' => $filterId, + 'relationship_type' => RelationshipType::UsesAssignmentFilter->value, + 'metadata' => array_filter([ + 'last_known_name' => null, + 'foundation_type' => 'assignment_filter', + 'filter_mode' => $filterMode, + ], fn ($v) => $v !== null), + ]; + } + if (is_string($groupId) && $groupId !== '') { + $relationshipType = RelationshipType::AssignedToInclude->value; + if (is_string($odataType) && str_contains($odataType, 'exclusion')) { + $relationshipType = RelationshipType::AssignedToExclude->value; + } $edges[] = [ 'tenant_id' => (int) $item->tenant_id, 'source_type' => 'inventory_item', 'source_id' => (string) $item->external_id, 'target_type' => 'foundation_object', 'target_id' => $groupId, - 'relationship_type' => RelationshipType::AssignedTo->value, + 'relationship_type' => $relationshipType, 'metadata' => [ 'last_known_name' => null, 'foundation_type' => 'aad_group', ], ]; - } else { - $warning = [ - 'type' => 'unsupported_reference', - 'policy_id' => (string) ($policyData['id'] ?? $item->external_id), - 'raw_ref' => $assignment, - 'reason' => 'unsupported_assignment_target_shape', - ]; - $warnings[] = $warning; - Log::info('Unsupported reference shape encountered', $warning); + continue; } + + // Known non-group targets (e.g. allDevices/allLicensedUsers) are out-of-scope for edges. + if (is_string($odataType) && (str_contains($odataType, 'alldevices') || str_contains($odataType, 'alllicensedusers') || str_contains($odataType, 'allusers'))) { + continue; + } + + $warning = [ + 'type' => 'unsupported_reference', + 'policy_id' => $policyId, + 'raw_ref' => $assignment, + 'reason' => 'unsupported_assignment_target_shape', + ]; + + $warnings[] = $warning; + Log::info('Unsupported reference shape encountered', $warning); } return collect($edges); 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/app/Services/Inventory/InventorySyncService.php b/app/Services/Inventory/InventorySyncService.php index e581016..56c7da9 100644 --- a/app/Services/Inventory/InventorySyncService.php +++ b/app/Services/Inventory/InventorySyncService.php @@ -12,6 +12,7 @@ use Carbon\CarbonImmutable; use Illuminate\Contracts\Cache\Lock; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; use Throwable; class InventorySyncService @@ -290,6 +291,18 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal $observed++; + $includeDeps = (bool) ($normalizedSelection['include_dependencies'] ?? true); + + if ($includeDeps && $this->shouldHydrateAssignments($policyType)) { + $existingAssignments = $policyData['assignments'] ?? null; + if (! is_array($existingAssignments) || count($existingAssignments) === 0) { + $hydratedAssignments = $this->fetchAssignmentsForPolicyType($policyType, $tenant, $externalId, $warnings); + if (is_array($hydratedAssignments)) { + $policyData['assignments'] = $hydratedAssignments; + } + } + } + $displayName = $policyData['displayName'] ?? $policyData['name'] ?? null; $displayName = is_string($displayName) ? $displayName : null; @@ -327,7 +340,6 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal $upserted++; // Extract dependencies if requested in selection - $includeDeps = (bool) ($normalizedSelection['include_dependencies'] ?? true); if ($includeDeps) { $warnings = array_merge( $warnings, @@ -386,6 +398,67 @@ private function shouldSkipPolicyForSelectedType(string $selectedPolicyType, arr return $this->resolveConfigurationPolicyType($policyData) !== $selectedPolicyType; } + private function shouldHydrateAssignments(string $policyType): bool + { + return in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true); + } + + /** + * @param array> $warnings + * @return null|array + */ + private function fetchAssignmentsForPolicyType(string $policyType, Tenant $tenant, string $externalId, array &$warnings): ?array + { + $pathTemplate = config("graph_contracts.types.{$policyType}.assignments_list_path"); + if (! is_string($pathTemplate) || $pathTemplate === '') { + return null; + } + + $path = str_replace('{id}', $externalId, $pathTemplate); + + $options = [ + 'tenant' => $tenant->tenant_id ?? $tenant->external_id, + 'client_id' => $tenant->app_client_id, + 'client_secret' => $tenant->app_client_secret, + ]; + + $maxAttempts = 3; + + for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) { + $response = $this->graphClient->request('GET', $path, $options); + + if (! $response->failed()) { + $data = $response->data; + if (is_array($data) && array_key_exists('value', $data) && is_array($data['value'])) { + return $data['value']; + } + + if (is_array($data)) { + return $data; + } + + return null; + } + + $status = (int) ($response->status ?? 0); + if (! in_array($status, [429, 503], true)) { + break; + } + } + + $warning = [ + 'type' => 'assignments_fetch_failed', + 'policy_id' => $externalId, + 'policy_type' => $policyType, + 'reason' => 'graph_assignments_list_failed', + ]; + + $warnings[] = $warning; + Log::info('Failed to fetch policy assignments', $warning); + + return null; + } + private function resolveConfigurationPolicyType(array $policyData): string { $templateReference = $policyData['templateReference'] ?? null; diff --git a/app/Support/Enums/RelationshipType.php b/app/Support/Enums/RelationshipType.php index 2c1f101..69ced31 100644 --- a/app/Support/Enums/RelationshipType.php +++ b/app/Support/Enums/RelationshipType.php @@ -5,6 +5,9 @@ enum RelationshipType: string { case AssignedTo = 'assigned_to'; + case AssignedToInclude = 'assigned_to_include'; + case AssignedToExclude = 'assigned_to_exclude'; + case UsesAssignmentFilter = 'uses_assignment_filter'; case ScopedBy = 'scoped_by'; case Targets = 'targets'; case DependsOn = 'depends_on'; @@ -13,6 +16,9 @@ public function label(): string { return match ($this) { self::AssignedTo => 'Assigned to', + self::AssignedToInclude => 'Assigned to (include)', + self::AssignedToExclude => 'Assigned to (exclude)', + self::UsesAssignmentFilter => 'Uses assignment filter', self::ScopedBy => 'Scoped by', self::Targets => 'Targets', self::DependsOn => 'Depends on', diff --git a/resources/views/filament/components/dependency-edges.blade.php b/resources/views/filament/components/dependency-edges.blade.php index fcaec87..146913c 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) { @@ -50,9 +52,26 @@ $missingTitle .= '. Ref: '.\Illuminate\Support\Str::limit($encodedRef, 200); } } + + $fallbackDisplay = null; + if (is_string($lastKnownName) && $lastKnownName !== '') { + $fallbackDisplay = $lastKnownName; + } elseif (is_string($targetId) && $targetId !== '') { + $fallbackDisplay = 'ID: '.substr($targetId, 0, 6).'…'; + } else { + $fallbackDisplay = 'External reference'; + } @endphp
  • - {{ $display }} + @if (is_string($badgeText) && $badgeText !== '') + @if (is_string($linkUrl) && $linkUrl !== '') + {{ $badgeText }} + @else + {{ $badgeText }} + @endif + @else + {{ $fallbackDisplay }} + @endif @if ($isMissing) Missing @endif diff --git a/specs/042-inventory-dependencies-graph/contracts/dependency-edge.schema.json b/specs/042-inventory-dependencies-graph/contracts/dependency-edge.schema.json index 922a2a2..d4a91cf 100644 --- a/specs/042-inventory-dependencies-graph/contracts/dependency-edge.schema.json +++ b/specs/042-inventory-dependencies-graph/contracts/dependency-edge.schema.json @@ -17,14 +17,15 @@ "source_id": { "type": "string" }, "target_type": { "type": "string", "enum": ["inventory_item", "foundation_object", "missing"] }, "target_id": { "type": ["string", "null"] }, - "relationship_type": { "type": "string", "enum": ["assigned_to", "scoped_by", "targets", "depends_on"] }, + "relationship_type": { "type": "string", "enum": ["assigned_to", "assigned_to_include", "assigned_to_exclude", "uses_assignment_filter", "scoped_by", "targets", "depends_on"] }, "metadata": { "type": ["object", "null"], "additionalProperties": true, "properties": { "last_known_name": { "type": ["string", "null"] }, "raw_ref": {}, - "foundation_type": { "type": "string", "enum": ["aad_group", "scope_tag", "device_category"] } + "foundation_type": { "type": "string", "enum": ["aad_group", "scope_tag", "device_category", "assignment_filter"] }, + "filter_mode": { "type": ["string", "null"], "enum": ["include", "exclude", null] } } }, "created_at": { "type": ["string", "null"], "description": "ISO-8601 timestamp" }, diff --git a/specs/042-inventory-dependencies-graph/data-model.md b/specs/042-inventory-dependencies-graph/data-model.md index 61e7b7d..53148b5 100644 --- a/specs/042-inventory-dependencies-graph/data-model.md +++ b/specs/042-inventory-dependencies-graph/data-model.md @@ -50,12 +50,16 @@ #### InventoryLink.metadata - `raw_ref` (mixed/array; only when safe) Required when `target_type='foundation_object'`: -- `foundation_type` (string enum-like): `aad_group` | `scope_tag` | `device_category` - +- `foundation_type` (string enum-like): `aad_group` | `scope_tag` | `device_category` | `assignment_filter` +Additional metadata (when applicable): +- `filter_mode` (string enum-like): `include` | `exclude` (for `foundation_type='assignment_filter'`) ## Enums ### RelationshipType - `assigned_to` +- `assigned_to_include` +- `assigned_to_exclude` +- `uses_assignment_filter` - `scoped_by` - `targets` - `depends_on` diff --git a/specs/042-inventory-dependencies-graph/spec.md b/specs/042-inventory-dependencies-graph/spec.md index 9ba0913..1dde711 100644 --- a/specs/042-inventory-dependencies-graph/spec.md +++ b/specs/042-inventory-dependencies-graph/spec.md @@ -20,6 +20,10 @@ ### 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 +52,12 @@ ### 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" @@ -58,7 +68,10 @@ ## Functional Requirements - **FR1: Relationship taxonomy** Define a normalized set of relationship types covering inventory→inventory and inventory→foundation edges. Supported types (MVP): - - `assigned_to` (Policy → AAD Group) + - `assigned_to` (Policy → AAD Group) *(legacy/general)* + - `assigned_to_include` (Policy → AAD Group; include assignment) + - `assigned_to_exclude` (Policy → AAD Group; exclude assignment) + - `uses_assignment_filter` (Policy → Assignment Filter; metadata `filter_mode=include|exclude`) - `scoped_by` (Policy → Scope Tag) - `targets` (Update Policy → Device Category, conditional logic) - `depends_on` (Generic prerequisite, e.g., Compliance Policy referenced by Conditional Access) @@ -85,6 +98,7 @@ ## Functional Requirements - AAD Groups (`aad_group`) - Scope Tags (`scope_tag`) - Device Categories (`device_category`) + - Assignment Filters (`assignment_filter`) **Out-of-scope foundation types** (for this iteration): Conditional Access Policies, Compliance Policies as foundation nodes (only as inventory items). diff --git a/specs/042-inventory-dependencies-graph/tasks.md b/specs/042-inventory-dependencies-graph/tasks.md index 780e294..1daffc2 100644 --- a/specs/042-inventory-dependencies-graph/tasks.md +++ b/specs/042-inventory-dependencies-graph/tasks.md @@ -21,22 +21,11 @@ ## Phase 2: Foundational (Blocking Prerequisites) **Purpose**: Storage + extraction + query services that all UI stories rely on. **Checkpoint**: After Phase 2, edges can be extracted and queried tenant-safely with limits. - -- [ ] T003 [P] Ensure relationship types and labels are centralized in `app/Support/Enums/RelationshipType.php` -- [ ] T004 Ensure `inventory_links` schema (unique key + indexes) matches spec in `database/migrations/2026_01_07_150000_create_inventory_links_table.php` -- [ ] T005 [P] Ensure `InventoryLink` casts and tenant-safe query patterns in `app/Models/InventoryLink.php` -- [ ] T006 [P] Ensure factory coverage for dependency edges in `database/factories/InventoryLinkFactory.php` -- [ ] T007 Align idempotent upsert semantics for edges in `app/Services/Inventory/DependencyExtractionService.php` -- [ ] T008 Implement warning-only handling for unknown/unsupported reference shapes in `app/Services/Inventory/DependencyExtractionService.php` -- [ ] T009 Persist warnings on the sync run record at `InventorySyncRun.error_context.warnings[]` via `app/Services/Inventory/InventorySyncService.php` -- [ ] T010 Implement limit-only inbound/outbound queries with optional relationship filter in `app/Services/Inventory/DependencyQueryService.php` (ordered by `created_at DESC`) -- [ ] T011 [P] Add determinism + idempotency tests in `tests/Unit/DependencyExtractionServiceTest.php` -- [ ] T012 [P] Add tenant isolation tests for queries in `tests/Feature/DependencyExtractionFeatureTest.php` - [x] T003 [P] Ensure relationship types and labels are centralized in `app/Support/Enums/RelationshipType.php` - [x] T004 Ensure `inventory_links` schema (unique key + indexes) matches spec in `database/migrations/2026_01_07_150000_create_inventory_links_table.php` - [x] T005 [P] Ensure `InventoryLink` casts and tenant-safe query patterns in `app/Models/InventoryLink.php` - [x] T006 [P] Ensure factory coverage for dependency edges in `database/factories/InventoryLinkFactory.php` -- [x] T007 Align idempotent upsert semantics for edges in `app/Services/Inventory/DependencyExtractionService.php` +- [x] T007 Align idempotent upsert semantics for edges in `app/Services/Inventory/DependencyExtractionService.php` (including hydrated assignments when not present in list payloads) - [x] T008 Implement warning-only handling for unknown/unsupported reference shapes in `app/Services/Inventory/DependencyExtractionService.php` - [x] T009 Persist warnings on the sync run record at `InventorySyncRun.error_context.warnings[]` via `app/Services/Inventory/InventorySyncService.php` - [x] T010 Implement limit-only inbound/outbound queries with optional relationship filter in `app/Services/Inventory/DependencyQueryService.php` (ordered by `created_at DESC`) @@ -50,12 +39,6 @@ ## Phase 3: User Story 1 — View Dependencies (Priority: P1) 🎯 MVP **Goal**: As an admin, I can view direct inbound/outbound dependencies for an inventory item. **Independent Test**: Opening an Inventory Item shows a Dependencies section that renders within limits and supports direction filtering. - -- [ ] T013 [P] [US1] Wire dependencies section into Filament item view in `app/Filament/Resources/InventoryItemResource.php` -- [ ] T014 [P] [US1] Render edges grouped by relationship type in `resources/views/filament/components/dependency-edges.blade.php` -- [ ] T015 [US1] Add direction filter (querystring-backed) in `resources/views/filament/components/dependency-edges.blade.php` -- [ ] T016 [US1] Parse/validate `direction` and pass to query service in `app/Filament/Resources/InventoryItemResource.php` -- [ ] T017 [US1] Add UI smoke test for dependencies rendering + direction filter in `tests/Feature/InventoryItemDependenciesTest.php` - [x] T013 [P] [US1] Wire dependencies section into Filament item view in `app/Filament/Resources/InventoryItemResource.php` - [x] T014 [P] [US1] Render edges grouped by relationship type in `resources/views/filament/components/dependency-edges.blade.php` - [x] T015 [US1] Add direction filter (querystring-backed) in `resources/views/filament/components/dependency-edges.blade.php` @@ -69,11 +52,6 @@ ## Phase 4: User Story 2 — Identify Missing Prerequisites (Priority: P2) **Goal**: As an admin, I can clearly see when a referenced prerequisite object is missing. **Independent Test**: A missing target renders a red “Missing” badge and safe tooltip using `metadata.last_known_name`/`metadata.raw_ref`. - -- [ ] T018 [US2] Ensure unresolved targets create `target_type='missing'` edges with safe metadata in `app/Services/Inventory/DependencyExtractionService.php` -- [ ] T019 [US2] Display “Missing” badge + tooltip for missing edges in `resources/views/filament/components/dependency-edges.blade.php` -- [ ] T020 [US2] Add feature test for missing edge creation + metadata in `tests/Feature/DependencyExtractionFeatureTest.php` -- [ ] T021 [US2] Add UI test asserting missing badge/tooltip is visible in `tests/Feature/InventoryItemDependenciesTest.php` - [x] T018 [US2] Ensure unresolved targets create `target_type='missing'` edges with safe metadata in `app/Services/Inventory/DependencyExtractionService.php` - [x] T019 [US2] Display “Missing” badge + tooltip for missing edges in `resources/views/filament/components/dependency-edges.blade.php` - [x] T020 [US2] Add feature test for missing edge creation + metadata in `tests/Feature/DependencyExtractionFeatureTest.php` @@ -86,10 +64,6 @@ ## Phase 5: User Story 3 — Filter By Relationship Type (Priority: P2) **Goal**: As an admin, I can filter dependencies by relationship type to reduce noise. **Independent Test**: Selecting a relationship type shows only matching edges; default “All” shows everything. - -- [ ] T022 [US3] Add relationship-type dropdown filter (querystring-backed) in `resources/views/filament/components/dependency-edges.blade.php` -- [ ] T023 [US3] Parse/validate `relationship_type` and pass to query service in `app/Filament/Resources/InventoryItemResource.php` -- [ ] T024 [US3] Add UI smoke test verifying relationship filter limits edges in `tests/Feature/InventoryItemDependenciesTest.php` - [x] T022 [US3] Add relationship-type dropdown filter (querystring-backed) in `resources/views/filament/components/dependency-edges.blade.php` - [x] T023 [US3] Parse/validate `relationship_type` and pass to query service in `app/Filament/Resources/InventoryItemResource.php` - [x] T024 [US3] Add UI smoke test verifying relationship filter limits edges in `tests/Feature/InventoryItemDependenciesTest.php` @@ -101,9 +75,6 @@ ## Phase 6: User Story 4 — Zero Dependencies (Priority: P3) **Goal**: As an admin, I get a clear empty state when no dependencies exist. **Independent Test**: When queries return zero edges, the UI shows “No dependencies found” and does not error. - -- [ ] T025 [US4] Render zero-state message in `resources/views/filament/components/dependency-edges.blade.php` -- [ ] T026 [US4] Add UI test for zero-state rendering in `tests/Feature/InventoryItemDependenciesTest.php` - [x] T025 [US4] Render zero-state message in `resources/views/filament/components/dependency-edges.blade.php` - [x] T026 [US4] Add UI test for zero-state rendering in `tests/Feature/InventoryItemDependenciesTest.php` @@ -112,12 +83,6 @@ ## Phase 6: User Story 4 — Zero Dependencies (Priority: P3) ## Phase 7: Polish & Cross-Cutting Concerns **Purpose**: Tighten docs/contracts and run quality gates. - -- [ ] T027 [P] Align filter docs + URLs in `specs/042-inventory-dependencies-graph/quickstart.md` -- [ ] T028 [P] Ensure schema reflects metadata requirements in `specs/042-inventory-dependencies-graph/contracts/dependency-edge.schema.json` -- [ ] T029 Update requirement-gate checkboxes in `specs/042-inventory-dependencies-graph/checklists/pr-gate.md` -- [ ] T030 Run Pint and fix formatting in `app/`, `resources/views/filament/components/`, and `tests/` (touching `app/Support/Enums/RelationshipType.php`, `resources/views/filament/components/dependency-edges.blade.php`, `tests/Feature/InventoryItemDependenciesTest.php`) -- [ ] T031 Run targeted tests: `tests/Feature/InventoryItemDependenciesTest.php`, `tests/Feature/DependencyExtractionFeatureTest.php`, `tests/Unit/DependencyExtractionServiceTest.php` - [x] T027 [P] Align filter docs + URLs in `specs/042-inventory-dependencies-graph/quickstart.md` - [x] T028 [P] Ensure schema reflects metadata requirements in `specs/042-inventory-dependencies-graph/contracts/dependency-edge.schema.json` - [x] T029 Update requirement-gate checkboxes in `specs/042-inventory-dependencies-graph/checklists/pr-gate.md` @@ -129,13 +94,6 @@ ## Phase 7: Polish & Cross-Cutting Concerns ## Phase 8: Consistency & Security Coverage (Cross-Cutting) **Purpose**: Close remaining spec→tasks gaps (ordering, masking, auth expectations, logging severity). - -- [ ] T032 [P] Add test asserting inbound/outbound query ordering by `created_at DESC` + limit-only behavior in `tests/Feature/DependencyExtractionFeatureTest.php` (or `tests/Unit/DependencyExtractionServiceTest.php` if more appropriate) -- [ ] T033 [US2] Implement masked/abbreviated identifier rendering when `metadata.last_known_name` is null in `resources/views/filament/components/dependency-edges.blade.php` -- [ ] T034 [US2] Add UI test for masked identifier behavior in `tests/Feature/InventoryItemDependenciesTest.php` -- [ ] T035 [P] Add auth behavior test for the inventory item dependencies view (guest blocked; authenticated tenant user allowed) in `tests/Feature/InventoryItemDependenciesTest.php` -- [ ] T036 [P] Ensure unknown/unsupported reference warnings are logged at `info` severity in `app/Services/Inventory/DependencyExtractionService.php` and add a unit test using `Log::fake()` in `tests/Unit/DependencyExtractionServiceTest.php` -- [ ] T037 Document how to verify the <2s dependency section goal (manual acceptance check) in `specs/042-inventory-dependencies-graph/quickstart.md` - [x] T032 [P] Add test asserting inbound/outbound query ordering by `created_at DESC` + limit-only behavior in `tests/Feature/DependencyExtractionFeatureTest.php` (or `tests/Unit/DependencyExtractionServiceTest.php` if more appropriate) - [x] T033 [US2] Implement masked/abbreviated identifier rendering when `metadata.last_known_name` is null in `resources/views/filament/components/dependency-edges.blade.php` - [x] T034 [US2] Add UI test for masked identifier behavior in `tests/Feature/InventoryItemDependenciesTest.php` @@ -145,6 +103,22 @@ ## 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/DependencyExtractionFeatureTest.php b/tests/Feature/DependencyExtractionFeatureTest.php index cbfc0ac..d1d5236 100644 --- a/tests/Feature/DependencyExtractionFeatureTest.php +++ b/tests/Feature/DependencyExtractionFeatureTest.php @@ -273,3 +273,97 @@ public function request(string $method, string $path, array $options = []): Grap expect($inbound[1]->source_id)->toBe('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'); expect($inbound[0]->created_at->greaterThan($inbound[1]->created_at))->toBeTrue(); }); + +it('hydrates settings catalog assignments and extracts include/exclude/filter edges', function () { + $tenant = Tenant::factory()->create(); + + $this->app->bind(GraphClientInterface::class, function () { + return new class implements GraphClientInterface + { + public function listPolicies(string $policyType, array $options = []): GraphResponse + { + return new GraphResponse(true, [[ + 'id' => 'sc-1', + 'name' => 'Settings Catalog Policy', + 'roleScopeTagIds' => ['scope-tag-1'], + // assignments present but empty (must still be hydrated via /assignments) + 'assignments' => [], + ]], 200); + } + + public function request(string $method, string $path, array $options = []): GraphResponse + { + if ($method === 'GET' && $path === '/deviceManagement/configurationPolicies/sc-1/assignments') { + return new GraphResponse(true, [ + 'value' => [ + [ + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-inc-1', + ], + ], + [ + 'target' => [ + '@odata.type' => '#microsoft.graph.exclusionGroupAssignmentTarget', + 'groupId' => 'group-exc-1', + ], + ], + [ + 'target' => [ + '@odata.type' => '#microsoft.graph.groupAssignmentTarget', + 'groupId' => 'group-inc-2', + ], + 'deviceAndAppManagementAssignmentFilterId' => 'filter-1', + 'deviceAndAppManagementAssignmentFilterType' => 'include', + ], + ], + ], 200); + } + + return new GraphResponse(true, [], 200); + } + + public function getPolicy(string $policyType, string $policyId, array $options = []): GraphResponse + { + return new GraphResponse(true, [], 200); + } + + public function getOrganization(array $options = []): GraphResponse + { + return new GraphResponse(true, [], 200); + } + + public function applyPolicy(string $policyType, string $policyId, array $payload, array $options = []): GraphResponse + { + return new GraphResponse(true, [], 200); + } + + public function getServicePrincipalPermissions(array $options = []): GraphResponse + { + return new GraphResponse(true, [], 200); + } + }; + }); + + $svc = app(InventorySyncService::class); + + $run = $svc->syncNow($tenant, [ + 'policy_types' => ['settingsCatalogPolicy'], + 'categories' => [], + 'include_foundations' => false, + 'include_dependencies' => true, + ]); + + expect($run->status)->toBe('success'); + + $edges = InventoryLink::query()->where('tenant_id', $tenant->getKey())->get(); + expect($edges->where('relationship_type', 'scoped_by'))->toHaveCount(1); + expect($edges->where('relationship_type', 'assigned_to_include'))->toHaveCount(2); + expect($edges->where('relationship_type', 'assigned_to_exclude'))->toHaveCount(1); + expect($edges->where('relationship_type', 'uses_assignment_filter'))->toHaveCount(1); + + $filterEdge = $edges->firstWhere('relationship_type', 'uses_assignment_filter'); + expect($filterEdge)->not->toBeNull(); + expect($filterEdge->metadata['foundation_type'] ?? null)->toBe('assignment_filter'); + expect($filterEdge->metadata['filter_mode'] ?? null)->toBe('include'); +}); 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/DependencyExtractionServiceTest.php b/tests/Unit/DependencyExtractionServiceTest.php index f0f83de..fc9173e 100644 --- a/tests/Unit/DependencyExtractionServiceTest.php +++ b/tests/Unit/DependencyExtractionServiceTest.php @@ -80,3 +80,35 @@ && ($context['policy_id'] ?? null) === $item->external_id) ->once(); }); + +it('deduplicates edges before upsert to avoid conflict errors', function () { + $tenant = \App\Models\Tenant::factory()->create(); + + $item = \App\Models\InventoryItem::factory()->create([ + 'tenant_id' => $tenant->getKey(), + 'policy_type' => 'settingsCatalogPolicy', + 'external_id' => 'pol-dup-1', + ]); + + $svc = app(\App\Services\Inventory\DependencyExtractionService::class); + + $policyData = [ + 'id' => 'pol-dup-1', + 'assignments' => [ + ['target' => ['groupId' => 'group-1', '@odata.type' => '#microsoft.graph.groupAssignmentTarget']], + ['target' => ['groupId' => 'group-1', '@odata.type' => '#microsoft.graph.groupAssignmentTarget']], + ], + 'roleScopeTagIds' => ['0', '0'], + ]; + + $warnings = $svc->extractForPolicyData($item, $policyData); + expect($warnings)->toBeArray()->toBeEmpty(); + + $edges = \App\Models\InventoryLink::query() + ->where('tenant_id', $tenant->getKey()) + ->where('source_id', 'pol-dup-1') + ->get(); + + expect($edges->where('relationship_type', 'assigned_to_include'))->toHaveCount(1); + expect($edges->where('relationship_type', 'scoped_by'))->toHaveCount(1); +}); 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…)'); +});