Public/Invoke-IdentityAtlas.ps1
|
function Invoke-IdentityAtlas { [CmdletBinding()] param( [string] $OutputPath = (Join-Path (Get-Location) "IdentityAtlasReport-$([datetime]::Now.ToString('yyyyMMdd-HHmmss'))"), [switch] $OpenReport, [ValidateSet('Auto', 'Core', 'Governance')] [string] $CollectionProfile = 'Auto', [switch] $SkipSlowCollectors, [ValidateSet('GroupMembersAndOwners', 'DeviceOwners', 'AuthenticationMethods', 'ApplicationRoleAssignments', 'ApplicationOwners')] [string[]] $SkipCollector = @(), [ValidateRange(1, 20)] [int] $BatchSize = 10, [ValidateRange(1024, 65535)] [int] $Port = 8766, [ValidateRange(0, 50)] [int] $PortSearchLimit = 20, [switch] $Checkpoint, [switch] $Resume, [string] $SettingsPath, [string] $SaveSettingsPath, [switch] $IncludeConsent ) if (-not (Get-Command -Name Get-MgContext -ErrorAction SilentlyContinue)) { throw 'Microsoft.Graph.Authentication is required for live collection. Run Connect-IdentityAtlas after installing the dependency.' } $context = Get-MgContext if (-not $context -or -not $context.TenantId) { throw 'No Microsoft Graph PowerShell session is active. Run Connect-IdentityAtlas first.' } if ($SettingsPath) { $settings = Get-Content -LiteralPath $SettingsPath -Raw | ConvertFrom-Json -AsHashtable foreach ($key in $settings.Keys) { if ($key -notin @('CollectionProfile', 'SkipCollector', 'BatchSize', 'IncludeConsent')) { throw "Unsupported saved setting: $key" } } if (-not $PSBoundParameters.ContainsKey('CollectionProfile') -and $settings.ContainsKey('CollectionProfile')) { if ($settings.CollectionProfile -notin @('Core', 'Governance')) { throw 'Invalid saved collection profile.' } $CollectionProfile = $settings.CollectionProfile } if (-not $PSBoundParameters.ContainsKey('BatchSize') -and $settings.ContainsKey('BatchSize')) { if ($settings.BatchSize -notin 1..20) { throw 'Saved BatchSize must be from 1 to 20.' } $BatchSize = $settings.BatchSize } if (-not $PSBoundParameters.ContainsKey('SkipCollector') -and $settings.ContainsKey('SkipCollector')) { foreach ($name in $settings.SkipCollector) { if ($name -notin @('GroupMembersAndOwners', 'DeviceOwners', 'AuthenticationMethods', 'ApplicationRoleAssignments', 'ApplicationOwners')) { throw "Invalid saved collector: $name" } } $SkipCollector = @($settings.SkipCollector) } if (-not $PSBoundParameters.ContainsKey('IncludeConsent') -and $settings.ContainsKey('IncludeConsent')) { if ($settings.IncludeConsent -isnot [bool]) { throw 'Saved IncludeConsent must be a JSON boolean.' } $IncludeConsent = $settings.IncludeConsent } } if ($CollectionProfile -eq 'Auto') { $storedProfile = Get-Variable -Name IdentityAtlasCollectionProfile -Scope Script -ErrorAction SilentlyContinue $CollectionProfile = if ($storedProfile -and $storedProfile.Value) { $storedProfile.Value } else { 'Core' } } $skippedCollector = [System.Collections.Generic.HashSet[string]]::new( [System.StringComparer]::OrdinalIgnoreCase ) foreach ($collectorName in $SkipCollector) { [void] $skippedCollector.Add($collectorName) } if ($SkipSlowCollectors) { foreach ($collectorName in @('GroupMembersAndOwners', 'DeviceOwners', 'AuthenticationMethods', 'ApplicationRoleAssignments', 'ApplicationOwners')) { [void] $skippedCollector.Add($collectorName) } } $stepCount = if ($CollectionProfile -eq 'Governance') { 13 } else { 9 } if ($IncludeConsent) { $stepCount++ } if ($SaveSettingsPath) { if (Test-Path -LiteralPath $SaveSettingsPath) { throw 'Settings file already exists. Choose a new settings filename.' } Write-AtlasTextFile -Path ([IO.Path]::GetFullPath($SaveSettingsPath)) -Content (@{ CollectionProfile = $CollectionProfile; SkipCollector = @($skippedCollector); BatchSize = $BatchSize; IncludeConsent = [bool]$IncludeConsent } | ConvertTo-Json) } $effectiveBatchSize = $BatchSize $progressSummary = $null Initialize-AtlasProgress ` -StepCount $stepCount ` -CollectionProfile $CollectionProfile ` -SkippedCollector @($skippedCollector) | Out-Null try { $script:AtlasCheckpoint = $null if ($Checkpoint -or $Resume) { $implementationHashes = @(foreach ($folder in @('Private', 'Public')) { Get-ChildItem -LiteralPath (Join-Path $moduleRoot $folder) -Filter '*.ps1' -File | Sort-Object Name | ForEach-Object { "$folder/$($_.Name):$((Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash)" } }) $checkpointIdentity = [ordered]@{ tenant = $context.TenantId; account = $context.Account; client = $context.ClientId scopes = @($context.Scopes | Sort-Object); profile = $CollectionProfile skipped = @($skippedCollector | Sort-Object); batchSize = $BatchSize; consent = [bool]$IncludeConsent; version = '1.1.0-development' implementation = Get-AtlasStableId -InputString ($implementationHashes -join '|') } Initialize-AtlasCheckpoint -Path "$OutputPath.checkpoint" -Identity $checkpointIdentity -Resume:$Resume } $permissionPreflight = New-AtlasPermissionPreflightResult -ContextScope @($context.Scopes) -CollectionProfile $CollectionProfile -IncludeConsent:$IncludeConsent $users = Invoke-AtlasCollector -Name 'users' -DisplayName 'Users' -Step 1 -Collector { Get-AtlasUser -TenantId $context.TenantId } $groups = Invoke-AtlasCollector -Name 'groups' -DisplayName 'Groups' -Step 2 -Collector { Get-AtlasGroup ` -TenantId $context.TenantId ` -SkipMembersAndOwners:$skippedCollector.Contains('GroupMembersAndOwners') } $identityCollection = Merge-AtlasCollectionResult -Result @($permissionPreflight, $users, $groups) $devicesAndAuthentication = Invoke-AtlasCollector -Name 'devicesAndAuthentication' -DisplayName 'Devices and authentication methods' -Step 3 -Collector { Get-AtlasDeviceAndAuthentication ` -TenantId $context.TenantId ` -KnownNode @($identityCollection.Nodes) ` -SkipDeviceOwners:$skippedCollector.Contains('DeviceOwners') ` -SkipAuthenticationMethods:$skippedCollector.Contains('AuthenticationMethods') ` -BatchSize $effectiveBatchSize } $roles = Invoke-AtlasCollector -Name 'directoryRoles' -DisplayName 'Directory roles' -Step 4 -Collector { Get-AtlasDirectoryRole -TenantId $context.TenantId -KnownNode @($identityCollection.Nodes) } $applications = Invoke-AtlasCollector -Name 'applications' -DisplayName 'Applications' -Step 5 -Collector { Get-AtlasApplication ` -TenantId $context.TenantId ` -KnownNode @($identityCollection.Nodes) ` -SkipAppRoleAssignments:$skippedCollector.Contains('ApplicationRoleAssignments') ` -SkipOwners:$skippedCollector.Contains('ApplicationOwners') ` -BatchSize $effectiveBatchSize } $identityAndApplicationCollection = Merge-AtlasCollectionResult -Result @($identityCollection, $applications) $applicationManagementPolicies = Invoke-AtlasCollector -Name 'applicationManagementPolicies' -DisplayName 'Application management policies' -Step 6 -Collector { Get-AtlasApplicationManagementPolicy -TenantId $context.TenantId -KnownNode @($identityAndApplicationCollection.Nodes) } $crossTenantAccess = Invoke-AtlasCollector -Name 'crossTenantAccess' -DisplayName 'Cross-tenant access settings' -Step 7 -Collector { Get-AtlasCrossTenantAccess -TenantId $context.TenantId } $conditionalAccess = Invoke-AtlasCollector -Name 'conditionalAccess' -DisplayName 'Conditional Access policies' -Step 8 -Collector { Get-AtlasConditionalAccessPolicy -TenantId $context.TenantId -KnownNode @($identityAndApplicationCollection.Nodes) } $conditionalAccessReferences = Invoke-AtlasCollector -Name 'conditionalAccessReferences' -DisplayName 'Conditional Access references' -Step 9 -Collector { Get-AtlasConditionalAccessReference -TenantId $context.TenantId -KnownNode @($conditionalAccess.Nodes) } $coreCollection = Merge-AtlasCollectionResult -Result @( $identityCollection $devicesAndAuthentication $roles $applications $applicationManagementPolicies $crossTenantAccess $conditionalAccess $conditionalAccessReferences ) $governanceResults = @() if ($CollectionProfile -eq 'Governance') { $administrativeUnits = Invoke-AtlasCollector -Name 'administrativeUnits' -DisplayName 'Administrative Units' -Step 10 -Collector { Get-AtlasAdministrativeUnit -TenantId $context.TenantId -KnownNode @($coreCollection.Nodes) -KnownEdge @($roles.Edges) } $pimGroups = Invoke-AtlasCollector -Name 'pimGroups' -DisplayName 'PIM for Groups assignments' -Step 11 -Collector { Get-AtlasPrivilegedGroupAssignment -TenantId $context.TenantId -KnownNode @($coreCollection.Nodes) } $governanceFoundation = Merge-AtlasCollectionResult -Result @($coreCollection, $administrativeUnits, $pimGroups) $entitlementManagement = Invoke-AtlasCollector -Name 'entitlementManagement' -DisplayName 'Entitlement Management' -Step 12 -Collector { Get-AtlasEntitlementManagement -TenantId $context.TenantId -KnownNode @($governanceFoundation.Nodes) } $governanceWithEntitlements = Merge-AtlasCollectionResult -Result @($governanceFoundation, $entitlementManagement) $accessReviews = Invoke-AtlasCollector -Name 'accessReviews' -DisplayName 'Access Reviews' -Step 13 -Collector { Get-AtlasAccessReview -TenantId $context.TenantId -KnownNode @($governanceWithEntitlements.Nodes) } $governanceResults = @($administrativeUnits, $pimGroups, $entitlementManagement, $accessReviews) } $collection = Merge-AtlasCollectionResult -Result (@($coreCollection) + $governanceResults) $consent = $null if ($IncludeConsent) { $consent = Invoke-AtlasCollector -Name 'delegatedConsent' -DisplayName 'Delegated consent grants' -Step $stepCount -Collector { Get-AtlasConsent -TenantId $context.TenantId -KnownNode @($collection.Nodes) } $collection = Merge-AtlasCollectionResult -Result @($collection, $consent) } $collectors = @( @{ name = 'permissionPreflight'; status = $permissionPreflight.Status; metrics = $permissionPreflight.Metrics } @{ name = 'users'; status = $users.Status; metrics = $users.Metrics } @{ name = 'groups'; status = $groups.Status; metrics = $groups.Metrics } @{ name = 'devicesAndAuthentication'; status = $devicesAndAuthentication.Status; metrics = $devicesAndAuthentication.Metrics } @{ name = 'directoryRoles'; status = $roles.Status; metrics = $roles.Metrics } @{ name = 'applications'; status = $applications.Status; metrics = $applications.Metrics } @{ name = 'applicationManagementPolicies'; status = $applicationManagementPolicies.Status; metrics = $applicationManagementPolicies.Metrics } @{ name = 'crossTenantAccess'; status = $crossTenantAccess.Status; metrics = $crossTenantAccess.Metrics } @{ name = 'conditionalAccess'; status = $conditionalAccess.Status; metrics = $conditionalAccess.Metrics } @{ name = 'conditionalAccessReferences'; status = $conditionalAccessReferences.Status; metrics = $conditionalAccessReferences.Metrics } ) if ($CollectionProfile -eq 'Governance') { $collectors += @( @{ name = 'administrativeUnits'; status = $administrativeUnits.Status; metrics = $administrativeUnits.Metrics } @{ name = 'pimGroups'; status = $pimGroups.Status; metrics = $pimGroups.Metrics } @{ name = 'entitlementManagement'; status = $entitlementManagement.Status; metrics = $entitlementManagement.Metrics } @{ name = 'accessReviews'; status = $accessReviews.Status; metrics = $accessReviews.Metrics } ) } $report = New-AtlasReport -TenantId $context.TenantId -TenantDisplayName $context.TenantId -Collection $collection -Collectors $collectors -DataOrigin LiveTenant -CollectionProfile $CollectionProfile if ($IncludeConsent) { $report.manifest.coverage.collectors += @{ name = 'delegatedConsent'; status = $consent.Status; metrics = $consent.Metrics } } if ($script:AtlasCheckpoint -and $script:AtlasCheckpoint.Reused.Count) { $report.manifest.coverage.status = 'partial' $report.manifest.coverage.warnings += 'Resumed collection contains earlier evidence. Review individual timestamps; this is not a single-time snapshot.' $report.manifest['resumedCollectors'] = @($script:AtlasCheckpoint.Reused) } $indexFile = Write-AtlasReport -Report $report -OutputPath $OutputPath $progressSummary = Complete-AtlasProgress -Status Complete -OutputPath $indexFile.DirectoryName } catch [System.Management.Automation.PipelineStoppedException] { [void] (Complete-AtlasProgress -Status Cancelled -OutputPath $OutputPath) Write-AtlasInterruptedCollectionGuidance throw } catch [System.OperationCanceledException] { [void] (Complete-AtlasProgress -Status Cancelled -OutputPath $OutputPath) Write-AtlasInterruptedCollectionGuidance throw } catch { [void] (Complete-AtlasProgress -Status Failed -OutputPath $OutputPath) Write-AtlasInterruptedCollectionGuidance throw } finally { # Ctrl+C can stop the pipeline before the catch block can run output # cmdlets. Direct host calls in finally still work in an interactive # host; do not call functions or cmdlets on this fallback path. if ($script:IdentityAtlasProgressContext) { $interrupted = $script:IdentityAtlasProgressContext $interrupted.Stopwatch.Stop() $elapsedText = $interrupted.Stopwatch.Elapsed.ToString('hh\:mm\:ss') $Host.UI.WriteLine("Identity Atlas collection interrupted after $elapsedText | Collector: $($interrupted.CollectorDisplayName) | Completed stages $($interrupted.CompletedStepCount)/$($interrupted.StepCount) | Requests $($interrupted.RequestCount) | Retries $($interrupted.RetryCount).") $Host.UI.WriteLine("Processed $($interrupted.NodeCount) objects, $($interrupted.EdgeCount) relationships and $($interrupted.EvidenceCount) evidence records. No completed report is being returned by this run.") if ($script:AtlasCheckpoint) { $Host.UI.WriteLine("Checkpoint folder: $($script:AtlasCheckpoint.Root). Re-run the original command with -Resume and the same output folder, options, account and module within 24 hours. Completed collectors can be reused; interrupted or partial collectors start again. Keep checkpoint data private.") } else { $Host.UI.WriteLine('No checkpoint is active. Start a new collection; use -Checkpoint to retain completed collectors for resume.') } $script:IdentityAtlasProgressContext = $null } $script:AtlasCheckpoint = $null } $completion = Get-AtlasCompletionSummary -Report $report -ReportPath $indexFile.DirectoryName -SkippedCollector @($skippedCollector) Write-Information "Report saved: $($indexFile.FullName) | Coverage: $($report.manifest.coverage.status) | $($report.manifest.counts.nodes) unique objects, $($report.manifest.counts.edges) relationships, $($report.manifest.counts.evidence) evidence records." -InformationAction Continue Write-Information "Complete collectors: $($completion.CompleteCollectors -join ', ')." -InformationAction Continue if ($completion.IncompleteCollectors.Count) { Write-Information "Incomplete collectors: $($completion.IncompleteCollectors -join ', '). Coverage warnings: $($completion.WarningCount)." -InformationAction Continue } if ($completion.SkippedCollectors.Count) { Write-Information "Skipped: $($completion.SkippedCollectors -join ', ')." -InformationAction Continue } foreach ($action in $completion.NextActions) { Write-Information "Next: $action" -InformationAction Continue } Write-Information "Reopen this report: $($completion.ReopenCommand)" -InformationAction Continue $server = $null if ($OpenReport) { try { $server = Start-AtlasReportServer ` -ReportRoot $indexFile.DirectoryName ` -Port $Port ` -PortSearchLimit $PortSearchLimit ` -OpenBrowser } catch { if ($_.Exception -is [System.OperationCanceledException] -or $_.Exception -is [System.Management.Automation.PipelineStoppedException]) { throw } Write-Warning "The report was saved, but could not be opened automatically. Reopen it using: $($completion.ReopenCommand)" } } return [pscustomobject] @{ OutputPath = $indexFile.DirectoryName IndexPath = $indexFile.FullName NodeCount = $report.manifest.counts.nodes EdgeCount = $report.manifest.counts.edges EvidenceCount = $report.manifest.counts.evidence CoverageStatus = $report.manifest.coverage.status CollectionProfile = $CollectionProfile Duration = $progressSummary.Duration RequestCount = $progressSummary.RequestCount RetryCount = $progressSummary.RetryCount SkippedCollectors = @($progressSummary.SkippedCollectors) CompleteCollectors = $completion.CompleteCollectors IncompleteCollectors = $completion.IncompleteCollectors NextActions = $completion.NextActions ReopenCommand = $completion.ReopenCommand ReportUrl = if ($server) { $server.Url } else { $null } ServerProcessId = if ($server) { $server.ProcessId } else { $null } } } |