Private/Remediation/Remediation.Service.ps1
|
function Get-TcRemediationNow { <# .SYNOPSIS The time, for the waits of a remediation. #> [CmdletBinding()] [OutputType([datetime])] param() Get-Date } function Wait-TcRemediation { <# .SYNOPSIS Pauses a remediation. #> [CmdletBinding()] param( [Parameter(Mandatory)][int]$Seconds ) Start-Sleep -Seconds $Seconds } function Get-TcRemediationScope { <# .SYNOPSIS The Graph permissions of the running script, and nothing else. #> [CmdletBinding()] [OutputType([string])] param() @($script:TcRemediationRun['Entry']['permissions'] | Sort-Object -Unique) } function Assert-TcRemediationCall { <# .SYNOPSIS Refuses a connection in preview, and a write to a tenant whose primary domain a person did not type. .DESCRIPTION A write that goes through is added to the action that runs, so the journal says where it really went. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$What, [Parameter(Mandatory)][string]$Tenant, [switch]$Write ) $run = $script:TcRemediationRun if (-not $run['Apply']) { throw "Refused in preview: $What. A preview reads the snapshot only." } if (-not $Write) { return } if (-not @($run['ConfirmedTenants']).Count) { throw "Write refused before the confirmation: $What. A write belongs in an action of the plan." } if ($Tenant -notin $run['ConfirmedTenants']) { throw "Write refused on $Tenant, whose primary domain nobody typed: $What. An action that writes to a second tenant declares it with -AlsoWrites." } $action = $run['CurrentAction'] if ($action -and $Tenant -notin @($action['written'])) { $action['written'] = @($action['written']) + $Tenant } } function Assert-TcServiceFamily { <# .SYNOPSIS Refuses to mix Exchange, Teams and PnP in one process: together they stop PowerShell. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Service ) $run = $script:TcRemediationRun $family = @{ Exo = 'Exchange'; Scc = 'Exchange'; Teams = 'Teams'; Pnp = 'Pnp' }[$Service] if ($run['Family'] -and $run['Family'] -ne $family) { throw "A script cannot mix the $($run['Family']) and $family modules in one process." } $run['Family'] = $family } function Connect-TcRemediationService { <# .SYNOPSIS Signs a person in to a service of a tenant, with only the permissions of the script. .DESCRIPTION Never uses the read-only application, which stays read-only. Each connection is checked, as the assessment does: a session on another tenant is refused. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Tenant, [Parameter(Mandatory)][string]$Service, [string]$Url ) $run = $script:TcRemediationRun $info = Get-TcRemediationTenant -Tenant $Tenant $sessions = $run['Connections'] switch ($Service) { 'Graph' { if ($sessions['Graph'] -eq $Tenant) { return } if ($sessions['Graph']) { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } $scopes = @(Get-TcRemediationScope) if (-not $scopes.Count) { $scopes = @('Organization.Read.All') } Connect-MgGraph -TenantId $info['TenantId'] -Scopes $scopes -NoWelcome -ErrorAction Stop $organization = @(Get-TcProperty (Invoke-TcGraphTransport -Method GET -Url 'https://graph.microsoft.com/v1.0/organization') 'value') | Select-Object -First 1 if ([string](Get-TcProperty $organization 'id') -ne [string]$info['TenantId']) { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null throw "Connected to the wrong tenant instead of $($info['TenantId']) ($Tenant)." } $sessions['Graph'] = $Tenant } 'Exo' { if ($sessions['Exo'] -eq $Tenant) { return } if ($sessions['Exo'] -or $sessions['Scc']) { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue; $sessions['Scc'] = $null } Connect-ExchangeOnline -Organization $info['InitialDomain'] -ShowBanner:$false -ErrorAction Stop if (-not @(Get-AcceptedDomain -ErrorAction Stop | Where-Object { [string](Get-TcProperty $_ 'DomainName') -eq $info['InitialDomain'] }).Count) { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue throw "Exchange Online connected to the wrong tenant: $($info['InitialDomain']) is not an accepted domain ($Tenant)." } $sessions['Exo'] = $Tenant } 'Scc' { if ($sessions['Scc'] -eq $Tenant) { return } if ($sessions['Exo'] -or $sessions['Scc']) { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue; $sessions['Exo'] = $null } Connect-IPPSSession -ShowBanner:$false -ErrorAction Stop $session = @(Get-ConnectionInformation -ErrorAction Stop) | Where-Object { [string](Get-TcProperty $_ 'ConnectionUri') -match 'compliance' } | Select-Object -Last 1 if ([string](Get-TcProperty $session 'TenantID') -ne [string]$info['TenantId']) { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue throw "Purview connected to the wrong tenant instead of $($info['TenantId']) ($Tenant)." } $sessions['Scc'] = $Tenant } 'Teams' { if ($sessions['Teams'] -eq $Tenant) { return } if ($sessions['Teams']) { Disconnect-MicrosoftTeams -ErrorAction SilentlyContinue | Out-Null } Connect-MicrosoftTeams -TenantId $info['TenantId'] -ErrorAction Stop | Out-Null if ([string](Get-TcProperty (Get-CsTenant -ErrorAction Stop) 'TenantId') -ne [string]$info['TenantId']) { Disconnect-MicrosoftTeams -ErrorAction SilentlyContinue | Out-Null throw "Teams connected to the wrong tenant instead of $($info['TenantId']) ($Tenant)." } $sessions['Teams'] = $Tenant } 'Pnp' { $adminUrls = Get-TcRemediationOption 'SharePointAdminUrl' $site = if ($Url) { $Url } elseif ($adminUrls.ContainsKey($Tenant)) { $adminUrls[$Tenant] } else { 'https://{0}-admin.sharepoint.com' -f ($info['InitialDomain'] -split '\.')[0] } $clientId = Get-TcRemediationOption 'PnpClientId' if (-not $clientId) { throw 'SharePoint needs the client ID of a PnP application: set PnpClientId in the remediation settings.' } if (-not $sessions.ContainsKey("Pnp|$site")) { # -ReturnConnection leaves the current connection alone, and Disconnect-PnPOnline takes no connection: a refused one is dropped. $connection = Connect-PnPOnline -Url $site -ClientId $clientId -Interactive -ReturnConnection -ErrorAction Stop if ([string](Get-PnPTenantId -Connection $connection -ErrorAction Stop) -ne [string]$info['TenantId']) { throw "SharePoint connected to the wrong tenant instead of $($info['TenantId']) ($Tenant): $site." } $sessions["Pnp|$site"] = $connection } return $sessions["Pnp|$site"] } } $null } function Disconnect-TcRemediationService { <# .SYNOPSIS Ends the sessions of the run. #> [CmdletBinding()] param() $sessions = $script:TcRemediationRun['Connections'] if ($sessions['Graph']) { Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null } if ($sessions['Exo'] -or $sessions['Scc']) { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue } if ($sessions['Teams']) { Disconnect-MicrosoftTeams -ErrorAction SilentlyContinue | Out-Null } $sessions.Clear() } function Invoke-TcGraphTransport { <# .SYNOPSIS The only call to Microsoft Graph of the remediations. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Method, [Parameter(Mandatory)][string]$Url, $Body, [hashtable]$Headers = @{} ) $request = @{ Method = $Method; Uri = $Url; OutputType = 'Hashtable'; ErrorAction = 'Stop' } if ($null -ne $Body) { $request['Body'] = $Body | ConvertTo-Json -Depth 30 -Compress; $request['ContentType'] = 'application/json' } if ($Headers.Count) { $request['Headers'] = $Headers } Invoke-MgGraphRequest @request } function Send-TcGraphRequest { <# .SYNOPSIS One Graph request, with retries. Retry-After is respected. .DESCRIPTION A 429 ran nothing, so any request is retried. After a 503, a 504 or a cut connection a write may have gone through, so only idempotent requests are retried, never a POST or a DELETE. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Method, [Parameter(Mandatory)][string]$Url, $Body, [hashtable]$Headers = @{} ) $idempotent = $Method -in 'GET', 'PUT', 'PATCH' for ($attempt = 1; $attempt -le 6; $attempt++) { try { return (Invoke-TcGraphTransport -Method $Method -Url $Url -Body $Body -Headers $Headers) } catch { $text = Get-TcErrorText -ErrorInput $_ $status = [int](Get-TcProperty (Get-TcProperty $_.Exception 'Response') 'StatusCode' 0) if (-not $status -and $text -match '\b(4\d\d|5\d\d)\b') { $status = [int]$Matches[1] } $again = $status -eq 429 -or ($idempotent -and $status -in 503, 504) if ($again -and $attempt -lt 6) { Wait-TcRemediation -Seconds (Get-TcRetryDelay -ErrorRecord $_ -Attempt $attempt); continue } if (-not $status -and -not $idempotent) { throw ("Graph {0} {1} gave no answer: the write may have gone through. Check the tenant before running it again ({2})." -f $Method, $Url, $text) } throw ('Graph {0} {1} answered {2}: {3}' -f $Method, $Url, $status, $text) } } } function Invoke-TcGraph { <# .SYNOPSIS A Graph call of a remediation. Any method but GET is refused before the confirmation. .PARAMETER All Follows the pages and returns the whole list. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Tenant, [ValidateSet('GET', 'POST', 'PATCH', 'PUT', 'DELETE')][string]$Method = 'GET', [Parameter(Mandatory)][string]$Uri, $Body, [switch]$All, [hashtable]$Headers = @{} ) Assert-TcRemediationCall -What "Graph $Method $Uri" -Tenant $Tenant -Write:($Method -ne 'GET') [void](Connect-TcRemediationService -Tenant $Tenant -Service Graph) $url = if ($Uri -match '^https://') { $Uri } else { "https://graph.microsoft.com/$Uri" } if (-not $All) { return (Send-TcGraphRequest -Method $Method -Url $url -Body $Body -Headers $Headers) } $items = [System.Collections.Generic.List[object]]::new() while ($url) { $page = Send-TcGraphRequest -Method $Method -Url $url -Body $Body -Headers $Headers foreach ($value in @(Get-TcProperty $page 'value')) { if ($null -ne $value) { $items.Add($value) } } $url = Get-TcProperty $page '@odata.nextLink' } $items.ToArray() } function Invoke-TcCommandTransport { <# .SYNOPSIS The only call to a cmdlet of a service. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Service, [Parameter(Mandatory)][string]$Command, [hashtable]$Parameters = @{}, $Connection ) $copy = @{} foreach ($key in @($Parameters.Keys)) { $copy[$key] = $Parameters[$key] } if ((Get-Command $Command -ErrorAction Stop).Parameters.ContainsKey('Confirm')) { $copy['Confirm'] = $false } if ($Service -eq 'Pnp') { $copy['Connection'] = $Connection } & $Command @copy -ErrorAction Stop } function Get-TcCommandParameter { <# .SYNOPSIS The parameters of a service command, once its module is loaded. #> [CmdletBinding()] [OutputType([System.Collections.IDictionary])] param( [Parameter(Mandatory)][string]$Command ) (Get-Command -Name $Command -ErrorAction Stop).Parameters } function Invoke-TcServiceCommand { <# .SYNOPSIS Runs a cmdlet on a tenant. Only a Get- or an Export-, which read, run without a confirmed domain. #> [CmdletBinding()] param( [Parameter(Mandatory)][string]$Tenant, [Parameter(Mandatory)][ValidateSet('Exo', 'Scc', 'Teams', 'Pnp')][string]$Service, [Parameter(Mandatory)][string]$Command, [hashtable]$Parameters = @{}, [string]$Url ) Assert-TcRemediationCall -What "$Command on $Tenant" -Tenant $Tenant -Write:($Command -notmatch '^(Get|Export)-') Assert-TcServiceFamily -Service $Service $connection = Connect-TcRemediationService -Tenant $Tenant -Service $Service -Url $Url Invoke-TcCommandTransport -Service $Service -Command $Command -Parameters $Parameters -Connection $connection } function Get-TcEmergencyAccount { <# .SYNOPSIS The emergency accounts of the target, from the remediation settings. #> [CmdletBinding()] [OutputType([string])] param() $domain = Get-TcRemediationOption 'EmergencyAccountDomain' if (-not $domain) { $domain = (Get-TcRemediationTenant -Tenant 'Target')['InitialDomain'] } $prefix = Get-TcRemediationOption 'EmergencyAccountPrefix' @(1..[int](Get-TcRemediationOption 'EmergencyAccountCount') | ForEach-Object { '{0}{1}@{2}' -f $prefix, $_, $domain }) } function Test-TcProtectedAccount { <# .SYNOPSIS An emergency account, a kept account or an account of the migration team. A script never touches these. #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory, Position = 0)][AllowEmptyString()][string]$Account, [Parameter(Position = 1)]$Info ) $migration = Get-TcRemediationOption 'MigrationAccountPattern' $Account -match (Get-TcRemediationOption 'ProtectedAccountPattern') -or ($Info -and $Account -in @($Info['KeptAccounts'])) -or ($migration -and $Account -match $migration) } function Get-TcProtectedAccount { <# .SYNOPSIS The accounts of a tenant no script may touch. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory, Position = 0)][string]$Tenant ) if ($Tenant -eq 'Target') { return @(Get-TcEmergencyAccount) } $info = Get-TcRemediationTenant -Tenant $Tenant $found = @(Get-TcRemediationTable $Tenant 'users' | Where-Object { Test-TcProtectedAccount -Account ([string]$_['UPN']) } | ForEach-Object { $_['UPN'] }) @(@($found) + @($info['KeptAccounts']) | Where-Object { $_ -and $_ -notmatch '^<' } | Select-Object -Unique) } function Get-TcMissingProtectedAccount { <# .SYNOPSIS The protected accounts missing from the snapshot, or disabled. #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory, Position = 0)][string]$Tenant ) $enabled = @(Get-TcRemediationTable $Tenant 'users' | Where-Object { ConvertTo-TcBoolean $_['Enabled'] } | ForEach-Object { $_['UPN'] }) @(Get-TcProtectedAccount $Tenant | Where-Object { $_ -notin $enabled }) } function Test-TcTargetDomain { <# .SYNOPSIS True when Exchange accepts a domain in the target. Read once per run. #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory, Position = 0)][string]$Domain ) $run = $script:TcRemediationRun if ($null -eq $run['AcceptedDomains']) { $run['AcceptedDomains'] = @(Invoke-TcServiceCommand -Tenant 'Target' -Service Exo -Command 'Get-AcceptedDomain' | ForEach-Object { ([string](Get-TcProperty $_ 'DomainName')).ToLowerInvariant() }) } $Domain.ToLowerInvariant() -in $run['AcceptedDomains'] } function Test-TcTenantDomain { <# .SYNOPSIS True when a domain is present in a tenant. #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory, Position = 0)][string]$Tenant, [Parameter(Mandatory, Position = 1)][string]$Domain ) [bool]@(Invoke-TcGraph -Tenant $Tenant -Uri 'v1.0/domains' -All | Where-Object { (Get-TcProperty $_ 'id') -eq $Domain }).Count } function Test-TcGraphAbsent { <# .SYNOPSIS True when Graph answers 404: the object is gone. #> [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory, Position = 0)][string]$Tenant, [Parameter(Mandatory, Position = 1)][string]$Uri ) try { [void](Invoke-TcGraph -Tenant $Tenant -Uri $Uri); return $false } catch { if ((Get-TcErrorText -ErrorInput $_) -match 'answered 404') { return $true }; throw } } |