Remediation/EntraID/CMP-CROSS-GUESTS.ps1
|
# UNTESTED: this script has never been run on a real tenant. Read it, try it on a test tenant first, and use it at your own risk. <# .SYNOPSIS Handles the guests of the target that carry an address of a migrated domain, before the domains are assigned (procedure CMP-CROSS-GUESTS). .DESCRIPTION decisions\CMP-CROSS-GUESTS.csv: one row per guest, choices Delete, Convert or Purge. - Delete: the guest is removed during the cutover, before the domains are assigned to target accounts. Deleting too early cuts their access: it is planned only once the cutover is open in decisions\CMP-CROSS-GUESTS-CUTOVER.csv (one row per source, decision Open). - Convert: the guest becomes an internal account and serves as the single account. The UPN after conversion column can be edited and stays. IDENTITY-MATRIX then enters Duplicate to this new UPN. It needs a license to get a mailbox. The initial password is encrypted with DPAPI in secrets\ in the log folder. - Purge: to decide after a deletion, only if the address stays taken. It is final. In an -Apply run, the script first reads the groups and teams of each guest into exports\cross-guest-access-<source>.csv: these memberships do not come back on their own on the migrated account. Keep this file for after the domains are assigned. Limits: conversion goes through a beta Graph endpoint. A person must give the access back to the migrated account. #> $c = Open-TcRemediation $DecisionName = 'CMP-CROSS-GUESTS' $DecisionKey = @('Tenant', 'Id') $TargetDomain = (Get-TcMember (Get-TcRemediationTenant 'Target') 'InitialDomain') function Test-AccountMissing { param([string]$Id) return -not @(Invoke-TcGraph -Tenant 'Target' -Uri ("v1.0/users?`$filter=id eq '{0}'&`$select=id" -f $Id) -All).Count } $entries = Read-TcDecisionEntry $DecisionName $DecisionKey $guests = @(Get-TcRemediationTable 'Target' 'users' | Where-Object { $_['Type'] -eq 'Guest' }) $byTenant = @{} $proposals = @(foreach ($t in $c.Tenants) { Write-TcRemediationTenant $t 'Guests of the target on the domains of the source' $reason = '({0})' -f ((Get-TcMember (Get-TcRemediationTenant $t) 'Domains') | ForEach-Object { [regex]::Escape($_) } | Join-String -Separator '|') $draft = @{} foreach ($l in (Get-TcMember (Get-TcRemediationIdentity $t) 'rows')) { if ((Get-TcMember $l 'Target UPN')) { $draft[$l.'Target UPN'.ToLowerInvariant()] = (Get-TcMember $l 'Decision') } } $concerned = @($guests | Where-Object { (([string]$_['UPN']) + ' ' + ([string]$_['Email'])).ToLowerInvariant() -match $reason }) $byTenant[$t] = @($concerned | Where-Object { -not (Test-TcProtectedAccount -Account ([string]$_['UPN'])) }) Write-TcRemediationNote ("{0} guest(s) of the target have an address from this source; {1} protected account(s) set aside" -f $concerned.Count, ($concerned.Count - (Get-TcCount $byTenant[$t]))) foreach ($u in $byTenant[$t]) { $s = $entries[(Get-TcDecisionKey ([pscustomobject]@{ Tenant = $t; Id = $u['Id'] }) $DecisionKey)] [ordered]@{ Tenant = $t; Id = $u['Id']; Name = $u['Name']; Email = $u['Email']; 'Invitation state' = $u['Invitation state']; 'Days inactive' = $u['Days inactive'] 'Matrix' = [string]$draft[([string]$u['Email']).ToLowerInvariant()] 'UPN after conversion' = $(if ($s -and (Get-TcMember $s 'UPN after conversion')) { (Get-TcMember $s 'UPN after conversion') } else { '{0}@{1}' -f (Get-TcAddressPrefix $u['Email']).ToLowerInvariant(), $TargetDomain }) Proposal = $(if ($draft[([string]$u['Email']).ToLowerInvariant()] -eq 'Create') { 'Delete' } else { '' }) } } }) $decisions = @(Get-TcDecision -Name $DecisionName -Proposal $proposals -Key $DecisionKey -Choice 'Delete', 'Convert', 'Purge') $openOnes = @(Get-TcDecision -Name 'CMP-CROSS-GUESTS-CUTOVER' -Proposal @($c.Tenants | ForEach-Object { [ordered]@{ Tenant = $_; Object = 'Cutover started, before the domains are assigned to target accounts'; Proposal = '' } }) -Key 'Tenant' -Choice 'Open' | ForEach-Object { (Get-TcMember $_ 'Tenant') }) foreach ($t in $c.Tenants) { if ((Get-TcCount $byTenant[$t])) { Add-TcBlockAction -Tenant 'Target' -Service Graph -Object "Guest access ($t)" -Description ("Read the groups and teams of {0} cross-tenant guest(s) of {1}" -f (Get-TcCount $byTenant[$t]), $t) ` -Data @{ Source = $t; Accounts = @($byTenant[$t] | ForEach-Object { @{ Id = $_['Id']; Email = $_['Email'] } }) } -Reason 'Read only.' ` -Block { param($a) $rows = @(foreach ($x in $a.data.Accounts) { if (Test-AccountMissing (Get-TcMember $x 'Id')) { continue } foreach ($g in Invoke-TcGraph -Tenant $a.tenant -Uri ("v1.0/users/{0}/memberOf?`$select=id,displayName" -f (Get-TcMember $x 'Id')) -All) { [ordered]@{ Email = (Get-TcMember $x 'Email'); Group = (Get-TcMember $g 'displayName'); GroupId = (Get-TcMember $g 'id'); Type = $g['@odata.type'] } } }) if ($rows.Count) { Export-TcRemediationReport -Name ("cross-guest-access-{0}" -f $a.data.Source) -Rows $rows | Out-Null } } } $toDelete = @($decisions | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t -and (Get-TcMember $_ 'Decision') -eq 'Delete' }) if ($toDelete.Count -and $t -notin $openOnes) { Write-TcRemediationNote ("{0}: {1} deletion(s) decided, waiting for the cutover to open (CMP-CROSS-GUESTS-CUTOVER.csv)" -f $t, $toDelete.Count) } elseif ($toDelete.Count) { foreach ($d in $toDelete) { Add-TcGraphAction -Tenant 'Target' -Method DELETE -Uri "v1.0/users/$((Get-TcMember $d 'Id'))" -Object (Get-TcMember $d 'Email') -Description ("Delete the guest {0} before the domains are assigned to target accounts" -f (Get-TcMember $d 'Email')) ` -Test { param($a) Test-AccountMissing ($a.uri -replace '^.*/', '') } ` -Undo @{ type = 'Graph'; tenant = 'Target'; method = 'POST'; uri = "v1.0/directory/deletedItems/$((Get-TcMember $d 'Id'))/restore" } } Add-TcManualAction -Tenant 'Target' -Object "Access restored ($t)" -Description ("After the domains are assigned to target accounts, give the migrated accounts of {0} the access of {1} deleted guest(s) again" -f $t, $toDelete.Count) ` -Instruction @('Start from exports\cross-guest-access of the snapshot. Deletion removes memberships: they do not come back by themselves.') } foreach ($d in $decisions | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t -and (Get-TcMember $_ 'Decision') -eq 'Purge' }) { Add-TcBlockAction -Tenant 'Target' -Service Graph -Object (Get-TcMember $d 'Email') -Description ("Permanently delete the guest {0}: its address stays taken" -f (Get-TcMember $d 'Email')) -Data @{ Id = (Get-TcMember $d 'Id') } ` -Reason 'Permanent deletion: irreversible.' ` -Test { param($a) if (-not (Test-AccountMissing $a.data.Id)) { throw "Account still active: decide Delete first." } try { Invoke-TcGraph -Tenant $a.tenant -Uri ("v1.0/directory/deletedItems/{0}" -f $a.data.Id) | Out-Null; return $false } catch { if ((Get-TcErrorText $_) -match '404|NotFound') { return $true }; throw } } ` -Block { param($a) Invoke-TcGraph -Tenant $a.tenant -Method DELETE -Uri ("v1.0/directory/deletedItems/{0}" -f $a.data.Id) } } foreach ($d in $decisions | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t -and (Get-TcMember $_ 'Decision') -eq 'Convert' }) { if (-not (Get-TcMember $d 'UPN after conversion')) { Write-TcRemediationAlert ("{0}: UPN after conversion is empty for {1}" -f $t, (Get-TcMember $d 'Email')); continue } Add-TcBlockAction -Tenant 'Target' -Service Graph -Object (Get-TcMember $d 'Email') -Description ("Convert the guest {0} to the internal account {1}" -f (Get-TcMember $d 'Email'), (Get-TcMember $d 'UPN after conversion')) -Data @{ Id = (Get-TcMember $d 'Id'); Upn = (Get-TcMember $d 'UPN after conversion') } ` -Reason 'Conversion to an internal account has no documented reverse operation.' ` -Test { param($a) (Get-TcMember (Invoke-TcGraph -Tenant $a.tenant -Uri ("v1.0/users/{0}?`$select=userType" -f $a.data.Id)) 'userType') -eq 'Member' } ` -Block { param($a) $passwordValue = Get-TcRandomPassword -Length 24 Invoke-TcGraph -Tenant $a.tenant -Method POST -Uri ("beta/users/{0}/convertExternalToInternalMemberUser" -f $a.data.Id) -Body @{ userPrincipalName = $a.data.Upn; passwordProfile = @{ password = $passwordValue; forceChangePasswordNextSignIn = $true } } | Out-Null Save-TcRemediationSecret -Name ("conversion-{0}" -f $a.data.Id) -Value $passwordValue | Out-Null } } $converted = @($decisions | Where-Object { (Get-TcMember $_ 'Tenant') -eq $t -and (Get-TcMember $_ 'Decision') -eq 'Convert' }).Count if ($converted) { Add-TcManualAction -Tenant 'Target' -Object "Licenses of converted accounts ($t)" -Description ("Check the license of {0} converted account(s) of {1}" -f $converted, $t) ` -Instruction @('A converted account needs a license to get a mailbox. Check it before running IDENTITY-MATRIX again, which registers them as Duplicate.') } } Invoke-TcRemediationPlan -Verify { param($t) if ($t -notin $openOnes) { return } $domains = (Get-TcMember (Get-TcRemediationTenant $t) 'Domains') $remaining = @(Invoke-TcGraph -Tenant 'Target' -Uri "v1.0/users?`$filter=userType eq 'Guest'&`$select=id,mail" -All | Where-Object { (Get-TcAddressDomain (Get-TcMember $_ 'mail')) -in $domains }) if ($remaining.Count) { New-TcRemediationResult Blocking ("{0} guest(s) still have an address from this source: the step that assigns domains to target accounts will fail" -f $remaining.Count) } else { New-TcRemediationResult OK 'no more guests on the domains of this source' } } Close-TcRemediation |