Providers/Entra/Public/New-EntraGuestUser.ps1
|
function New-EntraGuestUser { <# .EXTERNALHELP TestEnvironment-Help.xml .SYNOPSIS Creates the external identities defined in Data\EntraGuestUsers.csv #> [CmdletBinding(SupportsShouldProcess)] [OutputType('EntraGuestUser')] param( [Parameter()] [string[]]$GuestKey, [Parameter()] [switch]$SkipGroups, [Parameter()] [switch]$ShowProgress, [Parameter()] [switch]$PassThru ) $connection = Get-EntraConnection $marker = Get-EntraSeedMarker -Connection $connection $definitions = @(Get-EntraSeedData -Name 'EntraGuestUsers') if ($GuestKey) { $definitions = @($definitions | Where-Object { $GuestKey -contains $_.Key }) $missing = @($GuestKey | Where-Object { $definitions.Key -notcontains $_ }) if ($missing) { Write-Error "No seed definition for guest key(s): $($missing -join ', ')" -ErrorAction Stop return } } Write-Verbose "Creating $($definitions.Count) external identity/identities" $created = [System.Collections.Generic.List[object]]::new() $idByKey = @{} $index = 0 foreach ($definition in $definitions) { $index++ Write-TestProgress -Activity 'Seeding external identities' -Status $definition.DisplayName ` -PercentComplete ([int](100 * $index / [Math]::Max(1, $definitions.Count))) -ShowProgress:$ShowProgress $email = $definition.InvitedEmail -replace '\{Prefix\}', $marker.Prefix $isInvited = $definition.CreationMethod -eq 'Invitation' # The identifier a human would recognise, used for ShouldProcess and for every message # below. For an invited guest the UPN does not exist until Entra mints it. $target = if ($isInvited) { $email } else { '{0}{1}@{2}' -f $marker.Prefix, $definition.Key, $marker.UpnSuffix } # --- Already there? ------------------------------------------------------------ $existingId = $null try { if ($isInvited) { $literal = $email.Replace("'", "''") $found = @(Invoke-EntraRequest -Method GET -Path '/users' -Connection $connection -Paginate -ConsistencyLevel ` -Query @{ '$filter' = "mail eq '$literal'"; '$select' = 'id,userPrincipalName' }) if ($found.Count -ge 1) { $existingId = $found[0].id } } else { $found = Invoke-EntraRequest -Method GET -Connection $connection ` -Path "/users/$([uri]::EscapeDataString($target))" -Query @{ '$select' = 'id' } $existingId = $found.id } } catch { # A 404 on the direct address is the ordinary "not there yet" answer, not a fault. Write-Verbose "No existing identity for '$target': $($_.Exception.Message)" } if ($existingId) { Write-Verbose "External identity '$target' already exists ($existingId)" $idByKey[$definition.Key] = $existingId $created.Add([PSCustomObject]@{ PSTypeName = 'EntraGuestUser' Key = $definition.Key Id = $existingId DisplayName = $definition.DisplayName Method = $definition.CreationMethod UserType = $definition.UserType InvitedMail = if ($isInvited) { $email } else { $null } Purpose = $definition.Purpose }) continue } if (-not $PSCmdlet.ShouldProcess($target, "Create external identity ($($definition.CreationMethod))")) { continue } # --- Create -------------------------------------------------------------------- $newId = $null if ($isInvited) { try { # invitedUserType is set on the invitation rather than PATCHed afterwards. The # end state is identical to inviting a guest and converting it, and doing it in # one call means there is no window where the row is half-applied. $invitation = Invoke-EntraRequest -Method POST -Path '/invitations' -Connection $connection -Body @{ invitedUserEmailAddress = $email invitedUserDisplayName = $definition.DisplayName invitedUserType = $definition.UserType inviteRedirectUrl = 'https://example.com/entralab/redeem' # Not a parameter, deliberately. See the description: there is no way to # make this module send mail. sendInvitationMessage = $false } $newId = $invitation.invitedUser.id } catch { $message = $_.Exception.Message # Verified live against a tenant whose allowInvitesFrom is adminsAndGuestInviters. # Graph answers 403 "Guest invitations not allowed for your company", which names # neither the cause nor the fix, and two plausible readings of it are both wrong: # # - It is not User.ReadWrite.All missing. That was granted and consented, and # appeared in the token's roles claim, and the invitation was still refused. # - It is not the Guest Inviter directory role missing either. Assigning it and # re-acquiring the token so the role appeared in wids changed nothing. # # What is left is User.Invite.All, the permission built for this, which the seed # app requests as optional. So the message points there rather than at the tenant # setting - the setting is the gate, but the permission is what opens it. if ($message -match 'Guest invitations not allowed') { Write-Warning ("Could not invite '$email': this tenant restricts who may invite guests " + '(see allowInvitesFrom on /policies/authorizationPolicy) and this application is not ' + 'permitted. Grant it User.Invite.All - verified insufficient are User.ReadWrite.All ' + 'alone and the Guest Inviter directory role - or skip the step with -Skip GuestUsers.') continue } Write-Warning "Could not invite '$email': $message" continue } } else { try { $user = Invoke-EntraRequest -Method POST -Path '/users' -Connection $connection -Body @{ accountEnabled = $true displayName = $definition.DisplayName mailNickname = ('{0}{1}' -f $marker.Prefix, $definition.Key) -replace '[^A-Za-z0-9]', '' userPrincipalName = $target # The whole point of this row: userType is just a property on an otherwise # ordinary cloud account. Nothing about the UPN or the mail says external. userType = $definition.UserType passwordProfile = @{ forceChangePasswordNextSignIn = $false password = New-TestPassword } } $newId = $user.id } catch { Write-Warning "Could not create '$target': $($_.Exception.Message)" continue } } if (-not $newId) { continue } $idByKey[$definition.Key] = $newId # --- Attributes and the seed tag ----------------------------------------------- # Separate from the create for the same reason ordinary users are: Graph rejects # onPremisesExtensionAttributes on POST /users, and the invitation endpoint accepts no # directory attributes at all beyond the display name. $patch = @{ onPremisesExtensionAttributes = @{ extensionAttribute15 = $marker.Tag } companyName = $marker.Tag } if ($definition.GivenName) { $patch.givenName = $definition.GivenName } if ($definition.Surname) { $patch.surname = $definition.Surname } if ($definition.Department) { $patch.department = $definition.Department } if ($definition.JobTitle) { $patch.jobTitle = $definition.JobTitle } # Left unset on two rows on purpose. A guest with no usageLocation is the default state # of every invitation, and it is the state in which every licence assignment fails. if ($definition.UsageLocation) { $patch.usageLocation = $definition.UsageLocation } try { Invoke-EntraRequest -Method PATCH -Path "/users/$newId" -Body $patch -RetryOnNotFound -Connection $connection | Out-Null } catch { Write-Warning ("Created '$target' but could not write its attributes: $($_.Exception.Message). " + 'It remains identifiable by its prefixed UPN and its administrative unit.') } $created.Add([PSCustomObject]@{ PSTypeName = 'EntraGuestUser' Key = $definition.Key Id = $newId DisplayName = $definition.DisplayName Method = $definition.CreationMethod UserType = $definition.UserType InvitedMail = if ($isInvited) { $email } else { $null } Purpose = $definition.Purpose }) Write-Verbose "Created external identity '$($definition.DisplayName)' ($newId)" } # --- Group membership -------------------------------------------------------------- if (-not $SkipGroups -and $idByKey.Count -gt 0) { $groupCache = @{} $memberRequests = [System.Collections.Generic.List[object]]::new() foreach ($definition in $definitions) { if (-not $definition.Groups -or -not $idByKey.ContainsKey($definition.Key)) { continue } foreach ($groupKey in @($definition.Groups -split ';' | Where-Object { $_ })) { $groupId = Resolve-EntraSeededId -Key $groupKey.Trim() -Kind Group -Cache $groupCache -Connection $connection if (-not $groupId) { Write-Warning "Guest '$($definition.Key)' names group '$groupKey', which does not exist. Skipping it." continue } $memberRequests.Add([PSCustomObject]@{ Reference = "$($definition.Key)->$groupKey" Method = 'POST' Url = "/groups/$groupId/members/`$ref" Body = @{ '@odata.id' = "$($connection.GraphBaseUri)/v1.0/directoryObjects/$($idByKey[$definition.Key])" } }) } } if ($memberRequests.Count -gt 0) { $memberResults = @(Invoke-EntraBatch -Request $memberRequests.ToArray() -Connection $connection ` -Activity 'Adding external identities to groups' -ShowProgress:$ShowProgress) # The same three wordings Add-EntraUnitMember and New-EntraGroup match on. Graph # phrases a duplicate differently depending on the collection, and all three mean # the membership is already there - which is the ordinary answer on a re-run. $duplicate = 'already exist|added object references already exist|A conflicting object' foreach ($result in ($memberResults | Where-Object { -not $_.Success })) { if ($result.Error -match $duplicate) { Write-Verbose "Membership $($result.Reference) already existed" continue } Write-Warning "Could not add $($result.Reference): $($result.Error)" } } } # --- Containment ------------------------------------------------------------------- if ($idByKey.Count -gt 0) { $unit = @(Get-EntraSeededObject -Type AdministrativeUnits -Connection $connection) | Where-Object { $_.displayName -eq ('{0}Users' -f $marker.Prefix) } | Select-Object -First 1 if ($unit) { $placed = Add-EntraUnitMember -UnitId $unit.id -ObjectId @($idByKey.Values) -Connection $connection ` -Activity 'Placing external identities in their administrative unit' -ShowProgress:$ShowProgress Write-Verbose "Placed $placed external identity/identities in '$($unit.displayName)'" } else { Write-Warning ("No $($marker.Prefix)Users administrative unit exists, so the external identities are " + 'not contained. Run New-EntraAdministrativeUnit first.') } } Write-TestProgress -Activity 'Seeding external identities' -Completed -ShowProgress:$ShowProgress if ($PassThru) { return $created.ToArray() } } |