Private/NC-Hlp.EnterpriseApplications.ps1
|
#Requires -Version 5.0 using namespace System.Management.Automation # Nebula.Core: (Private) Enterprise Application helpers ============================================================================================= function Get-NCEnterpriseApplicationSnapshot { <# .SYNOPSIS Reads an Enterprise Application (Application + Service Principal) into a normalized snapshot object. .PARAMETER ApplicationName Display name of the source Enterprise Application. .PARAMETER ApplicationId Object ID of the source Application. .PARAMETER IncludeAppRoleAssignments Also read App Role Assignments (users/groups assigned to the app). #> [CmdletBinding(DefaultParameterSetName = 'ByName')] param( [Parameter(Mandatory = $true, ParameterSetName = 'ByName')] [string]$ApplicationName, [Parameter(Mandatory = $true, ParameterSetName = 'ById')] [string]$ApplicationId, [switch]$IncludeAppRoleAssignments ) $selectProps = 'id,appId,displayName,signInAudience,identifierUris,notes,tags,web,spa,publicClient,requiredResourceAccess,appRoles,api,groupMembershipClaims,optionalClaims,isFallbackPublicClient,passwordCredentials,keyCredentials' if ($PSCmdlet.ParameterSetName -eq 'ById') { try { $app = Invoke-MgGraphRequest -Uri "v1.0/applications/$ApplicationId`?`$select=$selectProps" -Method GET -ErrorAction Stop } catch { Write-NCMessage "Enterprise Application with ID '$ApplicationId' not found: $($_.Exception.Message)" -Level ERROR return } } else { $escapedName = $ApplicationName.Replace("'", "''") try { $response = Invoke-MgGraphRequest -Uri "v1.0/applications?`$filter=$([uri]::EscapeDataString("displayName eq '$escapedName'"))&`$select=$selectProps" -Method GET -ErrorAction Stop } catch { Write-NCMessage "Unable to resolve Enterprise Application '$ApplicationName': $($_.Exception.Message)" -Level ERROR return } $foundApps = @($response.value) if ($foundApps.Count -eq 0) { Write-NCMessage "Enterprise Application '$ApplicationName' not found." -Level ERROR return } if ($foundApps.Count -gt 1) { Write-NCMessage "Multiple Enterprise Applications named '$ApplicationName' found. Use -ApplicationId instead." -Level ERROR return } $app = $foundApps[0] } try { $spResponse = Invoke-MgGraphRequest -Uri "v1.0/servicePrincipals?`$filter=appId eq '$($app.appId)'&`$select=id,appId,displayName,tags,homepage,logoUrl,appRoleAssignmentRequired,accountEnabled,preferredSingleSignOnMode" -Method GET -ErrorAction Stop } catch { Write-NCMessage "Unable to read Service Principal for '$($app.displayName)': $($_.Exception.Message)" -Level ERROR return } $sp = @($spResponse.value) | Select-Object -First 1 if (-not $sp) { Write-NCMessage "No Service Principal found for Application '$($app.displayName)' (appId: $($app.appId))." -Level ERROR return } try { $owners = @(Invoke-NCGraphAllPagesCore -Uri "v1.0/applications/$($app.id)/owners?`$select=id,displayName,userPrincipalName") } catch { # An incomplete owner list would make the snapshot (and any copy from it) silently drop owners Write-NCMessage "Unable to read owners for '$($app.displayName)', snapshot not created: $($_.Exception.Message)" -Level ERROR return } # Service Principal owners are a separate relationship from the application's owners try { $servicePrincipalOwners = @(Invoke-NCGraphAllPagesCore -Uri "v1.0/servicePrincipals/$($sp.id)/owners?`$select=id,displayName,userPrincipalName") } catch { Write-NCMessage "Unable to read Service Principal owners for '$($app.displayName)', snapshot not created: $($_.Exception.Message)" -Level ERROR return } $appRoleAssignments = @() if ($IncludeAppRoleAssignments.IsPresent) { try { $appRoleAssignments = @(Invoke-NCGraphAllPagesCore -Uri "v1.0/servicePrincipals/$($sp.id)/appRoleAssignedTo") } catch { Write-NCMessage "Unable to read App Role Assignments for '$($app.displayName)', snapshot not created: $($_.Exception.Message)" -Level ERROR return } } [pscustomobject][ordered]@{ SchemaVersion = 1 ExportedAt = (Get-Date).ToUniversalTime().ToString('o') Application = [pscustomobject][ordered]@{ DisplayName = $app.displayName SignInAudience = $app.signInAudience IdentifierUris = @($app.identifierUris) Notes = $app.notes Tags = @($app.tags) Web = $app.web Spa = $app.spa PublicClient = $app.publicClient RequiredResourceAccess = @($app.requiredResourceAccess) AppRoles = @($app.appRoles) Oauth2PermissionScopes = @($app.api.oauth2PermissionScopes) # Writable api settings besides the scopes, so a clone keeps pre-authorizations and token version Api = [pscustomobject][ordered]@{ AcceptMappedClaims = $app.api.acceptMappedClaims KnownClientApplications = @($app.api.knownClientApplications) PreAuthorizedApplications = @($app.api.preAuthorizedApplications) RequestedAccessTokenVersion = $app.api.requestedAccessTokenVersion } # Token claim settings: without them a clone issues different ID/access/SAML tokens GroupMembershipClaims = $app.groupMembershipClaims OptionalClaims = $app.optionalClaims # Public client for flows without a redirect URI (e.g. ROPC); the default is confidential IsFallbackPublicClient = $app.isFallbackPublicClient Owners = @($owners | ForEach-Object { [pscustomobject][ordered]@{ Id = $_.id DisplayName = $_.displayName UserPrincipalName = $_.userPrincipalName } }) } ServicePrincipal = [pscustomobject][ordered]@{ AppId = $sp.appId DisplayName = $sp.displayName Tags = @($sp.tags) Homepage = $sp.homepage LogoUrl = $sp.logoUrl # When true, only users and groups with an App Role Assignment can sign in AppRoleAssignmentRequired = [bool]$sp.appRoleAssignmentRequired # A disabled Service Principal blocks sign-in for the app; Graph omits it when it is the default (true) AccountEnabled = if ($null -eq $sp.accountEnabled) { $true } else { [bool]$sp.accountEnabled } # Captured for comparison only: SAML and password SSO need settings that are not cloned PreferredSingleSignOnMode = $sp.preferredSingleSignOnMode Owners = @($servicePrincipalOwners | ForEach-Object { [pscustomobject][ordered]@{ Id = $_.id DisplayName = $_.displayName UserPrincipalName = $_.userPrincipalName } }) } AppRoleAssignments = @($appRoleAssignments | ForEach-Object { [pscustomobject][ordered]@{ PrincipalId = $_.principalId PrincipalDisplayName = $_.principalDisplayName PrincipalType = $_.principalType AppRoleId = $_.appRoleId } }) CredentialsMetadata = [pscustomobject][ordered]@{ PasswordCredentials = @($app.passwordCredentials | ForEach-Object { [pscustomobject][ordered]@{ DisplayName = $_.displayName KeyId = $_.keyId EndDateTime = $_.endDateTime } }) KeyCredentials = @($app.keyCredentials | ForEach-Object { [pscustomobject][ordered]@{ DisplayName = $_.displayName KeyId = $_.keyId EndDateTime = $_.endDateTime } }) } } } function Set-NCEnterpriseApplicationFromSnapshot { <# .SYNOPSIS Creates or updates an Enterprise Application (Application + Service Principal) from a snapshot. .PARAMETER Snapshot Snapshot object produced by Get-NCEnterpriseApplicationSnapshot. .PARAMETER TargetDisplayName Display name of the destination Enterprise Application. Created if missing, updated if it exists. .PARAMETER IncludeAppRoleAssignments Also apply App Role Assignments from the snapshot. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param( [Parameter(Mandatory = $true)] [object]$Snapshot, [Parameter(Mandatory = $true)] [string]$TargetDisplayName, [switch]$IncludeAppRoleAssignments ) $escapedName = $TargetDisplayName.Replace("'", "''") try { $existingResponse = Invoke-MgGraphRequest -Uri "v1.0/applications?`$filter=$([uri]::EscapeDataString("displayName eq '$escapedName'"))&`$select=id,appId,displayName,appRoles,api" -Method GET -ErrorAction Stop } catch { Write-NCMessage "Unable to resolve target Enterprise Application '$TargetDisplayName': $($_.Exception.Message)" -Level ERROR [pscustomobject][ordered]@{ TargetDisplayName = $TargetDisplayName TargetApplicationId = $null Created = $false OwnersAdded = 0 OwnersSkipped = 0 OwnersFailed = 0 AssignmentsAdded = 0 AssignmentsSkipped = 0 AssignmentsFailed = 0 Error = "Unable to resolve target Enterprise Application '$TargetDisplayName': $($_.Exception.Message)" } return } $existingMatches = @($existingResponse.value) if ($existingMatches.Count -gt 1) { Write-NCMessage "Multiple Enterprise Applications named '$TargetDisplayName' found. Aborting to avoid ambiguity." -Level ERROR [pscustomobject][ordered]@{ TargetDisplayName = $TargetDisplayName TargetApplicationId = $null Created = $false OwnersAdded = 0 OwnersSkipped = 0 OwnersFailed = 0 AssignmentsAdded = 0 AssignmentsSkipped = 0 AssignmentsFailed = 0 Error = "Multiple Enterprise Applications named '$TargetDisplayName' found. Aborting to avoid ambiguity." } return } $targetApp = $existingMatches | Select-Object -First 1 $created = $false # Graph returns read-only companion fields (e.g. web.redirectUriSettings) alongside the writable # ones when reading an application; sending both back on create/update is rejected outright # ("Can't modify redirectUris and redirectUriSettings within the same request."). Rebuild each # sub-object from only the fields Graph accepts on write, instead of forwarding the raw payload. $webSource = $Snapshot.Application.Web $cleanWeb = [ordered]@{ redirectUris = @($webSource.redirectUris) } # Sent even when empty, so an update clears values the snapshot doesn't have $cleanWeb.homePageUrl = if ($webSource.homePageUrl) { $webSource.homePageUrl } else { $null } $cleanWeb.logoutUrl = if ($webSource.logoutUrl) { $webSource.logoutUrl } else { $null } $cleanWeb.implicitGrantSettings = if ($webSource.implicitGrantSettings) { $webSource.implicitGrantSettings } else { [ordered]@{ enableAccessTokenIssuance = $false; enableIdTokenIssuance = $false } } $cleanApi = [ordered]@{ oauth2PermissionScopes = @($Snapshot.Application.Oauth2PermissionScopes) } # Snapshots saved before the Api property existed only carry the scopes $apiSource = $Snapshot.Application.Api if ($apiSource) { # Nulls are sent too: both settings are nullable, and omitting them would keep the destination's values $cleanApi.acceptMappedClaims = $apiSource.AcceptMappedClaims $cleanApi.knownClientApplications = @($apiSource.KnownClientApplications | Where-Object { $_ }) $cleanApi.preAuthorizedApplications = @($apiSource.PreAuthorizedApplications | Where-Object { $_ }) $cleanApi.requestedAccessTokenVersion = $apiSource.RequestedAccessTokenVersion } $appBody = [ordered]@{ displayName = $TargetDisplayName signInAudience = $Snapshot.Application.SignInAudience notes = $Snapshot.Application.Notes tags = @($Snapshot.Application.Tags) web = $cleanWeb spa = @{ redirectUris = @($Snapshot.Application.Spa.redirectUris) } publicClient = @{ redirectUris = @($Snapshot.Application.PublicClient.redirectUris) } requiredResourceAccess = @($Snapshot.Application.RequiredResourceAccess) appRoles = @($Snapshot.Application.AppRoles) api = $cleanApi } # Snapshots saved before these properties existed leave the destination's values untouched $snapshotApplicationProperties = @($Snapshot.Application.PSObject.Properties.Name) if ($snapshotApplicationProperties -contains 'GroupMembershipClaims') { $appBody.groupMembershipClaims = $Snapshot.Application.GroupMembershipClaims } if ($snapshotApplicationProperties -contains 'OptionalClaims') { $appBody.optionalClaims = $Snapshot.Application.OptionalClaims } if ($snapshotApplicationProperties -contains 'IsFallbackPublicClient') { $appBody.isFallbackPublicClient = $Snapshot.Application.IsFallbackPublicClient } if ($Snapshot.Application.IdentifierUris -and @($Snapshot.Application.IdentifierUris).Count -gt 0) { Write-NCMessage "Source Enterprise Application '$($Snapshot.Application.DisplayName)' has identifierUris ($($Snapshot.Application.IdentifierUris -join ', ')). Identifier URIs are unique per tenant and are never copied to the destination app; set them manually if the destination needs to expose an API." -Level WARNING } if (-not $targetApp) { if (-not $PSCmdlet.ShouldProcess($TargetDisplayName, "Create Enterprise Application '$TargetDisplayName'")) { return } try { $targetApp = Invoke-MgGraphRequest -Uri 'v1.0/applications' -Method POST -Body ($appBody | ConvertTo-Json -Depth 10) -ContentType 'application/json' -ErrorAction Stop $created = $true Write-NCMessage "Created Enterprise Application '$TargetDisplayName'." -Level SUCCESS } catch { Write-NCMessage "Failed to create Enterprise Application '$TargetDisplayName': $($_.Exception.Message)" -Level ERROR [pscustomobject][ordered]@{ TargetDisplayName = $TargetDisplayName TargetApplicationId = $null Created = $false OwnersAdded = 0 OwnersSkipped = 0 OwnersFailed = 0 AssignmentsAdded = 0 AssignmentsSkipped = 0 AssignmentsFailed = 0 Error = "Failed to create Enterprise Application '$TargetDisplayName': $($_.Exception.Message)" } return } } else { if (-not $PSCmdlet.ShouldProcess($TargetDisplayName, "Update Enterprise Application '$TargetDisplayName'")) { return } $patchBody = [ordered]@{} foreach ($key in $appBody.Keys) { if ($key -eq 'displayName') { continue } $patchBody[$key] = $appBody[$key] } # Graph only removes an app role or permission scope that is already disabled: disable the ones the # snapshot drops in a first update, then send the snapshot collections $toTable = { param($Item) $table = [ordered]@{} if ($Item -is [System.Collections.IDictionary]) { foreach ($k in $Item.Keys) { $table[$k] = $Item[$k] } } elseif ($null -ne $Item) { foreach ($property in $Item.PSObject.Properties) { $table[$property.Name] = $property.Value } } $table } $keptRoleIds = @($Snapshot.Application.AppRoles | ForEach-Object { [string]$_.id }) $keptScopeIds = @($Snapshot.Application.Oauth2PermissionScopes | ForEach-Object { [string]$_.id }) $obsoleteRoleIds = @($targetApp.appRoles | Where-Object { $_.isEnabled -and $keptRoleIds -notcontains [string]$_.id } | ForEach-Object { [string]$_.id }) $obsoleteScopeIds = @($targetApp.api.oauth2PermissionScopes | Where-Object { $_.isEnabled -and $keptScopeIds -notcontains [string]$_.id } | ForEach-Object { [string]$_.id }) if ($obsoleteRoleIds.Count -gt 0 -or $obsoleteScopeIds.Count -gt 0) { $stagingBody = [ordered]@{} if ($obsoleteRoleIds.Count -gt 0) { $stagingBody.appRoles = @($targetApp.appRoles | ForEach-Object { $role = & $toTable $_ if ($obsoleteRoleIds -contains [string]$role.id) { $role.isEnabled = $false } $role }) } if ($obsoleteScopeIds.Count -gt 0) { $stagingApi = & $toTable $targetApp.api $stagingApi.oauth2PermissionScopes = @($targetApp.api.oauth2PermissionScopes | ForEach-Object { $scope = & $toTable $_ if ($obsoleteScopeIds -contains [string]$scope.id) { $scope.isEnabled = $false } $scope }) # A pre-authorization can't point at a scope that is being disabled $stagingApi.preAuthorizedApplications = @($targetApp.api.preAuthorizedApplications | ForEach-Object { $preAuthorized = & $toTable $_ $preAuthorized.delegatedPermissionIds = @($preAuthorized.delegatedPermissionIds | Where-Object { $obsoleteScopeIds -notcontains [string]$_ }) if ($preAuthorized.delegatedPermissionIds.Count -gt 0) { $preAuthorized } }) $stagingBody.api = $stagingApi } try { Invoke-MgGraphRequest -Uri "v1.0/applications/$($targetApp.id)" -Method PATCH -Body ($stagingBody | ConvertTo-Json -Depth 10) -ContentType 'application/json' -ErrorAction Stop | Out-Null Write-Verbose "Disabled $($obsoleteRoleIds.Count) app role(s) and $($obsoleteScopeIds.Count) permission scope(s) before removing them from '$TargetDisplayName'." } catch { Write-NCMessage "Failed to disable the app roles or permission scopes removed from '$TargetDisplayName': $($_.Exception.Message)" -Level ERROR [pscustomobject][ordered]@{ TargetDisplayName = $TargetDisplayName TargetApplicationId = $null Created = $false OwnersAdded = 0 OwnersSkipped = 0 OwnersFailed = 0 AssignmentsAdded = 0 AssignmentsSkipped = 0 AssignmentsFailed = 0 Error = "Failed to disable the app roles or permission scopes removed from '$TargetDisplayName': $($_.Exception.Message)" } return } } try { Invoke-MgGraphRequest -Uri "v1.0/applications/$($targetApp.id)" -Method PATCH -Body ($patchBody | ConvertTo-Json -Depth 10) -ContentType 'application/json' -ErrorAction Stop | Out-Null Write-NCMessage "Updated Enterprise Application '$TargetDisplayName'." -Level SUCCESS } catch { Write-NCMessage "Failed to update Enterprise Application '$TargetDisplayName': $($_.Exception.Message)" -Level ERROR [pscustomobject][ordered]@{ TargetDisplayName = $TargetDisplayName TargetApplicationId = $null Created = $false OwnersAdded = 0 OwnersSkipped = 0 OwnersFailed = 0 AssignmentsAdded = 0 AssignmentsSkipped = 0 AssignmentsFailed = 0 Error = "Failed to update Enterprise Application '$TargetDisplayName': $($_.Exception.Message)" } return } } try { $spResponse = Invoke-MgGraphRequest -Uri "v1.0/servicePrincipals?`$filter=appId eq '$($targetApp.appId)'&`$select=id,appId,displayName" -Method GET -ErrorAction Stop } catch { Write-NCMessage "Unable to check Service Principal for '$TargetDisplayName': $($_.Exception.Message)" -Level ERROR [pscustomobject][ordered]@{ TargetDisplayName = $TargetDisplayName TargetApplicationId = $null Created = $false OwnersAdded = 0 OwnersSkipped = 0 OwnersFailed = 0 AssignmentsAdded = 0 AssignmentsSkipped = 0 AssignmentsFailed = 0 Error = "Unable to check Service Principal for '$TargetDisplayName': $($_.Exception.Message)" } return } # Tags (e.g. WindowsAzureActiveDirectoryIntegratedApp) are what makes the Entra Portal list a # Service Principal under "Enterprise applications" at all; a bare `appId`-only create leaves # the app visible in "App registrations" but absent from "Enterprise applications". homepageUrl # is applied too; logoUrl is read-only in Graph and is never written. # homepage is always written so an update also clears a value the snapshot doesn't have $spWriteBody = [ordered]@{ tags = @($Snapshot.ServicePrincipal.Tags) homepage = if ($Snapshot.ServicePrincipal.Homepage) { $Snapshot.ServicePrincipal.Homepage } else { $null } } $snapshotServicePrincipalProperties = @($Snapshot.ServicePrincipal.PSObject.Properties.Name) if ($snapshotServicePrincipalProperties -contains 'AppRoleAssignmentRequired') { $spWriteBody.appRoleAssignmentRequired = [bool]$Snapshot.ServicePrincipal.AppRoleAssignmentRequired } if ($snapshotServicePrincipalProperties -contains 'AccountEnabled') { $spWriteBody.accountEnabled = [bool]$Snapshot.ServicePrincipal.AccountEnabled } # SAML and password SSO depend on signing certificates and SSO settings this cmdlet doesn't copy if (@('saml', 'password') -contains [string]$Snapshot.ServicePrincipal.PreferredSingleSignOnMode) { Write-NCMessage "Source Enterprise Application '$($Snapshot.Application.DisplayName)' uses $($Snapshot.ServicePrincipal.PreferredSingleSignOnMode) single sign-on, which is not copied (signing certificates and SSO settings included). Configure single sign-on on '$TargetDisplayName' manually." -Level WARNING } $targetSp = @($spResponse.value) | Select-Object -First 1 if (-not $targetSp) { $spCreateBody = [ordered]@{ appId = $targetApp.appId } foreach ($key in $spWriteBody.Keys) { if ($key -eq 'homepage' -and $null -eq $spWriteBody[$key]) { continue } $spCreateBody[$key] = $spWriteBody[$key] } try { $targetSp = Invoke-MgGraphRequest -Uri 'v1.0/servicePrincipals' -Method POST -Body ($spCreateBody | ConvertTo-Json -Depth 5) -ContentType 'application/json' -ErrorAction Stop Write-NCMessage "Created Service Principal for '$TargetDisplayName'." -Level SUCCESS } catch { Write-NCMessage "Failed to create Service Principal for '$TargetDisplayName': $($_.Exception.Message)" -Level ERROR [pscustomobject][ordered]@{ TargetDisplayName = $TargetDisplayName TargetApplicationId = $null Created = $false OwnersAdded = 0 OwnersSkipped = 0 OwnersFailed = 0 AssignmentsAdded = 0 AssignmentsSkipped = 0 AssignmentsFailed = 0 Error = "Failed to create Service Principal for '$TargetDisplayName': $($_.Exception.Message)" } return } } else { try { Invoke-MgGraphRequest -Uri "v1.0/servicePrincipals/$($targetSp.id)" -Method PATCH -Body ($spWriteBody | ConvertTo-Json -Depth 5) -ContentType 'application/json' -ErrorAction Stop | Out-Null } catch { # Stop here: reporting success would hide stale sign-in settings (enabled state, assignment requirement) Write-NCMessage "Failed to update the Service Principal for '$TargetDisplayName': $($_.Exception.Message)" -Level ERROR [pscustomobject][ordered]@{ TargetDisplayName = $TargetDisplayName TargetApplicationId = $targetApp.id Created = $false OwnersAdded = 0 OwnersSkipped = 0 OwnersFailed = 0 AssignmentsAdded = 0 AssignmentsSkipped = 0 AssignmentsFailed = 0 Error = "Failed to update the Service Principal for '$TargetDisplayName': $($_.Exception.Message)" } return } } # Owners are only added (see Import-EnterpriseApplication): the destination keeps its own $ownersAdded = 0 $ownersSkipped = 0 $ownersFailed = 0 $syncOwners = { param([string]$OwnersPath, [object[]]$Owners, [string]$OwnerKind) $added = 0 $skipped = 0 $failed = 0 if ($Owners -and @($Owners).Count -gt 0) { try { $destinationOwners = @(Invoke-NCGraphAllPagesCore -Uri "v1.0/$OwnersPath`?`$select=id") } catch { Write-NCMessage "Unable to read existing $OwnerKind owners for '$TargetDisplayName': $($_.Exception.Message)" -Level WARNING $destinationOwners = @() } $destinationOwnerIds = @($destinationOwners | ForEach-Object { [string]$_.id }) foreach ($owner in @($Owners)) { if ($destinationOwnerIds -contains $owner.Id) { $skipped++ continue } try { $body = @{ '@odata.id' = (Get-NCGraphDirectoryObjectUri -Id $owner.Id) } | ConvertTo-Json -Depth 3 Invoke-MgGraphRequest -Uri "v1.0/$OwnersPath/`$ref" -Method POST -Body $body -ContentType 'application/json' -ErrorAction Stop | Out-Null $added++ Write-NCMessage "Copied $OwnerKind owner '$($owner.DisplayName)' to '$TargetDisplayName'." -Level SUCCESS } catch { if ($_.Exception.Message -match 'already exist' -or $_.Exception.Message -match 'exists') { $skipped++ } else { Write-NCMessage "Failed to copy $OwnerKind owner '$($owner.DisplayName)' to '$TargetDisplayName': $($_.Exception.Message)" -Level ERROR $failed++ } } } } [pscustomobject]@{ Added = $added; Skipped = $skipped; Failed = $failed } } $ownerResults = @( & $syncOwners "applications/$($targetApp.id)/owners" @($Snapshot.Application.Owners) 'application' # Snapshots saved before Service Principal owners were captured carry no Owners there & $syncOwners "servicePrincipals/$($targetSp.id)/owners" @($Snapshot.ServicePrincipal.Owners) 'Service Principal' ) foreach ($ownerResult in $ownerResults) { $ownersAdded += $ownerResult.Added $ownersSkipped += $ownerResult.Skipped $ownersFailed += $ownerResult.Failed } $assignmentsAdded = 0 $assignmentsSkipped = 0 $assignmentsFailed = 0 if ($IncludeAppRoleAssignments.IsPresent -and $Snapshot.AppRoleAssignments -and @($Snapshot.AppRoleAssignments).Count -gt 0) { try { $destinationAssignments = @(Invoke-NCGraphAllPagesCore -Uri "v1.0/servicePrincipals/$($targetSp.id)/appRoleAssignedTo") } catch { Write-NCMessage "Unable to read existing App Role Assignments for '$TargetDisplayName': $($_.Exception.Message)" -Level WARNING $destinationAssignments = @() } foreach ($assignment in @($Snapshot.AppRoleAssignments)) { $alreadyAssigned = $destinationAssignments | Where-Object { $_.principalId -eq $assignment.PrincipalId -and $_.appRoleId -eq $assignment.AppRoleId } if ($alreadyAssigned) { $assignmentsSkipped++ continue } try { $body = @{ principalId = $assignment.PrincipalId resourceId = $targetSp.id appRoleId = $assignment.AppRoleId } | ConvertTo-Json -Depth 3 Invoke-MgGraphRequest -Uri "v1.0/servicePrincipals/$($targetSp.id)/appRoleAssignedTo" -Method POST -Body $body -ContentType 'application/json' -ErrorAction Stop | Out-Null $assignmentsAdded++ Write-NCMessage "Assigned '$($assignment.PrincipalDisplayName)' to '$TargetDisplayName'." -Level SUCCESS } catch { if ($_.Exception.Message -match 'already exist' -or $_.Exception.Message -match 'exists') { $assignmentsSkipped++ } else { Write-NCMessage "Failed to assign '$($assignment.PrincipalDisplayName)' to '$TargetDisplayName': $($_.Exception.Message)" -Level ERROR $assignmentsFailed++ } } } } [pscustomobject][ordered]@{ TargetDisplayName = $TargetDisplayName TargetApplicationId = $targetApp.id Created = $created OwnersAdded = $ownersAdded OwnersSkipped = $ownersSkipped OwnersFailed = $ownersFailed AssignmentsAdded = $assignmentsAdded AssignmentsSkipped = $assignmentsSkipped AssignmentsFailed = $assignmentsFailed Error = $null } } function Compare-NCEnterpriseApplicationSnapshot { <# .SYNOPSIS Diffs two Enterprise Application snapshots. .PARAMETER ReferenceSnapshot Baseline snapshot (the "A" side). .PARAMETER DifferenceSnapshot Snapshot to compare against the baseline (the "B" side). .PARAMETER IncludeAppRoleAssignments Also compare App Role Assignments. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [object]$ReferenceSnapshot, [Parameter(Mandatory = $true)] [object]$DifferenceSnapshot, [switch]$IncludeAppRoleAssignments ) $rows = [System.Collections.Generic.List[object]]::new() $addIfDifferent = { param($name, $refValue, $diffValue) $refJson = ($refValue | ConvertTo-Json -Depth 10 -Compress) $diffJson = ($diffValue | ConvertTo-Json -Depth 10 -Compress) if ($refJson -ne $diffJson) { $rows.Add([pscustomobject][ordered]@{ Property = $name ReferenceValue = $refValue DifferenceValue = $diffValue }) } } # Graph returns these collections in no stable order: sort them so the same content compares equal $sortedBy = { param($Items, [string[]]$Keys) @(@($Items | Where-Object { $null -ne $_ }) | Sort-Object -Property $Keys) } # Items can be objects (live snapshot) or hashtables: sort and copy through key access, which works on both $sortedByKey = { param($Items, [string[]]$Keys) $sortKeys = @(foreach ($key in $Keys) { $sortKey = $key; { [string]$_.$sortKey }.GetNewClosure() }) @(@($Items | Where-Object { $null -ne $_ }) | Sort-Object -Property $sortKeys) } $sortedValues = { param($Items) @(@($Items | Where-Object { $null -ne $_ } | ForEach-Object { [string]$_ }) | Sort-Object) } $toOrdered = { param($Item) $table = [ordered]@{} if ($Item -is [System.Collections.IDictionary]) { foreach ($k in $Item.Keys) { $table[[string]$k] = $Item[$k] } } elseif ($null -ne $Item) { foreach ($property in $Item.PSObject.Properties) { $table[$property.Name] = $property.Value } } $table } $canonicalRedirects = { param($Container) if ($null -eq $Container) { return $null } $table = & $toOrdered $Container if ($table.Contains('redirectUris')) { $table['redirectUris'] = & $sortedValues $table['redirectUris'] } [pscustomobject]$table } $canonicalPermissions = { param($Items) @(& $sortedByKey $Items 'resourceAppId' | ForEach-Object { [pscustomobject][ordered]@{ resourceAppId = [string]$_.resourceAppId resourceAccess = @(& $sortedByKey $_.resourceAccess 'id', 'type' | ForEach-Object { [pscustomobject][ordered]@{ id = [string]$_.id; type = [string]$_.type } }) } }) } $canonicalAppRoles = { param($Items) @(& $sortedByKey $Items 'id' | ForEach-Object { $role = & $toOrdered $_ if ($role.Contains('allowedMemberTypes')) { $role['allowedMemberTypes'] = & $sortedValues $role['allowedMemberTypes'] } [pscustomobject]$role }) } $canonicalOptionalClaims = { param($OptionalClaims) if ($null -eq $OptionalClaims) { return $null } $table = & $toOrdered $OptionalClaims foreach ($tokenType in @($table.Keys)) { $table[$tokenType] = @(& $sortedByKey $table[$tokenType] 'name', 'source' | ForEach-Object { $claim = & $toOrdered $_ if ($claim.Contains('additionalProperties')) { $claim['additionalProperties'] = & $sortedValues $claim['additionalProperties'] } [pscustomobject]$claim }) } [pscustomobject]$table } $canonicalApi = { param($Api) if ($null -eq $Api) { return $null } $table = & $toOrdered $Api if ($table.Contains('KnownClientApplications')) { $table['KnownClientApplications'] = & $sortedValues $table['KnownClientApplications'] } if ($table.Contains('PreAuthorizedApplications')) { $table['PreAuthorizedApplications'] = @(& $sortedByKey $table['PreAuthorizedApplications'] 'appId' | ForEach-Object { [pscustomobject][ordered]@{ appId = [string]$_.appId; delegatedPermissionIds = & $sortedValues $_.delegatedPermissionIds } }) } [pscustomobject]$table } & $addIfDifferent 'Application.DisplayName' $ReferenceSnapshot.Application.DisplayName $DifferenceSnapshot.Application.DisplayName & $addIfDifferent 'Application.SignInAudience' $ReferenceSnapshot.Application.SignInAudience $DifferenceSnapshot.Application.SignInAudience & $addIfDifferent 'Application.IdentifierUris' (& $sortedValues $ReferenceSnapshot.Application.IdentifierUris) (& $sortedValues $DifferenceSnapshot.Application.IdentifierUris) & $addIfDifferent 'Application.Notes' $ReferenceSnapshot.Application.Notes $DifferenceSnapshot.Application.Notes & $addIfDifferent 'Application.Tags' (& $sortedValues $ReferenceSnapshot.Application.Tags) (& $sortedValues $DifferenceSnapshot.Application.Tags) & $addIfDifferent 'Application.Owners' (& $sortedBy $ReferenceSnapshot.Application.Owners 'Id') (& $sortedBy $DifferenceSnapshot.Application.Owners 'Id') & $addIfDifferent 'Application.Web' (& $canonicalRedirects $ReferenceSnapshot.Application.Web) (& $canonicalRedirects $DifferenceSnapshot.Application.Web) & $addIfDifferent 'Application.Spa' (& $canonicalRedirects $ReferenceSnapshot.Application.Spa) (& $canonicalRedirects $DifferenceSnapshot.Application.Spa) & $addIfDifferent 'Application.PublicClient' (& $canonicalRedirects $ReferenceSnapshot.Application.PublicClient) (& $canonicalRedirects $DifferenceSnapshot.Application.PublicClient) & $addIfDifferent 'Application.RequiredResourceAccess' (& $canonicalPermissions $ReferenceSnapshot.Application.RequiredResourceAccess) (& $canonicalPermissions $DifferenceSnapshot.Application.RequiredResourceAccess) & $addIfDifferent 'Application.AppRoles' (& $canonicalAppRoles $ReferenceSnapshot.Application.AppRoles) (& $canonicalAppRoles $DifferenceSnapshot.Application.AppRoles) & $addIfDifferent 'Application.Oauth2PermissionScopes' (& $sortedByKey $ReferenceSnapshot.Application.Oauth2PermissionScopes 'id') (& $sortedByKey $DifferenceSnapshot.Application.Oauth2PermissionScopes 'id') & $addIfDifferent 'Application.Api' (& $canonicalApi $ReferenceSnapshot.Application.Api) (& $canonicalApi $DifferenceSnapshot.Application.Api) & $addIfDifferent 'Application.GroupMembershipClaims' $ReferenceSnapshot.Application.GroupMembershipClaims $DifferenceSnapshot.Application.GroupMembershipClaims & $addIfDifferent 'Application.OptionalClaims' (& $canonicalOptionalClaims $ReferenceSnapshot.Application.OptionalClaims) (& $canonicalOptionalClaims $DifferenceSnapshot.Application.OptionalClaims) & $addIfDifferent 'Application.IsFallbackPublicClient' $ReferenceSnapshot.Application.IsFallbackPublicClient $DifferenceSnapshot.Application.IsFallbackPublicClient & $addIfDifferent 'ServicePrincipal.Tags' (& $sortedValues $ReferenceSnapshot.ServicePrincipal.Tags) (& $sortedValues $DifferenceSnapshot.ServicePrincipal.Tags) & $addIfDifferent 'ServicePrincipal.Homepage' $ReferenceSnapshot.ServicePrincipal.Homepage $DifferenceSnapshot.ServicePrincipal.Homepage & $addIfDifferent 'ServicePrincipal.LogoUrl' $ReferenceSnapshot.ServicePrincipal.LogoUrl $DifferenceSnapshot.ServicePrincipal.LogoUrl & $addIfDifferent 'ServicePrincipal.AppRoleAssignmentRequired' $ReferenceSnapshot.ServicePrincipal.AppRoleAssignmentRequired $DifferenceSnapshot.ServicePrincipal.AppRoleAssignmentRequired & $addIfDifferent 'ServicePrincipal.AccountEnabled' $ReferenceSnapshot.ServicePrincipal.AccountEnabled $DifferenceSnapshot.ServicePrincipal.AccountEnabled & $addIfDifferent 'ServicePrincipal.PreferredSingleSignOnMode' $ReferenceSnapshot.ServicePrincipal.PreferredSingleSignOnMode $DifferenceSnapshot.ServicePrincipal.PreferredSingleSignOnMode & $addIfDifferent 'ServicePrincipal.Owners' (& $sortedBy $ReferenceSnapshot.ServicePrincipal.Owners 'Id') (& $sortedBy $DifferenceSnapshot.ServicePrincipal.Owners 'Id') if ($IncludeAppRoleAssignments.IsPresent) { & $addIfDifferent 'AppRoleAssignments' (& $sortedBy $ReferenceSnapshot.AppRoleAssignments 'PrincipalId', 'AppRoleId') (& $sortedBy $DifferenceSnapshot.AppRoleAssignments 'PrincipalId', 'AppRoleId') } & $addIfDifferent 'CredentialsMetadata.PasswordCredentials' (& $sortedBy $ReferenceSnapshot.CredentialsMetadata.PasswordCredentials 'KeyId') (& $sortedBy $DifferenceSnapshot.CredentialsMetadata.PasswordCredentials 'KeyId') & $addIfDifferent 'CredentialsMetadata.KeyCredentials' (& $sortedBy $ReferenceSnapshot.CredentialsMetadata.KeyCredentials 'KeyId') (& $sortedBy $DifferenceSnapshot.CredentialsMetadata.KeyCredentials 'KeyId') return $rows.ToArray() } |