Public/NC.EnterpriseApplications.ps1
|
#Requires -Version 5.0 using namespace System.Management.Automation # Nebula.Core: Enterprise Application lifecycle cmdlets ============================================================================================= function Export-EnterpriseApplication { <# .SYNOPSIS Exports an Enterprise Application (Application + Service Principal) to a JSON snapshot file. .DESCRIPTION Reads the source Application, its Service Principal, owners, and (optionally) App Role Assignments, then writes a normalized, versioned snapshot to -OutputPath. Client secret and certificate values are never read; only metadata (display name, key ID, expiry) is captured. .PARAMETER ApplicationName Display name of the source Enterprise Application. .PARAMETER ApplicationId Object ID of the source Application. .PARAMETER OutputPath Destination JSON file path. .PARAMETER IncludeAppRoleAssignments Also export App Role Assignments (users/groups assigned to the app). .PARAMETER Force Overwrite -OutputPath if it already exists. .EXAMPLE Export-EnterpriseApplication -ApplicationName "Contoso Test App" -OutputPath .\contoso-test-app.json .EXAMPLE Export-EnterpriseApplication -ApplicationName "Contoso Test App" -OutputPath .\contoso-test-app.json -IncludeAppRoleAssignments -Force #> [CmdletBinding(DefaultParameterSetName = 'ByName')] param( [Parameter(Mandatory = $true, ParameterSetName = 'ByName', Position = 0)] [string]$ApplicationName, [Parameter(Mandatory = $true, ParameterSetName = 'ById', Position = 0)] [string]$ApplicationId, [Parameter(Mandatory = $true, Position = 1)] [string]$OutputPath, [switch]$IncludeAppRoleAssignments, [switch]$Force ) begin { $graphReady = Test-MgGraphConnection -Scopes @('Application.Read.All', 'Directory.Read.All') -EnsureExchangeOnline:$false if (-not $graphReady) { Add-EmptyLine Write-NCMessage "Can't connect or use Microsoft Graph modules. Please check logs." -Level ERROR } } process { if (-not $graphReady) { return } if ((Test-Path -LiteralPath $OutputPath) -and -not $Force.IsPresent) { Write-NCMessage "Output file '$OutputPath' already exists. Use -Force to overwrite." -Level ERROR return } $snapshotParams = @{ IncludeAppRoleAssignments = $IncludeAppRoleAssignments.IsPresent } if ($PSCmdlet.ParameterSetName -eq 'ById') { $snapshotParams.ApplicationId = $ApplicationId } else { $snapshotParams.ApplicationName = $ApplicationName } $snapshot = Get-NCEnterpriseApplicationSnapshot @snapshotParams if (-not $snapshot) { return } try { $snapshot | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputPath -Encoding UTF8 -ErrorAction Stop Write-NCMessage "Exported Enterprise Application '$($snapshot.Application.DisplayName)' to '$OutputPath'." -Level SUCCESS } catch { Write-NCMessage "Failed to write snapshot to '$OutputPath': $($_.Exception.Message)" -Level ERROR } } } function Import-EnterpriseApplication { <# .SYNOPSIS Creates or updates an Enterprise Application from a JSON snapshot file. .DESCRIPTION Reads a snapshot produced by Export-EnterpriseApplication and applies its properties to the Enterprise Application identified by -TargetDisplayName, creating it if it does not exist. Owners and App Role Assignments are only added: those the destination has and the snapshot doesn't are kept, so an environment's own owners are never removed. .PARAMETER InputPath Path to the JSON snapshot file. .PARAMETER TargetDisplayName Display name of the destination Enterprise Application. Created if missing, updated if it exists. .PARAMETER IncludeAppRoleAssignments Also apply App Role Assignments captured in the snapshot. .PARAMETER PassThru Emit the apply-result summary object. .EXAMPLE Import-EnterpriseApplication -InputPath .\contoso-test-app.json -TargetDisplayName "Contoso Prod App" #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param( [Parameter(Mandatory = $true, Position = 0)] [string]$InputPath, [Parameter(Mandatory = $true, Position = 1)] [string]$TargetDisplayName, [switch]$IncludeAppRoleAssignments, [switch]$PassThru ) begin { $graphScopes = @('Application.ReadWrite.All', 'Directory.Read.All') if ($IncludeAppRoleAssignments.IsPresent) { # POST /servicePrincipals/{id}/appRoleAssignedTo needs its own permission $graphScopes += 'AppRoleAssignment.ReadWrite.All' } $graphReady = Test-MgGraphConnection -Scopes $graphScopes -EnsureExchangeOnline:$false if (-not $graphReady) { Add-EmptyLine Write-NCMessage "Can't connect or use Microsoft Graph modules. Please check logs." -Level ERROR } } process { if (-not $graphReady) { return } if (-not (Test-Path -LiteralPath $InputPath)) { Write-NCMessage "Input file '$InputPath' not found." -Level ERROR return } try { $snapshot = Get-Content -LiteralPath $InputPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop } catch { Write-NCMessage "Unable to read or parse snapshot '$InputPath': $($_.Exception.Message)" -Level ERROR return } # Missing collections become empty arrays when applied, which would clear an existing app: check the shape first if ($snapshot.SchemaVersion -ne 1) { Write-NCMessage "Snapshot '$InputPath' has schema version '$($snapshot.SchemaVersion)'; only version 1 (from Export-EnterpriseApplication) is supported." -Level ERROR return } # Every property the setter always writes must be present, or the import would clear it on the destination $requiredApplicationProperties = @('DisplayName', 'SignInAudience', 'Notes', 'Tags', 'Web', 'Spa', 'PublicClient', 'RequiredResourceAccess', 'AppRoles', 'Oauth2PermissionScopes', 'Owners') $applicationProperties = if ($snapshot.Application) { @($snapshot.Application.PSObject.Properties.Name) } else { @() } $missingProperties = @($requiredApplicationProperties | Where-Object { $applicationProperties -notcontains $_ } | ForEach-Object { "Application.$_" }) foreach ($redirectContainer in @('Web', 'Spa', 'PublicClient')) { $container = $snapshot.Application.$redirectContainer if ($applicationProperties -contains $redirectContainer -and -not ($container -and @($container.PSObject.Properties.Name) -contains 'redirectUris')) { $missingProperties += "Application.$redirectContainer.redirectUris" } } # The Web URLs and implicit grant settings are always written too (empty values clear them) $webContainer = $snapshot.Application.Web if ($applicationProperties -contains 'Web' -and $null -ne $webContainer) { $webProperties = @($webContainer.PSObject.Properties.Name) $missingProperties += @(@('homePageUrl', 'logoutUrl', 'implicitGrantSettings') | Where-Object { $webProperties -notcontains $_ } | ForEach-Object { "Application.Web.$_" }) } # Api is optional (older snapshots), but when present every setting in it is written if ($applicationProperties -contains 'Api' -and $null -ne $snapshot.Application.Api) { $apiProperties = @($snapshot.Application.Api.PSObject.Properties.Name) $missingProperties += @(@('AcceptMappedClaims', 'KnownClientApplications', 'PreAuthorizedApplications', 'RequestedAccessTokenVersion') | Where-Object { $apiProperties -notcontains $_ } | ForEach-Object { "Application.Api.$_" }) } if (-not $snapshot.ServicePrincipal) { $missingProperties += 'ServicePrincipal' } else { $servicePrincipalProperties = @($snapshot.ServicePrincipal.PSObject.Properties.Name) $missingProperties += @(@('Tags', 'Homepage') | Where-Object { $servicePrincipalProperties -notcontains $_ } | ForEach-Object { "ServicePrincipal.$_" }) } if ($missingProperties.Count -gt 0) { Write-NCMessage "Snapshot '$InputPath' is incomplete, nothing was imported. Missing: $($missingProperties -join ', ')." -Level ERROR return } if (-not $PSCmdlet.ShouldProcess($TargetDisplayName, "Import Enterprise Application from '$InputPath'")) { return } $result = Set-NCEnterpriseApplicationFromSnapshot -Snapshot $snapshot -TargetDisplayName $TargetDisplayName -IncludeAppRoleAssignments:$IncludeAppRoleAssignments.IsPresent -Confirm:$false if ($PassThru.IsPresent) { $result } } } function Copy-EnterpriseApplication { <# .SYNOPSIS Clones an Enterprise Application directly into a new or existing destination, without an intermediate file. .DESCRIPTION Combines Get-NCEnterpriseApplicationSnapshot and Set-NCEnterpriseApplicationFromSnapshot to read the source Enterprise Application and apply it to -TargetDisplayName in one step. Owners and App Role Assignments are only added: those the destination has and the source doesn't are kept. .PARAMETER SourceApplicationName Display name of the source Enterprise Application. .PARAMETER SourceApplicationId Object ID of the source Application. .PARAMETER TargetDisplayName Display name of the destination Enterprise Application. Created if missing, updated if it exists. .PARAMETER IncludeAppRoleAssignments Also copy App Role Assignments (users/groups assigned to the app). .PARAMETER PassThru Emit the apply-result summary object. .EXAMPLE Copy-EnterpriseApplication -SourceApplicationName "Contoso Test App" -TargetDisplayName "Contoso Prod App" #> [CmdletBinding(DefaultParameterSetName = 'ByName', SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param( [Parameter(Mandatory = $true, ParameterSetName = 'ByName', Position = 0)] [string]$SourceApplicationName, [Parameter(Mandatory = $true, ParameterSetName = 'ById', Position = 0)] [string]$SourceApplicationId, [Parameter(Mandatory = $true, Position = 1)] [string]$TargetDisplayName, [switch]$IncludeAppRoleAssignments, [switch]$PassThru ) begin { $graphScopes = @('Application.ReadWrite.All', 'Directory.Read.All') if ($IncludeAppRoleAssignments.IsPresent) { # POST /servicePrincipals/{id}/appRoleAssignedTo needs its own permission $graphScopes += 'AppRoleAssignment.ReadWrite.All' } $graphReady = Test-MgGraphConnection -Scopes $graphScopes -EnsureExchangeOnline:$false if (-not $graphReady) { Add-EmptyLine Write-NCMessage "Can't connect or use Microsoft Graph modules. Please check logs." -Level ERROR } } process { if (-not $graphReady) { return } $snapshotParams = @{ IncludeAppRoleAssignments = $IncludeAppRoleAssignments.IsPresent } if ($PSCmdlet.ParameterSetName -eq 'ById') { $snapshotParams.ApplicationId = $SourceApplicationId } else { $snapshotParams.ApplicationName = $SourceApplicationName } $snapshot = Get-NCEnterpriseApplicationSnapshot @snapshotParams if (-not $snapshot) { return } if ($snapshot.Application.DisplayName -eq $TargetDisplayName) { Write-NCMessage "Source and destination Enterprise Applications are the same. Aborting." -Level ERROR return } if (-not $PSCmdlet.ShouldProcess($TargetDisplayName, "Clone Enterprise Application '$($snapshot.Application.DisplayName)' into '$TargetDisplayName'")) { return } $result = Set-NCEnterpriseApplicationFromSnapshot -Snapshot $snapshot -TargetDisplayName $TargetDisplayName -IncludeAppRoleAssignments:$IncludeAppRoleAssignments.IsPresent -Confirm:$false if ($PassThru.IsPresent) { $result } } } function Compare-EnterpriseApplication { <# .SYNOPSIS Diffs two Enterprise Applications, each given as a JSON snapshot file or a live application. .DESCRIPTION Loads the reference and difference sides (file or live Graph lookup, independently), diffs them, and returns the differing properties on the pipeline. Optionally writes a CSV or JSON report. .PARAMETER ReferencePath JSON snapshot file for the reference ("A") side. .PARAMETER ReferenceApplicationName Display name of a live application for the reference side. .PARAMETER ReferenceApplicationId Object ID of a live application for the reference side. .PARAMETER DifferencePath JSON snapshot file for the difference ("B") side. .PARAMETER DifferenceApplicationName Display name of a live application for the difference side. .PARAMETER DifferenceApplicationId Object ID of a live application for the difference side. .PARAMETER IncludeAppRoleAssignments Also compare App Role Assignments. .PARAMETER OutputReportPath Optional report file. Written as JSON if the path ends in .json, otherwise as CSV. .PARAMETER PassThru Also emit the diff rows on the pipeline even when -OutputReportPath is used (rows are emitted by default; this has no additional effect and exists for symmetry with the other cmdlets). .EXAMPLE Compare-EnterpriseApplication -ReferencePath .\contoso-test-app.json -DifferenceApplicationName "Contoso Prod App" .EXAMPLE Compare-EnterpriseApplication -ReferenceApplicationName "Contoso Test App" -DifferenceApplicationName "Contoso Prod App" -OutputReportPath .\diff.csv #> [CmdletBinding()] param( [string]$ReferencePath, [string]$ReferenceApplicationName, [string]$ReferenceApplicationId, [string]$DifferencePath, [string]$DifferenceApplicationName, [string]$DifferenceApplicationId, [switch]$IncludeAppRoleAssignments, [string]$OutputReportPath, [switch]$PassThru ) begin { $referenceSourceCount = @($ReferencePath, $ReferenceApplicationName, $ReferenceApplicationId | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count $differenceSourceCount = @($DifferencePath, $DifferenceApplicationName, $DifferenceApplicationId | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count $inputValid = $true if ($referenceSourceCount -ne 1) { Write-NCMessage "Specify exactly one of -ReferencePath, -ReferenceApplicationName, or -ReferenceApplicationId." -Level ERROR $inputValid = $false } if ($differenceSourceCount -ne 1) { Write-NCMessage "Specify exactly one of -DifferencePath, -DifferenceApplicationName, or -DifferenceApplicationId." -Level ERROR $inputValid = $false } $graphReady = $true if ($inputValid -and ((-not $ReferencePath) -or (-not $DifferencePath))) { $graphReady = Test-MgGraphConnection -Scopes @('Application.Read.All', 'Directory.Read.All') -EnsureExchangeOnline:$false if (-not $graphReady) { Add-EmptyLine Write-NCMessage "Can't connect or use Microsoft Graph modules. Please check logs." -Level ERROR } } } process { if (-not $inputValid -or -not $graphReady) { return } $referenceSnapshot = if ($ReferencePath) { if (-not (Test-Path -LiteralPath $ReferencePath)) { Write-NCMessage "Reference file '$ReferencePath' not found." -Level ERROR return } Get-Content -LiteralPath $ReferencePath -Raw | ConvertFrom-Json } elseif ($ReferenceApplicationId) { Get-NCEnterpriseApplicationSnapshot -ApplicationId $ReferenceApplicationId -IncludeAppRoleAssignments:$IncludeAppRoleAssignments.IsPresent } else { Get-NCEnterpriseApplicationSnapshot -ApplicationName $ReferenceApplicationName -IncludeAppRoleAssignments:$IncludeAppRoleAssignments.IsPresent } if (-not $referenceSnapshot) { return } $differenceSnapshot = if ($DifferencePath) { if (-not (Test-Path -LiteralPath $DifferencePath)) { Write-NCMessage "Difference file '$DifferencePath' not found." -Level ERROR return } Get-Content -LiteralPath $DifferencePath -Raw | ConvertFrom-Json } elseif ($DifferenceApplicationId) { Get-NCEnterpriseApplicationSnapshot -ApplicationId $DifferenceApplicationId -IncludeAppRoleAssignments:$IncludeAppRoleAssignments.IsPresent } else { Get-NCEnterpriseApplicationSnapshot -ApplicationName $DifferenceApplicationName -IncludeAppRoleAssignments:$IncludeAppRoleAssignments.IsPresent } if (-not $differenceSnapshot) { return } $rows = Compare-NCEnterpriseApplicationSnapshot -ReferenceSnapshot $referenceSnapshot -DifferenceSnapshot $differenceSnapshot -IncludeAppRoleAssignments:$IncludeAppRoleAssignments.IsPresent $rows = @($rows) if ($OutputReportPath) { try { if ($OutputReportPath -match '\.json$') { # Do not pipe $rows into ConvertTo-Json: piping unwraps the array one item at a # time, so ConvertTo-Json would still emit a bare object for exactly one row and # nothing at all for zero rows. Passing -InputObject keeps $rows a single array # argument, so ConvertTo-Json always emits a JSON array ([], [{...}], [{...},{...}]). ConvertTo-Json -InputObject $rows -Depth 10 | Set-Content -LiteralPath $OutputReportPath -Encoding UTF8 -ErrorAction Stop } else { # Export-Csv stringifies each cell via .ToString(), which renders every non-scalar # ReferenceValue/DifferenceValue (Application.Web, .RequiredResourceAccess, .AppRoles, # .Owners, etc.) as the unhelpful, identical "@{...=System.Object[]}" text regardless of # what actually differs. Project both columns through ConvertTo-Json first so the CSV # cell carries readable, information-preserving content instead. Use -InputObject (not # a pipe) so an empty-array value isn't unwrapped/collapsed away to nothing - the same # pipe-vs-array-collapse hazard already worked around for $rows above. $null is handled # explicitly since `ConvertTo-Json -InputObject $null` produces nothing useful for a CSV # cell; render it as an empty string instead. $csvRows = $rows | ForEach-Object { [pscustomobject][ordered]@{ Property = $_.Property ReferenceValue = if ($null -eq $_.ReferenceValue) { '' } else { ConvertTo-Json -InputObject $_.ReferenceValue -Compress -Depth 10 } DifferenceValue = if ($null -eq $_.DifferenceValue) { '' } else { ConvertTo-Json -InputObject $_.DifferenceValue -Compress -Depth 10 } } } $csvRows | Export-Csv -LiteralPath $OutputReportPath -NoTypeInformation -Encoding $NCVars.CSV_Encoding -Delimiter $NCVars.CSV_DefaultLimiter -ErrorAction Stop } Write-NCMessage "Wrote comparison report to '$OutputReportPath' ($($rows.Count) difference(s))." -Level SUCCESS } catch { Write-NCMessage "Failed to write comparison report to '$OutputReportPath': $($_.Exception.Message)" -Level ERROR } } if ($rows.Count -eq 0) { Write-NCMessage "No differences found." -Level SUCCESS } else { Write-NCMessage "Found $($rows.Count) difference(s)." -Level WARNING } $rows } } |