Private/DataProcessing/Convert-AccessPackageDocumentorData.ps1
|
function Convert-AccessPackageDocumentorData { <#! .SYNOPSIS Transforms raw access package data into a Documentor-friendly node/edge structure with detail payloads. .DESCRIPTION Creates Cytoscape-ready nodes and edges to visualize relationships between access packages, policies, resources, approval stages, custom extensions, verified ID requirements, and requestor justification rules. .PARAMETER AccessPackageData PSCustomObject returned by Get-AccessPackageGraphData. .OUTPUTS PSCustomObject with Nodes, Edges, and Stats properties ready for HTML rendering. #> [CmdletBinding()] param( [Parameter(Mandatory)][psobject]$AccessPackageData ) $nodes = @() $edges = @() $resourceMap = @{} $catalogMap = @{} $uncatId = 'cat-uncategorized' if (-not $AccessPackageData.AccessPackages -or $AccessPackageData.AccessPackages.Count -eq 0) { $stats = [pscustomobject]@{ PackageCount = 0; PolicyCount = 0; ResourceCount = 0; CatalogCount = 0 } return [pscustomobject]@{ Nodes = $nodes; Edges = $edges; Stats = $stats } } foreach ($pkg in $AccessPackageData.AccessPackages) { # Catalog node (deduplicated) $catalogId = $pkg.Catalog?.Id if ($catalogId) { $catNodeId = "cat-$catalogId" if (-not $catalogMap.ContainsKey($catNodeId)) { $catalogMap[$catNodeId] = $true $nodes += [pscustomobject]@{ id = $catNodeId label = $pkg.Catalog.DisplayName type = 'catalog' data = @{ description = $pkg.Catalog.Description catalogId = $pkg.Catalog.Id } } } } else { if (-not $catalogMap.ContainsKey($uncatId)) { $catalogMap[$uncatId] = $true $nodes += [pscustomobject]@{ id = $uncatId label = 'Uncatalogued' type = 'catalog' data = @{} } } $catNodeId = $uncatId } $pkgId = "ap-$($pkg.Id)" $nodes += [pscustomobject]@{ id = $pkgId label = $pkg.DisplayName type = 'package' data = @{ description = $pkg.Description catalog = if ($pkg.Catalog) { $pkg.Catalog.DisplayName } else { $null } catalogId = if ($pkg.Catalog) { $pkg.Catalog.Id } else { $null } } } $edges += [pscustomobject]@{ id = "edge-$catNodeId-$pkgId"; source = $catNodeId; target = $pkgId; type = 'contains' } # Group node: Policies $polGroupId = "polgrp-$($pkg.Id)" $policyList = @($pkg.AssignmentPolicies | Where-Object { $_ }) $nodes += [pscustomobject]@{ id = $polGroupId label = "Policies ($($policyList.Count))" type = 'policy-group' data = @{ packageName = $pkg.DisplayName count = $policyList.Count } } $edges += [pscustomobject]@{ id = "edge-$pkgId-$polGroupId"; source = $pkgId; target = $polGroupId; type = 'group' } # Group node: Resources $resGroupId = "resgrp-$($pkg.Id)" $resourceList = @($pkg.accessPackageResourceRoleScopes | Where-Object { $_ }) $nodes += [pscustomobject]@{ id = $resGroupId label = "Resources ($($resourceList.Count))" type = 'resource-group' data = @{ packageName = $pkg.DisplayName count = $resourceList.Count } } $edges += [pscustomobject]@{ id = "edge-$pkgId-$resGroupId"; source = $pkgId; target = $resGroupId; type = 'group' } # Resources (children of the Resources group node) foreach ($rrs in $resourceList) { $role = $rrs.role $scope = $rrs.scope $resource = $role.resource $typeLabel = Get-AccessPackageResourceTypeLabel -Resource $resource $resLabel = if ($resource.displayName) { $resource.displayName } else { $resource.originId } $roleDisplay = if ($role.displayName) { $role.displayName } else { $role.originId } $originSys = $resource.originSystem $nodeId = "res-$($pkg.Id)-$($rrs.id)" if (-not $resourceMap.ContainsKey($nodeId)) { $resourceMap[$nodeId] = $true $sharedKey = "{0}|{1}|{2}" -f $originSys, $resource.originId, $resource.resourceType $nodes += [pscustomobject]@{ id = $nodeId label = "${typeLabel}: $resLabel ($roleDisplay)" type = 'resource' data = @{ name = $resLabel type = $resource.resourceType typeLabel = $typeLabel originSystem = $originSys originId = $resource.originId resourceId = $resource.id roleDisplay = $roleDisplay roleId = $role.id assignmentType = $scope.originId scope = $scope.displayName scopeId = $scope.id sharedKey = $sharedKey } } } $edges += [pscustomobject]@{ id = "edge-$resGroupId-$nodeId"; source = $resGroupId; target = $nodeId; type = 'resource' } } # Policies (children of the Policies group node) foreach ($policy in $policyList) { $polId = "pol-$($policy.Id)" # Derive audience prefix from allowedTargetScope (case-insensitive) $targetScope = @($policy.AllowedTargetScope | ForEach-Object { $_.ToString().ToLower() }) $audiencePrefix = 'Admin Only: ' if (-not $targetScope -or $targetScope.Count -eq 0 -or $targetScope -contains 'none' -or $targetScope -contains 'notspecified') { $audiencePrefix = 'Admin Only: ' } elseif ($targetScope -like '*connectedorganization*') { $audiencePrefix = 'External: ' } else { # Default to Internal for any in-tenant targets (allDirectory*, allUsers*, allPrincipals*, allMembers*, etc.) $audiencePrefix = 'Internal: ' } # Normalize approval settings across legacy and current property names $approvalSettings = $policy.ApprovalSettings if (-not $approvalSettings) { $approvalSettings = $policy.requestApprovalSettings } if (-not $approvalSettings) { $approvalSettings = $policy.RequestApprovalSettings } $requestorJustification = $policy.RequestorSettings?.RequestsJustificationRequired if ($null -eq $requestorJustification -and $approvalSettings) { $requestorJustification = $approvalSettings.isRequestorJustificationRequired } # Extract questions - check both camelCase and PascalCase, use PSObject.Properties for reliability $questionsArray = @() $questionsProperty = $policy.PSObject.Properties | Where-Object { $_.Name -eq 'questions' -or $_.Name -eq 'Questions' } | Select-Object -First 1 if ($questionsProperty -and $questionsProperty.Value) { $questionsArray = @($questionsProperty.Value) } # Extract specificAllowedTargets - check both camelCase and PascalCase $targetsArray = @() $targetsProperty = $policy.PSObject.Properties | Where-Object { $_.Name -eq 'specificAllowedTargets' -or $_.Name -eq 'SpecificAllowedTargets' } | Select-Object -First 1 if ($targetsProperty -and $targetsProperty.Value) { $targetsArray = @($targetsProperty.Value) } $nodes += [pscustomobject]@{ id = $polId label = "$audiencePrefix$($policy.DisplayName)" type = 'policy' data = @{ description = $policy.Description durationInDays = $policy.DurationInDays requestorSettings = $policy.RequestorSettings requestorJustification = $requestorJustification requestApprovalSettings = $approvalSettings verificationSettings = $policy.RequestorSettings?.VerifiableCredentialSettings questions = $questionsArray approvalSettings = $approvalSettings allowedTargetScope = $policy.AllowedTargetScope audiencePrefix = $audiencePrefix.Trim() specificAllowedTargets = $targetsArray reviewSettings = $policy.ReviewSettings schedule = $policy.Schedule expiration = $policy.Expiration automaticRequestSettings = $policy.AutomaticRequestSettings notificationSettings = $policy.NotificationSettings assignmentRequirements = $policy.Requirements } } $edges += [pscustomobject]@{ id = "edge-$polGroupId-$polId"; source = $polGroupId; target = $polId; type = 'policy' } # Approval stages $approvalStages = @() if ($approvalSettings) { if ($approvalSettings.ApprovalStages) { $approvalStages = @($approvalSettings.ApprovalStages) } elseif ($approvalSettings.stages) { $approvalStages = @($approvalSettings.stages) } } if ($approvalStages.Count -gt 0) { $stageIndex = 0 $prevStageId = $null foreach ($stage in $approvalStages) { $stageIndex++ $stageId = "appr-$($policy.Id)-$stageIndex" $nodes += [pscustomobject]@{ id = $stageId label = "Approval Stage $stageIndex" type = 'approval-stage' data = @{ primaryApprovers = if ($stage.PrimaryApprovers -or $stage.primaryApprovers) { @($stage.PrimaryApprovers ?? $stage.primaryApprovers) } else { @() } backupApprovers = if ($stage.BackupApprovers -or $stage.fallbackPrimaryApprovers -or $stage.backupApprovers) { @($stage.BackupApprovers ?? $stage.fallbackPrimaryApprovers ?? $stage.backupApprovers) } else { @() } escalationApprovers = if ($stage.EscalationApprovers -or $stage.escalationApprovers) { @($stage.EscalationApprovers ?? $stage.escalationApprovers) } else { @() } approvalMode = $stage.ApprovalStageTimeOutBehavior ?? $stage.durationBeforeAutomaticDenial approvalStageTimeOutInDays = $stage.DurationBeforeAutomaticDenialInDays ?? $stage.approvalStageTimeOutInDays ?? $stage.durationBeforeAutomaticDenial isApproverJustificationRequired = $stage.IsApproverJustificationRequired ?? $stage.isApproverJustificationRequired isEscalationEnabled = $stage.IsEscalationEnabled ?? $stage.isEscalationEnabled escalationTimeInMinutes = $stage.EscalationTimeInMinutes ?? $stage.escalationTimeInMinutes escalationTimeInDays = if (($stage.EscalationTimeInMinutes -ne $null) -or ($stage.escalationTimeInMinutes -ne $null)) { [math]::Round(($stage.EscalationTimeInMinutes ?? $stage.escalationTimeInMinutes) / 1440, 2) } elseif (($stage.EscalationTimeInDays -ne $null) -or ($stage.escalationTimeInDays -ne $null)) { $stage.EscalationTimeInDays ?? $stage.escalationTimeInDays } else { $null } escalationTime = $stage.EscalationTime ?? $stage.escalationTime durationBeforeEscalation = $stage.DurationBeforeEscalation ?? $stage.durationBeforeEscalation } } if ($stageIndex -eq 1) { $edges += [pscustomobject]@{ id = "edge-$polId-$stageId"; source = $polId; target = $stageId; type = 'approval' } } elseif ($prevStageId) { $edges += [pscustomobject]@{ id = "edge-$prevStageId-$stageId"; source = $prevStageId; target = $stageId; type = 'approval-seq' } } $prevStageId = $stageId } } # Custom extensions (beta when available) if ($AccessPackageData.CustomExtensionsByPolicy.ContainsKey($policy.Id)) { $extSet = $AccessPackageData.CustomExtensionsByPolicy[$policy.Id] if ($extSet) { foreach ($ext in $extSet) { $extId = "ext-$($policy.Id)-$($ext.id)" $nodes += [pscustomobject]@{ id = $extId label = $ext.customExtension?.displayName type = 'custom-extension' data = @{ stage = $ext.stage customExtensionId = $ext.customExtension?.id condition = $ext.customExtension?.clientConfiguration?.timeoutDuration # Include full custom extension object for richer client-side rendering customExtension = if ($ext.customExtension) { $ext.customExtension } else { $null } } } $edges += [pscustomobject]@{ id = "edge-$polId-$extId"; source = $polId; target = $extId; type = 'custom-extension' } } } } } } $extCount = 0 if ($AccessPackageData.CustomExtensionsByPolicy) { # Count unique custom extensions by their Id, not total references across policies $allExtensions = $AccessPackageData.CustomExtensionsByPolicy.Values | ForEach-Object { $_ } | Where-Object { $_ -and $_.customExtension } $uniqueExtIds = $allExtensions | ForEach-Object { $_.customExtension.id } | Where-Object { $_ } | Select-Object -Unique $extCount = ($uniqueExtIds | Measure-Object).Count } # ---------------------------- # Orphaned Resources - Resources in catalog but not assigned to any active access package # ---------------------------- $orphanedResourceCount = 0 if ($AccessPackageData.OrphanedResourcesByCatalog) { foreach ($catalogId in $AccessPackageData.OrphanedResourcesByCatalog.Keys) { $orphanedResources = @($AccessPackageData.OrphanedResourcesByCatalog[$catalogId]) if ($orphanedResources.Count -eq 0) { continue } $catNodeId = "cat-$catalogId" # Only add orphaned resources if the catalog node exists if (-not $catalogMap.ContainsKey($catNodeId)) { continue } # Create orphaned resources group node $orphanedGroupId = "orphaned-$catalogId" $nodes += [pscustomobject]@{ id = $orphanedGroupId label = "Orphaned Resources ($($orphanedResources.Count))" type = 'orphaned-group' data = @{ catalogId = $catalogId count = $orphanedResources.Count } } $edges += [pscustomobject]@{ id = "edge-$catNodeId-$orphanedGroupId"; source = $catNodeId; target = $orphanedGroupId; type = 'orphaned' } # Create individual orphaned resource nodes foreach ($orphanedResource in $orphanedResources) { if (-not $orphanedResource.id) { continue } $typeLabel = Get-AccessPackageResourceTypeLabel -Resource $orphanedResource $resLabel = if ($orphanedResource.displayName) { $orphanedResource.displayName } else { $orphanedResource.originId } # Get role information if available $roleDisplay = "Unknown Role" if ($orphanedResource.roles -and $orphanedResource.roles.Count -gt 0) { $firstRole = $orphanedResource.roles[0] $roleDisplay = if ($firstRole.displayName) { $firstRole.displayName } else { $firstRole.originId } } $orphanedResId = "orphaned-res-$catalogId-$($orphanedResource.id)" $nodes += [pscustomobject]@{ id = $orphanedResId label = "${typeLabel}: $resLabel ($roleDisplay)" type = 'orphaned-resource' data = @{ name = $resLabel type = $orphanedResource.resourceType typeLabel = $typeLabel originSystem = $orphanedResource.originSystem originId = $orphanedResource.originId resourceId = $orphanedResource.id catalogId = $catalogId } } $edges += [pscustomobject]@{ id = "edge-$orphanedGroupId-$orphanedResId"; source = $orphanedGroupId; target = $orphanedResId; type = 'orphaned-resource' } $orphanedResourceCount++ } } } # ---------------------------- # Separation of Duty (SoD) Edges # ---------------------------- if ($AccessPackageData.IncompatibleAccessPackages) { $sodPairs = @{} # Create a set of valid package IDs for validation $validPackageIds = @{} foreach ($node in $nodes) { if ($node.type -eq 'package') { $validPackageIds[$node.id] = $true } } foreach ($sod in @($AccessPackageData.IncompatibleAccessPackages)) { if (-not $sod.SourcePackageId -or -not $sod.TargetPackageId) { continue } # Validate both nodes exist before creating edge $sourceNodeId = "ap-$($sod.SourcePackageId)" $targetNodeId = "ap-$($sod.TargetPackageId)" if (-not $validPackageIds.ContainsKey($sourceNodeId) -or -not $validPackageIds.ContainsKey($targetNodeId)) { # Skip if either package doesn't exist in the current dataset continue } # Create bidirectional key to avoid duplicate edges (A-B and B-A are the same) $key1 = "$($sod.SourcePackageId)|$($sod.TargetPackageId)" $key2 = "$($sod.TargetPackageId)|$($sod.SourcePackageId)" if (-not $sodPairs.ContainsKey($key1) -and -not $sodPairs.ContainsKey($key2)) { $sodPairs[$key1] = $true $edges += [pscustomobject]@{ id = "sod-$($sod.SourcePackageId)-$($sod.TargetPackageId)" source = $sourceNodeId target = $targetNodeId type = 'sod-conflict' } } } } $stats = [pscustomobject]@{ CatalogCount = $catalogMap.Count PackageCount = ($AccessPackageData.AccessPackages | Measure-Object).Count PolicyCount = ($AccessPackageData.AccessPackages | ForEach-Object { $_.AssignmentPolicies } | Where-Object { $_ } | Measure-Object).Count ResourceCount = $resourceMap.Count ExtensionCount = $extCount OrphanedResourceCount = $orphanedResourceCount } return [pscustomobject]@{ Nodes = $nodes Edges = $edges Stats = $stats } } |