Public/Invoke-TenantLensSnapshot.ps1
|
function Invoke-TenantLensSnapshot { <# .SYNOPSIS Collects the tenant configuration into a versionable snapshot folder. .DESCRIPTION Runs every collector (or the subset given via -Area), writes one JSON file per area plus a manifest.json with SHA-256 hashes. Areas whose Graph scopes are missing are skipped and recorded as skipped - never silently left out. .EXAMPLE Invoke-TenantLensSnapshot -OutputPath .\out #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$OutputPath, # Collector names to run (default: all). [string[]]$Area, # Override the tenant label used for the snapshot folder name. [string]$TenantName, # Pseudonymize user-identifying values (UPNs, mail, user display names) # deterministically, so diffs across redacted snapshots still work. [switch]$Redact ) $context = Get-MgContext if (-not $context) { throw 'Not connected to Microsoft Graph. Run Connect-TenantLens first.' } $collectors = @(Get-TLCollector -Name $Area) if ($collectors.Count -eq 0) { throw 'No collectors matched the requested areas.' } # Tenant label for the folder name $tenantLabel = $TenantName if (-not $tenantLabel) { try { $organization = Invoke-TLGraphRequest -Uri '/v1.0/organization?$select=id,displayName,verifiedDomains' -All | Select-Object -First 1 $defaultDomain = @($organization.verifiedDomains | Where-Object { $_.isDefault }) | Select-Object -First 1 if ($defaultDomain) { $tenantLabel = ([string]$defaultDomain.name -split '\.')[0] } elseif ($organization.displayName) { $tenantLabel = [string]$organization.displayName } } catch { Write-Verbose ("Could not resolve the tenant name: {0}" -f $_.Exception.Message) } } if (-not $tenantLabel) { $tenantLabel = [string]$context.TenantId } $tenantLabel = ($tenantLabel -replace '[^\w\.-]', '-').Trim('-').ToLowerInvariant() if (-not (Test-Path -Path $OutputPath)) { New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null } $runFolder = Join-Path -Path (Resolve-Path -Path $OutputPath).Path -ChildPath ("{0}_{1}" -f $tenantLabel, (Get-Date -Format 'yyyy-MM-dd_HHmm')) $snapshotDir = Join-Path -Path $runFolder -ChildPath 'snapshot' New-Item -Path $snapshotDir -ItemType Directory -Force | Out-Null $contextScopes = @($context.Scopes | Where-Object { $_ }) $areaInfos = [ordered]@{} $collected = [System.Collections.Generic.List[string]]::new() $skipped = [System.Collections.Generic.List[string]]::new() foreach ($collector in $collectors) { $result = [ordered]@{ area = $collector.Name apiVersion = $collector.ApiVersion collectedAtUtc = [DateTime]::UtcNow.ToString('o') skipped = $false skipReason = $null errors = @() data = $null } # Proactive scope check for delegated auth; app-only roles are not always # listed in the context, so app-only just tries and reports failures. $missingScopes = @($collector.RequiredScopes | Where-Object { $contextScopes -notcontains $_ }) if ($missingScopes.Count -gt 0 -and $context.AuthType -ne 'AppOnly') { $result['skipped'] = $true $result['skipReason'] = 'Missing Graph scopes: ' + ($missingScopes -join ', ') Write-Warning ("Area '{0}' skipped - {1}" -f $collector.Name, $result['skipReason']) } else { Write-Verbose ("Collecting area '{0}' ({1})..." -f $collector.Name, $collector.ApiVersion) try { $result['data'] = & $collector.Collect } catch { $result['skipped'] = $true $result['skipReason'] = 'Collection failed: ' + $_.Exception.Message $result['errors'] = @([string]$_.Exception.Message) Write-Warning ("Area '{0}' failed: {1}" -f $collector.Name, $_.Exception.Message) } } if ($Redact -and -not $result['skipped'] -and $null -ne $result['data']) { $result['data'] = Protect-TLSnapshot -InputObject $result['data'] } $filePath = Join-Path -Path $snapshotDir -ChildPath ($collector.FileName + '.json') Write-TLFile -Path $filePath -Content (ConvertTo-Json -InputObject $result -Depth 32) $areaInfos[$collector.Name] = [ordered]@{ skipped = [bool]$result['skipped'] skipReason = $result['skipReason'] file = ($collector.FileName + '.json') } if ($result['skipped']) { $skipped.Add($collector.Name) } else { $collected.Add($collector.Name) } } $tenantIdForManifest = [string]$context.TenantId if ($Redact -and $tenantIdForManifest) { $tenantIdForManifest = Get-TLPseudonym -Value $tenantIdForManifest -Prefix 'tenant' } Write-TLManifest -SnapshotPath $snapshotDir -Metadata @{ Tenant = [ordered]@{ id = $tenantIdForManifest label = $tenantLabel authType = [string]$context.AuthType } Scopes = $contextScopes Areas = $areaInfos Redacted = [bool]$Redact } | Out-Null Write-Verbose ("Snapshot written to '{0}' ({1} areas collected, {2} skipped)." -f $runFolder, $collected.Count, $skipped.Count) [pscustomobject]@{ Path = $runFolder SnapshotPath = $snapshotDir Tenant = $tenantLabel Areas = @($collected) SkippedAreas = @($skipped) } } |