Providers/Entra/Public/New-EntraApplication.ps1
|
function New-EntraApplication { <# .EXTERNALHELP TestEnvironment-Help.xml .SYNOPSIS Creates the seeded app registrations, their service principals and their assignments #> [CmdletBinding(SupportsShouldProcess)] [OutputType('EntraApplication')] param( [Parameter()] [string[]]$ApplicationKey, [Parameter()] [switch]$SkipAssignment, [Parameter()] [switch]$ShowProgress, [Parameter()] [switch]$PassThru ) $connection = Get-EntraConnection $marker = Get-EntraSeedMarker -Connection $connection $definitions = @(Get-EntraSeedData -Name 'EntraApplications') if ($ApplicationKey) { $definitions = @($definitions | Where-Object { $ApplicationKey -contains $_.Key }) $missing = @($ApplicationKey | Where-Object { $definitions.Key -notcontains $_ }) if ($missing) { Write-Error "No seed definition for application key(s): $($missing -join ', ')" -ErrorAction Stop return } } # The all-zero GUID is Entra's "default access" app role: assigned to the application, # but to no role it defines. It is what the portal writes for an app with no app roles. $defaultAccessRoleId = '00000000-0000-0000-0000-000000000000' # Entra places no uniqueness constraint on an application's displayName, so without this a # re-run creates a second registration - and a second service principal - for each one. $existingByName = @{} foreach ($application in (Get-EntraSeededObject -Type Applications -Connection $connection)) { $existingByName[$application.displayName] = $application } $created = [System.Collections.Generic.List[object]]::new() $cache = @{} $index = 0 foreach ($definition in $definitions) { $index++ $displayName = '{0}{1}' -f $marker.Prefix, $definition.DisplayName Write-TestProgress -Activity 'Seeding applications' -Status $displayName ` -PercentComplete ([int](100 * $index / [Math]::Max(1, $definitions.Count))) -ShowProgress:$ShowProgress if ($existingByName.ContainsKey($displayName)) { Write-Verbose "Application '$displayName' already exists; reusing it" $existingApp = $existingByName[$displayName] $existingPrincipal = $null try { $existingPrincipal = @(Invoke-EntraRequest -Method GET -Path '/servicePrincipals' -Connection $connection ` -Paginate -ConsistencyLevel -Query @{ '$filter' = "appId eq '$($existingApp.appId)'"; '$select' = 'id' }) | Select-Object -First 1 } catch { Write-Verbose "Could not look up the service principal for '$displayName': $($_.Exception.Message)" } $created.Add([PSCustomObject]@{ PSTypeName = 'EntraApplication' Key = $definition.Key Id = $existingApp.id AppId = $existingApp.appId ServicePrincipalId = $existingPrincipal.id DisplayName = $displayName Tags = @($existingApp.tags) Assignments = @($definition.AppRoleAssignments -split ';' | Where-Object { $_ }) Purpose = $definition.Purpose }) continue } if (-not $PSCmdlet.ShouldProcess($displayName, 'Create application')) { continue } # tags is the ownership marker for applications, and unlike the user seed tag it is # both writable at create time and returned on read. The extra tags from the CSV are # appended, so HideApp works without displacing the marker. $tags = @($marker.Tag) + @($definition.Tags -split ';' | Where-Object { $_ }) $body = @{ displayName = $displayName signInAudience = $definition.SignInAudience tags = $tags notes = $marker.Description } # Reply URLs are the nearest thing Entra has to Okta's trusted origins, and the two # collections are not interchangeable: a URL under `web` is a redirect target for a # confidential client, while one under `spa` additionally makes Entra emit the CORS # headers a browser needs for the token request. Putting a single-page app's URL in # `web` produces an app that redirects correctly and then fails at the token call. $webUris = @($definition.WebRedirectUris -split ';' | Where-Object { $_ } | ForEach-Object { $_.Trim() }) $spaUris = @($definition.SpaRedirectUris -split ';' | Where-Object { $_ } | ForEach-Object { $_.Trim() }) if ($webUris) { $body.web = @{ redirectUris = $webUris; homePageUrl = $webUris[0] } } if ($spaUris) { $body.spa = @{ redirectUris = $spaUris } } try { $application = Invoke-EntraRequest -Method POST -Path '/applications' -Body $body } catch { Write-Error "Failed to create application '${displayName}': $($_.Exception.Message)" continue } $servicePrincipalId = $null if ([bool]::Parse($definition.CreateServicePrincipal)) { try { # This is the call that loses the race with replication most reliably of any # in the module, and it does so as a 400 rather than a 404 - Graph says the # appId "does not reference a valid application object" about an application # it created moments earlier. Status code alone cannot distinguish that from # a genuinely bad request, so the retry matches the message. $servicePrincipal = Invoke-EntraRequest -Method POST -Path '/servicePrincipals' ` -RetryOnNotFound -RetryOnErrorMatch 'does not reference a valid application object' -Body @{ appId = $application.appId tags = $tags } $servicePrincipalId = $servicePrincipal.id Write-Verbose "Created service principal for '$displayName' ($servicePrincipalId)" } catch { Write-Warning ("Created application '$displayName' but its service principal failed: " + "$($_.Exception.Message). Nothing can be assigned to it until one exists.") } } if (-not $SkipAssignment -and $servicePrincipalId -and $definition.AppRoleAssignments) { foreach ($key in ($definition.AppRoleAssignments -split ';' | Where-Object { $_ })) { $key = $key.Trim() # The CSV does not say whether an assignee is a user or a group, because the # keys are already unique across both files. Groups are tried first: the # group seed file is the smaller lookup and a group key never collides with # a user key. $principalId = Resolve-EntraSeededId -Key $key -Kind Group -Cache $cache -Connection $connection if (-not $principalId) { $principalId = Resolve-EntraSeededId -Key $key -Kind User -Cache $cache -Connection $connection } if (-not $principalId) { Write-Warning ("Application '$($definition.Key)' assigns '$key', which matches no seeded user " + "or group. Skipping it.") continue } if (-not $PSCmdlet.ShouldProcess($displayName, "Assign $key")) { continue } try { Invoke-EntraRequest -Method POST -Path "/servicePrincipals/$servicePrincipalId/appRoleAssignedTo" -RetryOnNotFound -Body @{ principalId = $principalId resourceId = $servicePrincipalId appRoleId = $defaultAccessRoleId } | Out-Null Write-Verbose "Assigned '$key' to '$displayName'" } catch { if ($_.Exception.Message -match 'already exist|Permission being assigned already exists') { Write-Verbose "'$key' is already assigned to '$displayName'" } else { Write-Warning "Could not assign '$key' to '$displayName': $($_.Exception.Message)" } } } } $created.Add([PSCustomObject]@{ PSTypeName = 'EntraApplication' Key = $definition.Key Id = $application.id AppId = $application.appId ServicePrincipalId = $servicePrincipalId DisplayName = $displayName Tags = $tags Assignments = @($definition.AppRoleAssignments -split ';' | Where-Object { $_ }) Purpose = $definition.Purpose }) Write-Verbose "Created application '$displayName' ($($application.id))" } # Applications go in an administrative unit like everything else. Their service principals # cannot - Graph accepts only users, groups, devices and applications as unit members - so # those stay identified by the name prefix and their seed tag. if ($created.Count -gt 0) { $unit = @(Get-EntraSeededObject -Type AdministrativeUnits -Connection $connection) | Where-Object { $_.displayName -eq ('{0}Applications' -f $marker.Prefix) } | Select-Object -First 1 if ($unit) { $placed = Add-EntraUnitMember -UnitId $unit.id -ObjectId @($created.Id) -Connection $connection ` -Activity 'Placing applications in their administrative unit' -ShowProgress:$ShowProgress Write-Verbose "Placed $placed application(s) in '$($unit.displayName)'" } else { Write-Warning ("No $($marker.Prefix)Applications administrative unit exists, so the created " + "applications are not contained. Run New-EntraAdministrativeUnit first.") } } Write-TestProgress -Activity 'Seeding applications' -Completed -ShowProgress:$ShowProgress if ($PassThru) { return $created.ToArray() } } |