feat/042-inventory-dependencies-graph #50
@ -6,6 +6,7 @@
|
|||||||
use App\Models\InventoryItem;
|
use App\Models\InventoryItem;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\Inventory\DependencyQueryService;
|
use App\Services\Inventory\DependencyQueryService;
|
||||||
|
use App\Services\Inventory\DependencyTargets\DependencyTargetResolver;
|
||||||
use App\Support\Enums\RelationshipType;
|
use App\Support\Enums\RelationshipType;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -87,6 +88,8 @@ public static function infolist(Schema $schema): Schema
|
|||||||
: RelationshipType::tryFrom($relationshipType)?->value;
|
: RelationshipType::tryFrom($relationshipType)?->value;
|
||||||
|
|
||||||
$service = app(DependencyQueryService::class);
|
$service = app(DependencyQueryService::class);
|
||||||
|
$resolver = app(DependencyTargetResolver::class);
|
||||||
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
$edges = collect();
|
$edges = collect();
|
||||||
if ($direction === 'inbound' || $direction === 'all') {
|
if ($direction === 'inbound' || $direction === 'all') {
|
||||||
@ -96,7 +99,11 @@ public static function infolist(Schema $schema): Schema
|
|||||||
$edges = $edges->merge($service->getOutboundEdges($record, $relationshipType));
|
$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 $edges->take(100); // both directions combined
|
||||||
|
|
||||||
|
return $resolver->attachRenderedTargets($edges->take(100), $tenant); // both directions combined
|
||||||
})
|
})
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
])
|
])
|
||||||
|
|||||||
@ -22,15 +22,30 @@ public function extractForPolicyData(InventoryItem $item, array $policyData): ar
|
|||||||
$warnings = [];
|
$warnings = [];
|
||||||
$edges = collect();
|
$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));
|
$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
|
// Enforce max 50 outbound edges by priority: assigned_to > scoped_by > others
|
||||||
$priorities = [
|
$priorities = [
|
||||||
RelationshipType::AssignedTo->value => 1,
|
RelationshipType::AssignedToInclude->value => 1,
|
||||||
RelationshipType::ScopedBy->value => 2,
|
RelationshipType::AssignedToExclude->value => 2,
|
||||||
RelationshipType::Targets->value => 3,
|
RelationshipType::AssignedTo->value => 3, // legacy
|
||||||
RelationshipType::DependsOn->value => 4,
|
RelationshipType::UsesAssignmentFilter->value => 4,
|
||||||
|
RelationshipType::ScopedBy->value => 5,
|
||||||
|
RelationshipType::Targets->value => 6,
|
||||||
|
RelationshipType::DependsOn->value => 7,
|
||||||
];
|
];
|
||||||
|
|
||||||
/** @var Collection<int, array{tenant_id:int,source_type:string,source_id:string,target_type:string,target_id:?string,relationship_type:string,metadata:array}> $sorted */
|
/** @var Collection<int, array{tenant_id:int,source_type:string,source_id:string,target_type:string,target_id:?string,relationship_type:string,metadata:array}> $sorted */
|
||||||
@ -67,7 +82,7 @@ public function extractForPolicyData(InventoryItem $item, array $policyData): ar
|
|||||||
* @param array<string, mixed> $policyData
|
* @param array<string, mixed> $policyData
|
||||||
* @return Collection<int, array<string, mixed>>
|
* @return Collection<int, array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
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');
|
$assignments = Arr::get($policyData, 'assignments');
|
||||||
if (! is_array($assignments)) {
|
if (! is_array($assignments)) {
|
||||||
@ -81,32 +96,77 @@ private function extractAssignedTo(InventoryItem $item, array $policyData, array
|
|||||||
continue;
|
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 = 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 !== '') {
|
if (is_string($groupId) && $groupId !== '') {
|
||||||
|
$relationshipType = RelationshipType::AssignedToInclude->value;
|
||||||
|
if (is_string($odataType) && str_contains($odataType, 'exclusion')) {
|
||||||
|
$relationshipType = RelationshipType::AssignedToExclude->value;
|
||||||
|
}
|
||||||
$edges[] = [
|
$edges[] = [
|
||||||
'tenant_id' => (int) $item->tenant_id,
|
'tenant_id' => (int) $item->tenant_id,
|
||||||
'source_type' => 'inventory_item',
|
'source_type' => 'inventory_item',
|
||||||
'source_id' => (string) $item->external_id,
|
'source_id' => (string) $item->external_id,
|
||||||
'target_type' => 'foundation_object',
|
'target_type' => 'foundation_object',
|
||||||
'target_id' => $groupId,
|
'target_id' => $groupId,
|
||||||
'relationship_type' => RelationshipType::AssignedTo->value,
|
'relationship_type' => $relationshipType,
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'last_known_name' => null,
|
'last_known_name' => null,
|
||||||
'foundation_type' => 'aad_group',
|
'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;
|
continue;
|
||||||
Log::info('Unsupported reference shape encountered', $warning);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
return collect($edges);
|
||||||
|
|||||||
124
app/Services/Inventory/DependencyTargets/DependencyTargetDto.php
Normal file
124
app/Services/Inventory/DependencyTargets/DependencyTargetDto.php
Normal 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).'…';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@
|
|||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Contracts\Cache\Lock;
|
use Illuminate\Contracts\Cache\Lock;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class InventorySyncService
|
class InventorySyncService
|
||||||
@ -290,6 +291,18 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
|
|||||||
|
|
||||||
$observed++;
|
$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 = $policyData['displayName'] ?? $policyData['name'] ?? null;
|
||||||
$displayName = is_string($displayName) ? $displayName : null;
|
$displayName = is_string($displayName) ? $displayName : null;
|
||||||
|
|
||||||
@ -327,7 +340,6 @@ private function executeRun(InventorySyncRun $run, Tenant $tenant, array $normal
|
|||||||
$upserted++;
|
$upserted++;
|
||||||
|
|
||||||
// Extract dependencies if requested in selection
|
// Extract dependencies if requested in selection
|
||||||
$includeDeps = (bool) ($normalizedSelection['include_dependencies'] ?? true);
|
|
||||||
if ($includeDeps) {
|
if ($includeDeps) {
|
||||||
$warnings = array_merge(
|
$warnings = array_merge(
|
||||||
$warnings,
|
$warnings,
|
||||||
@ -386,6 +398,67 @@ private function shouldSkipPolicyForSelectedType(string $selectedPolicyType, arr
|
|||||||
return $this->resolveConfigurationPolicyType($policyData) !== $selectedPolicyType;
|
return $this->resolveConfigurationPolicyType($policyData) !== $selectedPolicyType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function shouldHydrateAssignments(string $policyType): bool
|
||||||
|
{
|
||||||
|
return in_array($policyType, ['settingsCatalogPolicy', 'endpointSecurityPolicy', 'securityBaselinePolicy'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $warnings
|
||||||
|
* @return null|array<int, mixed>
|
||||||
|
*/
|
||||||
|
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
|
private function resolveConfigurationPolicyType(array $policyData): string
|
||||||
{
|
{
|
||||||
$templateReference = $policyData['templateReference'] ?? null;
|
$templateReference = $policyData['templateReference'] ?? null;
|
||||||
|
|||||||
@ -5,6 +5,9 @@
|
|||||||
enum RelationshipType: string
|
enum RelationshipType: string
|
||||||
{
|
{
|
||||||
case AssignedTo = 'assigned_to';
|
case AssignedTo = 'assigned_to';
|
||||||
|
case AssignedToInclude = 'assigned_to_include';
|
||||||
|
case AssignedToExclude = 'assigned_to_exclude';
|
||||||
|
case UsesAssignmentFilter = 'uses_assignment_filter';
|
||||||
case ScopedBy = 'scoped_by';
|
case ScopedBy = 'scoped_by';
|
||||||
case Targets = 'targets';
|
case Targets = 'targets';
|
||||||
case DependsOn = 'depends_on';
|
case DependsOn = 'depends_on';
|
||||||
@ -13,6 +16,9 @@ public function label(): string
|
|||||||
{
|
{
|
||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::AssignedTo => 'Assigned to',
|
self::AssignedTo => 'Assigned to',
|
||||||
|
self::AssignedToInclude => 'Assigned to (include)',
|
||||||
|
self::AssignedToExclude => 'Assigned to (exclude)',
|
||||||
|
self::UsesAssignmentFilter => 'Uses assignment filter',
|
||||||
self::ScopedBy => 'Scoped by',
|
self::ScopedBy => 'Scoped by',
|
||||||
self::Targets => 'Targets',
|
self::Targets => 'Targets',
|
||||||
self::DependsOn => 'Depends on',
|
self::DependsOn => 'Depends on',
|
||||||
|
|||||||
@ -35,13 +35,15 @@
|
|||||||
@foreach ($group as $edge)
|
@foreach ($group as $edge)
|
||||||
@php
|
@php
|
||||||
$isMissing = ($edge['target_type'] ?? null) === 'missing';
|
$isMissing = ($edge['target_type'] ?? null) === 'missing';
|
||||||
$name = $edge['metadata']['last_known_name'] ?? null;
|
|
||||||
$targetId = $edge['target_id'] ?? 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';
|
$missingTitle = 'Missing target';
|
||||||
if (is_string($name) && $name !== '') {
|
$lastKnownName = $edge['metadata']['last_known_name'] ?? null;
|
||||||
$missingTitle .= ". Last known: {$name}";
|
if (is_string($lastKnownName) && $lastKnownName !== '') {
|
||||||
|
$missingTitle .= ". Last known: {$lastKnownName}";
|
||||||
}
|
}
|
||||||
$rawRef = $edge['metadata']['raw_ref'] ?? null;
|
$rawRef = $edge['metadata']['raw_ref'] ?? null;
|
||||||
if ($rawRef !== null) {
|
if ($rawRef !== null) {
|
||||||
@ -50,9 +52,26 @@
|
|||||||
$missingTitle .= '. Ref: '.\Illuminate\Support\Str::limit($encodedRef, 200);
|
$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
|
@endphp
|
||||||
<li class="flex items-center gap-2 text-sm">
|
<li class="flex items-center gap-2 text-sm">
|
||||||
<span class="fi-badge" title="{{ is_string($targetId) ? $targetId : '' }}">{{ $display }}</span>
|
@if (is_string($badgeText) && $badgeText !== '')
|
||||||
|
@if (is_string($linkUrl) && $linkUrl !== '')
|
||||||
|
<a class="fi-badge" href="{{ $linkUrl }}" title="{{ is_string($targetId) ? $targetId : '' }}">{{ $badgeText }}</a>
|
||||||
|
@else
|
||||||
|
<span class="fi-badge" title="{{ is_string($targetId) ? $targetId : '' }}">{{ $badgeText }}</span>
|
||||||
|
@endif
|
||||||
|
@else
|
||||||
|
<span class="fi-badge" title="{{ is_string($targetId) ? $targetId : '' }}">{{ $fallbackDisplay }}</span>
|
||||||
|
@endif
|
||||||
@if ($isMissing)
|
@if ($isMissing)
|
||||||
<span class="fi-badge fi-badge-danger" title="{{ $missingTitle }}">Missing</span>
|
<span class="fi-badge fi-badge-danger" title="{{ $missingTitle }}">Missing</span>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@ -17,14 +17,15 @@
|
|||||||
"source_id": { "type": "string" },
|
"source_id": { "type": "string" },
|
||||||
"target_type": { "type": "string", "enum": ["inventory_item", "foundation_object", "missing"] },
|
"target_type": { "type": "string", "enum": ["inventory_item", "foundation_object", "missing"] },
|
||||||
"target_id": { "type": ["string", "null"] },
|
"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": {
|
"metadata": {
|
||||||
"type": ["object", "null"],
|
"type": ["object", "null"],
|
||||||
"additionalProperties": true,
|
"additionalProperties": true,
|
||||||
"properties": {
|
"properties": {
|
||||||
"last_known_name": { "type": ["string", "null"] },
|
"last_known_name": { "type": ["string", "null"] },
|
||||||
"raw_ref": {},
|
"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" },
|
"created_at": { "type": ["string", "null"], "description": "ISO-8601 timestamp" },
|
||||||
|
|||||||
@ -50,12 +50,16 @@ #### InventoryLink.metadata
|
|||||||
- `raw_ref` (mixed/array; only when safe)
|
- `raw_ref` (mixed/array; only when safe)
|
||||||
|
|
||||||
Required when `target_type='foundation_object'`:
|
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
|
## Enums
|
||||||
|
|
||||||
### RelationshipType
|
### RelationshipType
|
||||||
- `assigned_to`
|
- `assigned_to`
|
||||||
|
- `assigned_to_include`
|
||||||
|
- `assigned_to_exclude`
|
||||||
|
- `uses_assignment_filter`
|
||||||
- `scoped_by`
|
- `scoped_by`
|
||||||
- `targets`
|
- `targets`
|
||||||
- `depends_on`
|
- `depends_on`
|
||||||
|
|||||||
@ -20,6 +20,10 @@ ### Session 2026-01-10
|
|||||||
- Q: Should `foundation_object` edges always store `metadata.foundation_type`? → A: Yes (required).
|
- 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).
|
- 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**:
|
**Definitions**:
|
||||||
- **Blast radius**: All resources directly affected by a change to a given item (outbound edges only; no transitive traversal in MVP).
|
- **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.
|
- **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")
|
- When the user filters by relationship type (single-select dropdown, default: "All")
|
||||||
- Then only matching edges are shown (empty selection = all edges visible)
|
- 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
|
### Scenario 5: Only missing prerequisites
|
||||||
- Given an item where all referenced targets are unresolved (no matching inventory or foundation objects)
|
- 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"
|
- When the user opens the dependencies view and selects "Outbound" or "All"
|
||||||
@ -58,7 +68,10 @@ ## Functional Requirements
|
|||||||
- **FR1: Relationship taxonomy**
|
- **FR1: Relationship taxonomy**
|
||||||
Define a normalized set of relationship types covering inventory→inventory and inventory→foundation edges.
|
Define a normalized set of relationship types covering inventory→inventory and inventory→foundation edges.
|
||||||
Supported types (MVP):
|
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)
|
- `scoped_by` (Policy → Scope Tag)
|
||||||
- `targets` (Update Policy → Device Category, conditional logic)
|
- `targets` (Update Policy → Device Category, conditional logic)
|
||||||
- `depends_on` (Generic prerequisite, e.g., Compliance Policy referenced by Conditional Access)
|
- `depends_on` (Generic prerequisite, e.g., Compliance Policy referenced by Conditional Access)
|
||||||
@ -85,6 +98,7 @@ ## Functional Requirements
|
|||||||
- AAD Groups (`aad_group`)
|
- AAD Groups (`aad_group`)
|
||||||
- Scope Tags (`scope_tag`)
|
- Scope Tags (`scope_tag`)
|
||||||
- Device Categories (`device_category`)
|
- 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).
|
**Out-of-scope foundation types** (for this iteration): Conditional Access Policies, Compliance Policies as foundation nodes (only as inventory items).
|
||||||
|
|
||||||
|
|||||||
@ -21,22 +21,11 @@ ## Phase 2: Foundational (Blocking Prerequisites)
|
|||||||
**Purpose**: Storage + extraction + query services that all UI stories rely on.
|
**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.
|
**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] 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] 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] 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] 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] 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] 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`)
|
- [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.
|
**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.
|
**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] 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] 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`
|
- [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.
|
**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`.
|
**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] 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] 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`
|
- [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.
|
**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.
|
**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] 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] 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`
|
- [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.
|
**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.
|
**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] 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`
|
- [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
|
## Phase 7: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
**Purpose**: Tighten docs/contracts and run quality gates.
|
**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] 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] 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`
|
- [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)
|
## Phase 8: Consistency & Security Coverage (Cross-Cutting)
|
||||||
|
|
||||||
**Purpose**: Close remaining spec→tasks gaps (ordering, masking, auth expectations, logging severity).
|
**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] 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] 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`
|
- [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
|
## Dependencies & Execution Order
|
||||||
|
|
||||||
### Phase Dependencies
|
### Phase Dependencies
|
||||||
|
|||||||
@ -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[1]->source_id)->toBe('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb');
|
||||||
expect($inbound[0]->created_at->greaterThan($inbound[1]->created_at))->toBeTrue();
|
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');
|
||||||
|
});
|
||||||
|
|||||||
@ -165,13 +165,85 @@
|
|||||||
'relationship_type' => 'assigned_to',
|
'relationship_type' => 'assigned_to',
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'last_known_name' => null,
|
'last_known_name' => null,
|
||||||
|
'foundation_type' => 'aad_group',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant);
|
$url = InventoryItemResource::getUrl('view', ['record' => $item], tenant: $tenant);
|
||||||
$this->get($url)
|
$this->get($url)
|
||||||
->assertOk()
|
->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 () {
|
it('blocks guest access to inventory item dependencies view', function () {
|
||||||
|
|||||||
@ -80,3 +80,35 @@
|
|||||||
&& ($context['policy_id'] ?? null) === $item->external_id)
|
&& ($context['policy_id'] ?? null) === $item->external_id)
|
||||||
->once();
|
->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);
|
||||||
|
});
|
||||||
|
|||||||
58
tests/Unit/DependencyTargetResolverTest.php
Normal file
58
tests/Unit/DependencyTargetResolverTest.php
Normal 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…)');
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user