Remediation/Consolidation/IDENTITY-MATRIX.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 Gathers the identity decisions and produces the validated matrix of each source (procedure IDENTITY-MATRIX). .DESCRIPTION Starts from the draft of each source, as the plan computes it. Each row is settled in one place only: - CON-IDENTITIES.csv (CON-IDENTITIES): duplicates, likely duplicates, external accounts; - ENT-INACTIVE.csv (ENT-INACTIVE, ID-01): inactive accounts; - IDENTITY-MATRIX.csv, created here: all the other rows. The Type, Target UPN and Temporary UPN columns can be edited there and stay from one preview to the next. CON-ALIASES.csv (CON-ALIASES) fixes Temporary UPN. CMP-CROSS-GUESTS.csv and ENT-RISK.csv feed Notes; a converted guest becomes a Duplicate row pointing to its new UPN. Suggested order: run this script, CON-IDENTITIES, CON-ALIASES and ENT-INACTIVE, have them decided, then run this script again. When all the rows of a source are decided and the checks of the identity matrix pass, the script writes the validated matrix to exports\identity-matrix-<source>.csv in the log folder. A person copies it to the decisions folder, as the instruction shows. No tenant is read or changed: a preview is enough, it produces the matrix. Protected accounts (emergency, rescue, migration team, kept accounts) keep their row in the decisions files, but do not go in the exported matrix: when sign-ins are blocked in the source they stay open, and any account of the matrix still active fails, even Exclude. Limits: the soft match looks at the snapshot of the target. On-premises AD is seen there only through its synced accounts: a person must do the search in on-premises AD. The matrix holds personal data: limit the log folder to the project team. #> $c = Open-TcRemediation $ColumnNames = @('Source UPN', 'Display name', 'First name', 'Name', 'Type', 'Decision', 'Target UPN', 'Temporary UPN', 'License', 'Groups', 'Notes') $ObjectTypes = @('User', 'Shared', 'Resource') $MatrixChoices = @('Create', 'Duplicate', 'Exclude') $DecisionName = 'IDENTITY-MATRIX' $DecisionKey = @('Tenant', 'Source UPN') $Threshold = [int](Get-TcRemediationOption 'InactiveDays') $Sku = Get-TcRemediationLicense $TargetDomain = (Get-TcMember (Get-TcRemediationTenant 'Target') 'InitialDomain').ToLowerInvariant() function Read-KnownDecision { <# Rows of a decisions file of another procedure; an empty table if it has not run yet. #> param([string]$Name, [string[]]$Key) $h = @{} $f = $Name if (Test-TcDecisionFile $f) { foreach ($l in Read-TcDecisionRow $f) { $h[(Get-TcDecisionKey $l $Key)] = $l } } return $h } function Get-RowKey { param([string]$Tenant, [string]$Upn) return ('{0}|{1}' -f $Tenant.ToLowerInvariant(), $Upn.ToLowerInvariant()) } function Get-RowFolder { <# Where this row is settled: one decision per account. #> param($Row, [string]$Tenant, $User, $Info) if ((Get-TcMember $Row 'Decision') -eq 'Duplicate' -or (Get-TcMember $Row 'Notes') -match 'likely duplicate of' -or (Get-TcMember $Row 'Source UPN') -match '#EXT#' -or $duplicates[(Get-RowKey $Tenant (Get-TcMember $Row 'Source UPN'))]) { return 'Duplicates' } if ($User -and $User['Type'] -eq 'Member' -and (ConvertTo-TcBoolean $User['Enabled']) -and ([int]$User['Days inactive'] -ge $Threshold -or [int]$User['Days inactive'] -eq -1) -and [int]('0' + $User['Days since creation']) -ge $Threshold -and -not (Test-TcProtectedAccount (Get-TcMember $Row 'Source UPN') $Info)) { return 'Inactive' } return 'Matrix' } function Get-TargetMark { <# All the addresses already held in the target, for the soft match risk. #> $h = @{} foreach ($u in Get-TcRemediationTable 'Target' 'users') { $nature = if ($u['Type'] -eq 'Guest') { 'Guest' } else { 'Member' } foreach ($a in @($u['UPN'], $u['Email']) + @(([string]$u['Addresses']) -split ';\s*')) { if ($a -and $a -notmatch '#EXT#') { $h[$a.ToLowerInvariant()] = @{ Nature = $nature; Object = $u['UPN'] } } } } foreach ($b in Get-TcRemediationTable 'Target' 'mailboxes') { foreach ($a in ([string]$b['Addresses']) -split ';\s*') { if ($a -and -not $h[$a.ToLowerInvariant()]) { $h[$a.ToLowerInvariant()] = @{ Nature = 'Mailbox'; Object = $b['UPN'] } } } } return $h } function Get-AddressToCreate { param([string]$Tenant, $Row, [string[]]$Domains) $b = Get-TcRemediationTable $Tenant 'mailboxes' | Where-Object { $_['UPN'] -eq $Row['Source UPN'] } | Select-Object -First 1 $allItems = @($Row['Target UPN']) + $(if ($b) { @(([string]$b['Addresses']) -split ';\s*') } else { @() }) return @($allItems | Where-Object { $_ -and (Get-TcAddressDomain $_) -in $Domains } | ForEach-Object { $_.ToLowerInvariant() } | Select-Object -Unique) } function Test-MatrixValidated { <# The same checks as the identity matrix check, on the snapshot; returns the inconsistencies in plain words. #> param([object[]]$Rows, $Info, $Users) $e = [System.Collections.Generic.List[string]]::new() foreach ($l in $Rows) { $id = $l['Source UPN'] if ($l['Type'] -notin $ObjectTypes) { $e.Add("${id}: unknown type '$($l['Type'])'") } if (-not $Users[$id.ToLowerInvariant()]) { $e.Add("${id}: account missing from the source tenant") } if ($l['Decision'] -eq 'Create') { if ((Get-TcAddressDomain $l['Temporary UPN']) -ne $TargetDomain -or $l['Temporary UPN'] -match '#') { $e.Add("${id}: UpnTemporaire must be a valid address in @$TargetDomain") } if ((Get-TcAddressDomain $l['Target UPN']) -notin (Get-TcMember $Info 'Domains')) { $e.Add("${id}: UpnCible must use a migrated domain ($((Get-TcMember $Info 'Domains') -join ', '))") } } if ($l['Decision'] -eq 'Duplicate' -and -not $targetAccounts[([string]$l['Target UPN']).ToLowerInvariant()]) { $e.Add("${id}: target account $($l['Target UPN']) not found in the target") } } foreach ($col in 'Source UPN', 'Temporary UPN', 'Target UPN') { $groups = @($Rows | Where-Object { $_['Decision'] -ne 'Exclude' -and $_[$col] } | Group-Object { ([string]$_[$col]).ToLowerInvariant() } | Where-Object { (Get-TcCount $_) -gt 1 }) if ($col -eq 'Target UPN') { $groups = @($groups | Where-Object { @((Get-TcMember $_ 'Group') | Where-Object { $_['Decision'] -eq 'Create' }).Count -gt 1 }) } foreach ($g in $groups) { $e.Add("$col en double: $((Get-TcMember $g 'Name'))") } } $presentItems = @($Rows | ForEach-Object { ([string]$_['Source UPN']).ToLowerInvariant() }) foreach ($u in $Users.Values | Where-Object { $_['Type'] -eq 'Member' -and (ConvertTo-TcBoolean $_['Enabled']) -and ([string]$_['UPN']).ToLowerInvariant() -notin $presentItems -and -not (Test-TcProtectedAccount $_['UPN'] $Info) }) { $e.Add("$($u['UPN']): active member account missing from the matrix") } return @($e) } $duplicates = Read-KnownDecision 'CON-IDENTITIES' $DecisionKey $inactive = Read-KnownDecision 'ENT-INACTIVE' @('Tenant', 'Account') $alias = @{} foreach ($a in (Read-KnownDecision 'CON-ALIASES' @('Tenant', 'Alias', 'Object')).Values) { if ($a.'Decision' -eq 'Prefix' -and $a.'Target alias') { $alias[(Get-RowKey $a.Tenant $a.Object)] = $a.'Target alias' } } $crossOnes = @{} foreach ($x in (Read-KnownDecision 'CMP-CROSS-GUESTS' @('Tenant', 'Id')).Values) { if ((Get-TcMember $x 'Email')) { $crossOnes[(Get-RowKey (Get-TcMember $x 'Tenant') (Get-TcMember $x 'Email'))] = $x } } $risks = Read-KnownDecision 'ENT-RISK' @('Account') $atRisk = @{} foreach ($r in Get-TcRemediationTable 'Target' 'riskyUsers') { $atRisk[([string]$r['UPN']).ToLowerInvariant()] = 1 } $targetAccounts = @{} foreach ($u in Get-TcRemediationTable 'Target' 'users') { if ($u['Type'] -eq 'Member') { $targetAccounts[([string]$u['UPN']).ToLowerInvariant()] = 1 } } foreach ($x in $crossOnes.Values) { if ((Get-TcMember $x 'Decision') -eq 'Convert' -and (Get-TcMember $x 'UPN after conversion')) { $targetAccounts[$x.'UPN after conversion'.ToLowerInvariant()] = 1 } } $marks = Get-TargetMark $entries = Read-KnownDecision $DecisionName $DecisionKey $drafts = @{} $proposals = [System.Collections.Generic.List[object]]::new() foreach ($t in $c.Tenants) { $i = Get-TcRemediationTenant $t $u = Get-TcRowIndex (Get-TcRemediationTable $t 'users') 'UPN' $drafts[$t] = @(Import-TcMatrixDraft $t) # resource accounts are recreated by TEL-VOICE-APPS: in the matrix, they would be created twice $resources = @{} foreach ($r in Get-TcRemediationTable $t 'resourceAccounts') { $resources[([string]$r['UPN']).ToLowerInvariant()] = 1 } foreach ($l in $drafts[$t]) { if ((Get-RowFolder $l $t $u[(Get-TcMember $l 'Source UPN').ToLowerInvariant()] $i) -ne 'Matrix') { continue } $s = $entries[(Get-RowKey $t (Get-TcMember $l 'Source UPN'))] $proposals.Add([ordered]@{ Tenant = $t; 'Source UPN' = (Get-TcMember $l 'Source UPN'); Name = (Get-TcMember $l 'Display name'); Type = $(if ($s -and (Get-TcMember $s 'Type')) { (Get-TcMember $s 'Type') } else { (Get-TcMember $l 'Type') }) 'Target UPN' = $(if ($s -and (Get-TcMember $s 'Target UPN')) { (Get-TcMember $s 'Target UPN') } else { (Get-TcMember $l 'Target UPN') }); 'Temporary UPN' = $(if ($s -and (Get-TcMember $s 'Temporary UPN')) { (Get-TcMember $s 'Temporary UPN') } else { (Get-TcMember $l 'Temporary UPN') }) License = (Get-TcMember $l 'License'); Notes = (Get-TcMember $l 'Notes') Proposal = $(if ($resources[(Get-TcMember $l 'Source UPN').ToLowerInvariant()] -or (Test-TcProtectedAccount (Get-TcMember $l 'Source UPN') $i)) { 'Exclude' } elseif ((Get-TcMember $l 'Decision') -in $MatrixChoices) { (Get-TcMember $l 'Decision') } elseif ((Get-TcMember $l 'Notes') -match 'disabled account') { 'Exclude' } else { '' }) }) } } $decisions = @{} foreach ($d in Get-TcDecision -Name $DecisionName -Proposal @($proposals) -Key $DecisionKey -Choice $MatrixChoices) { $decisions[(Get-RowKey (Get-TcMember $d 'Tenant') (Get-TcMember $d 'Source UPN'))] = $d } $Stores = @{} foreach ($t in $c.Tenants) { Write-TcRemediationTenant $t 'Identity matrix' $i = Get-TcRemediationTenant $t $users = Get-TcRowIndex (Get-TcRemediationTable $t 'users') 'UPN' $preview = (Get-TcMember (Get-TcRemediationIdentity $t) 'account') Write-TcRemediationNote ("Draft: {0} row(s), {1} to create, {2} duplicate(s), {3} to validate, including {4} Bookings page(s)" -f (Get-TcCount $drafts[$t]), (Get-TcMember $preview 'Create'), (Get-TcMember $preview 'Duplicate'), (Get-TcMember $preview 'To review'), (Get-TcMember $preview 'Bookings')) $pending = @{ Duplicates = 0; Inactive = 0; Matrix = 0 } $rows = [System.Collections.Generic.List[object]]::new() $toCreate = [System.Collections.Generic.List[object]]::new() foreach ($l in $drafts[$t]) { $k = Get-RowKey $t (Get-TcMember $l 'Source UPN') $folder = Get-RowFolder $l $t $users[(Get-TcMember $l 'Source UPN').ToLowerInvariant()] $i $d = switch ($folder) { 'Duplicates' { $duplicates[$k] } 'Inactive' { $inactive[$k] } default { $decisions[$k] } } $choice = if ($d) { (Get-TcText $d 'Decision') } else { '' } $x = [ordered]@{} foreach ($col in $ColumnNames) { $x[$col] = (Get-TcText $l $col) } $notes = @((Get-TcMember $l 'Notes')) if ($folder -eq 'Matrix' -and $d) { $x.Type = (Get-TcMember $d 'Type'); $x.'Target UPN' = (Get-TcMember $d 'Target UPN'); $x.'Temporary UPN' = (Get-TcMember $d 'Temporary UPN') } $cross = $crossOnes[(Get-RowKey $t (Get-TcMember $l 'Source UPN'))] if (-not $cross -and (Get-TcMember $l 'Target UPN')) { $cross = $crossOnes[(Get-RowKey $t (Get-TcMember $l 'Target UPN'))] } $convertedOne = $cross -and (Get-TcMember $cross 'Decision') -eq 'Convert' -and (Get-TcMember $cross 'UPN after conversion') if ($cross -and (Get-TcMember $cross 'Decision')) { $notes += "cross-tenant guest: $((Get-TcMember $cross 'Decision'))" } if ($convertedOne) { $choice = 'Duplicate'; $x.'Target UPN' = (Get-TcMember $cross 'UPN after conversion') } if ($choice -notin $MatrixChoices) { $pending[$folder]++ if ((Get-TcMember $l 'Decision') -eq 'Create') { $toCreate.Add($x) } $x.Decision = 'To review'; $rows.Add($x); continue } $x.Decision = $choice switch ($choice) { 'Duplicate' { if ($folder -eq 'Duplicates' -and (Get-TcMember $d 'Single account') -and -not $convertedOne) { $x.'Target UPN' = (Get-TcMember $d 'Single account') } $x.'Temporary UPN' = ''; $x.License = '' $target = (Get-TcMember $x 'Target UPN').ToLowerInvariant() if ($atRisk[$target]) { $r = $risks[$target] if ($r -and (Get-TcMember $r 'Decision')) { $notes += "target account at risk: $((Get-TcMember $r 'Decision'))" } else { Write-TcRemediationAlert ("{0}: the target account {1} is at risk, with no decision in ENT-RISK.csv" -f $t, (Get-TcMember $x 'Target UPN')) } } } 'Exclude' { $x.'Target UPN' = ''; $x.'Temporary UPN' = ''; $x.License = '' } 'Create' { if ((Get-TcAddressDomain (Get-TcMember $x 'Target UPN')) -notin (Get-TcMember $i 'Domains')) { $x.'Target UPN' = (Get-TcMember $l 'Source UPN') } if (-not (Get-TcMember $x 'Temporary UPN')) { $x.'Temporary UPN' = '{0}.{1}@{2}' -f (Get-TcMember $i 'Prefix').ToLowerInvariant(), (Get-TcAddressPrefix (Get-TcMember $l 'Source UPN')).ToLowerInvariant(), $TargetDomain } if ($alias[$k]) { $x.'Temporary UPN' = '{0}@{1}' -f $alias[$k], $TargetDomain } if ((Get-TcMember $x 'Type') -eq 'User' -and -not (Get-TcMember $x 'License')) { $x.License = $Sku } } } if ($d -and (Get-TcMember $d 'Note')) { $notes += (Get-TcMember $d 'Note') } $x.Notes = (@($notes | Where-Object { $_ }) -join ' ; ') if ($choice -eq 'Create') { $toCreate.Add($x) } $rows.Add($x) } $softRows = @(foreach ($l in $toCreate) { foreach ($a in Get-AddressToCreate $t $l (Get-TcMember $i 'Domains')) { if ($marks[$a]) { [ordered]@{ Tenant = $t; Account = $l['Source UPN']; Address = $a; Nature = (Get-TcMember $marks[$a] 'Nature'); 'Target object' = (Get-TcMember $marks[$a] 'Object') } } } }) if ($softRows.Count) { Export-TcRemediationReport -Name "loose-match-$t" -Rows $softRows | Out-Null Write-TcRemediationNote ("{0} address(es) to create already exist in the target, including {1} on guests (CMP-CROSS-GUESTS)" -f $softRows.Count, @($softRows | Where-Object { $_['Nature'] -eq 'Guest' }).Count) } Add-TcManualAction -Tenant 'Target' -Object "Loose match ($t)" -Description ("Look up the UpnCible values and the addresses to create of {0} in the on-premises AD of the target" -f $t) ` -Instruction @('A UPN or an address already present in on-premises AD can trigger a soft match with the cloud account. Settle each case object by object; do not block soft matching blindly, the hybrid joins of the target depend on it.') $remainingItems = (Get-TcMember $pending 'Duplicates') + (Get-TcMember $pending 'Inactive') + (Get-TcMember $pending 'Matrix') if ($remainingItems) { Write-TcRemediationNote ("{0} row(s) without a decision: {1} in CON-IDENTITIES.csv, {2} in ENT-INACTIVE.csv, {3} in {4}.csv; validated matrix not produced" -f $remainingItems, (Get-TcMember $pending 'Duplicates'), (Get-TcMember $pending 'Inactive'), (Get-TcMember $pending 'Matrix'), $DecisionName) $script:TcRemediationRun['Pending'] += (Get-TcMember $pending 'Duplicates') + (Get-TcMember $pending 'Inactive') continue } $exported = @($rows | Where-Object { -not (Test-TcProtectedAccount $_['Source UPN'] $i) }) $errors = @(Test-MatrixValidated -Rows $exported -Info $i -Users $users) if ($errors.Count) { $f = Export-TcRemediationReport -Name "matrix-check-$t" -Rows @($errors | ForEach-Object { [ordered]@{ Tenant = $t; 'Inconsistency' = $_ } }) Write-TcRemediationAlert ("{0}: {1} inconsistency(ies), validated matrix not produced; detail: {2}" -f $t, $errors.Count, $f) continue } $f = Export-TcRemediationReport -Name "identity-matrix-$t" -Rows $exported $n = @{}; foreach ($v in $MatrixChoices) { $n[$v] = @($exported | Where-Object { $_['Decision'] -eq $v }).Count } Write-TcRemediationNote ("Validated matrix: {0} row(s), {1} to create, {2} duplicate(s), {3} excluded, {4} protected account(s) removed" -f $exported.Count, (Get-TcMember $n 'Create'), (Get-TcMember $n 'Duplicate'), (Get-TcMember $n 'Exclude'), ($rows.Count - $exported.Count)) $destination = Join-Path (Get-TcDecisionFolder) ("identity-matrix-{0}.csv" -f $t) if (Test-Path -LiteralPath $destination) { Write-TcRemediationNote 'A matrix already exists in the decisions folder: compare before replacing it' } $object = "identity-matrix-$t.csv" $Stores[$object] = @{ Export = $f; Target = $destination } Add-TcManualAction -Tenant $t -Object $object -Minutes 0 -Description ("Copy the validated matrix of {0} to the decisions folder" -f $t) ` -Instruction @("Copy: $f", "To: $destination", "Then run New-TcPlan again, and fix what the identity matrix check and the name conflict check report.") ` -WaitUntil { param($a) $x = $Stores[$a.object]; (Test-Path -LiteralPath (Get-TcMember $x 'Target')) -and (Get-TcMember (Get-FileHash -LiteralPath (Get-TcMember $x 'Target')) 'Hash') -eq (Get-TcMember (Get-FileHash -LiteralPath (Get-TcMember $x 'Export')) 'Hash') } } Invoke-TcRemediationPlan Close-TcRemediation |