feat/042-inventory-dependencies-graph #50

Merged
ahmido merged 22 commits from feat/042-inventory-dependencies-graph into dev 2026-01-10 12:50:08 +00:00
9 changed files with 562 additions and 7 deletions
Showing only changes of commit 7df011ebc1 - Show all commits

View File

@ -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(),
])

View File

@ -0,0 +1,124 @@
<?php
namespace App\Services\Inventory\DependencyTargets;
use App\Filament\Resources\InventoryItemResource;
use App\Models\Tenant;
class DependencyTargetDto
{
public function __construct(
public readonly string $targetLabel,
public readonly ?string $displayName,
public readonly ?string $maskedId,
public readonly bool $resolved,
public readonly ?string $linkUrl,
public readonly ?string $foundationType,
public readonly ?string $reasonUnresolved,
public readonly string $badgeText,
) {}
public static function missing(): self
{
return new self(
targetLabel: 'Missing target',
displayName: null,
maskedId: null,
resolved: false,
linkUrl: null,
foundationType: null,
reasonUnresolved: 'missing_target',
badgeText: 'Missing target',
);
}
public static function externalGroup(string $targetId): self
{
return static::externalGroupWithLabel($targetId, 'Group (external)');
}
public static function externalGroupWithLabel(string $targetId, string $label): self
{
$maskedId = static::mask($targetId);
return new self(
targetLabel: $label,
displayName: null,
maskedId: $maskedId,
resolved: false,
linkUrl: null,
foundationType: 'aad_group',
reasonUnresolved: 'external_reference',
badgeText: "{$label}: {$maskedId}",
);
}
public static function unresolvedFoundation(string $label, string $foundationType, string $targetId): self
{
$maskedId = static::mask($targetId);
return new self(
targetLabel: $label,
displayName: null,
maskedId: $maskedId,
resolved: false,
linkUrl: null,
foundationType: $foundationType,
reasonUnresolved: 'not_in_local_db',
badgeText: "{$label} (unresolved): {$maskedId}",
);
}
public static function resolvedFoundation(string $label, string $foundationType, string $targetId, string $displayName, ?int $inventoryItemId, Tenant $tenant): self
{
$maskedId = static::mask($targetId);
$url = $inventoryItemId ? InventoryItemResource::getUrl('view', ['record' => $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).'…';
}
}

View File

@ -0,0 +1,189 @@
<?php
namespace App\Services\Inventory\DependencyTargets;
use App\Models\InventoryItem;
use App\Models\Tenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
class DependencyTargetResolver
{
public function __construct(private readonly FoundationTypeMap $foundationTypeMap) {}
/**
* @param Collection<int, mixed> $edges
* @return Collection<int, array<string, mixed>>
*/
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<string, list<string>> $targetIdsByFoundationType
* @return array<string, array<string, array{inventory_item_id:int,display_name:?string}>>
*/
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;
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\Services\Inventory\DependencyTargets;
class FoundationTypeMap
{
/**
* @return array<string, array{inventory_policy_type:?string,label:string,linkable:bool,resolvable_via_db:bool}>
*/
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<string>
*/
public function resolvableFoundationTypes(): array
{
return collect($this->all())
->filter(fn (array $row) => ($row['resolvable_via_db'] ?? false) === true)
->keys()
->values()
->all();
}
/**
* @return array<string, string>
*/
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;
}
}

View File

@ -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
<li class="flex items-center gap-2 text-sm">
<span class="fi-badge" title="{{ is_string($targetId) ? $targetId : '' }}">{{ $display }}</span>
@if (is_string($linkUrl) && $linkUrl !== '')
<a class="fi-badge" href="{{ $linkUrl }}" title="{{ is_string($targetId) ? $targetId : '' }}">{{ is_string($badgeText) && $badgeText !== '' ? $badgeText : 'External reference' }}</a>
@else
<span class="fi-badge" title="{{ is_string($targetId) ? $targetId : '' }}">{{ is_string($badgeText) && $badgeText !== '' ? $badgeText : 'External reference' }}</span>
@endif
@if ($isMissing)
<span class="fi-badge fi-badge-danger" title="{{ $missingTitle }}">Missing</span>
@endif

View File

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

View File

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

View File

@ -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 () {

View File

@ -0,0 +1,58 @@
<?php
use App\Models\InventoryItem;
use App\Models\InventoryLink;
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
it('resolves dependency targets in batch and is tenant-isolated', function () {
[$user, $tenant] = createUserWithTenant();
$this->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…)');
});