Remediation/ExchangeOnline/CMP-FREE-BUSY.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 Shares free/busy between the target and each source during the migration (procedure CMP-FREE-BUSY). .DESCRIPTION Goes through the Microsoft 365 cross-tenant access policy, not an organization relationship: Microsoft replaces it for this sharing, because it depends on EWS, which is turned off in Exchange Online between October 2026 and April 2027. The policy is inbound: each tenant allows the other. For each source, in the target and in the source: 1. Microsoft 365 trust on the partner configuration created by CMP-CROSS-TENANT; 2. calendar availability capability for this partner; 3. active organization relationship to the other tenant turned off, because it takes priority over the capability. Turning it off waits until the capability is in place: a relationship is never cut before its replacement is there. Decision: decisions\CMP-FREE-BUSY.csv, one row per source. AvailabilityOnly or LimitedDetails: sets up this sharing, at this level; Remove : removes the capabilities and relationships, at closure; None : does nothing. LimitedDetails exposes the subject and location of meetings: the customer must validate it. Limits: Microsoft rolls this policy out tenant by tenant. If the API does not answer, the action is postponed and the script ends with code 1. A capability already present at another level is not changed: the check reports it. The test with the scheduling assistant stays to be done by a person. #> $c = Open-TcRemediation $PartnerUri = 'v1.0/policies/crossTenantAccessPolicy/partners' $Capabilities = [ordered]@{ AvailabilityOnly = 'crossTenantCalendarAvailabilityBasic'; LimitedDetails = 'crossTenantCalendarAvailabilityLimitedDetails' } $Trust = @{ m365CollaborationInbound = @{ users = @{ accessType = 'allowed'; targets = @(@{ target = 'AllUsers'; targetType = 'user' }) } } } $Scope = @{ included = @(@{ resourceId = 'All'; resourceType = 'user' }); excluded = @() } $targetId = (Get-TcMember (Get-TcRemediationTenant 'Target') 'TenantId') $TargetDomains = @(Get-TcRemediationTable 'Target' 'domains' | Where-Object { (ConvertTo-TcBoolean $_['Verified']) -and $_['Services'] -match '\bEmail\b' } | ForEach-Object { $_['Domain'] }) function Get-PartnerUri { param([string]$Uri) return ($Uri -replace '^.*/partners/([^/]+).*$', '$1') } function Get-Partner { <# Partner configuration, or $null if it does not exist: the direct GET returns an error in that case. #> param([string]$Tenant, [string]$Partner) if (-not @(Invoke-TcGraph -Tenant $Tenant -Uri $PartnerUri -All | Where-Object { (Get-TcMember $_ 'tenantId') -eq $Partner }).Count) { return $null } return (Invoke-TcGraph -Tenant $Tenant -Uri "$PartnerUri/$Partner") } function Get-PartnerCapability { <# Capability allowed for this partner, or $null. #> param([string]$Tenant, [string]$Partner, [string]$Name) return (@(Invoke-TcGraph -Tenant $Tenant -Uri "$PartnerUri/$Partner/m365Capabilities" -All) | Where-Object { (Get-TcMember $_ 'name') -eq $Name -and (Get-TcMember (Get-TcMember $_ 'inboundAccess') 'isAllowed') } | Select-Object -First 1) } function Get-Relationship { param([string]$Tenant, [string]$Name) return (Invoke-TcServiceCommand -Tenant $Tenant -Service Exo -Command 'Get-OrganizationRelationship' | Where-Object Name -eq $Name | Select-Object -First 1) } function Get-RelationshipTo { <# Active relationships of this tenant that share free/busy with these domains, according to the snapshot. #> param([string]$Tenant, [string[]]$Domains) return @(Get-TcRemediationTable $Tenant 'organizationRelationships' | Where-Object { (ConvertTo-TcBoolean $_['Enabled']) -and (ConvertTo-TcBoolean $_['Free/busy']) -and @([string]$_['Domains'] -split ';\s*' | Where-Object { $_ -in $Domains }).Count }) } function Get-PairList { <# Both directions of a source: the policy is inbound, each tenant allows the other. #> param([string]$Source) $i = Get-TcRemediationTenant $Source return @( @{ Tenant = 'Target'; Partner = (Get-TcMember $i 'TenantId'); Name = (Get-TcMember $i 'Name'); Relationships = @(Get-RelationshipTo 'Target' (@((Get-TcMember $i 'InitialDomain')) + @((Get-TcMember $i 'Domains')))) } @{ Tenant = $Source; Partner = $targetId; Name = 'Target'; Relationships = @(Get-RelationshipTo $Source $TargetDomains) }) } $proposals = foreach ($t in $c.Tenants) { Write-TcRemediationTenant $t $i = Get-TcRemediationTenant $t $relationships = @(Get-PairList $t | ForEach-Object { (Get-TcMember $_ 'Relationships') }) if ($relationships.Count) { Write-TcRemediationAlert ("{0} organization relationship(s) to replace: they depend on EWS and take priority over the cross-tenant policy" -f $relationships.Count) } Write-TcRemediationNote ("{0} active relationship(s) between {1} and the target" -f $relationships.Count, $t) $level = @($relationships | ForEach-Object { [string]$_['Level'] } | Where-Object { $_ -in $Capabilities.Keys } | Select-Object -First 1) [ordered]@{ Tenant = $t; 'Source domains' = (@((Get-TcMember $i 'InitialDomain')) + @((Get-TcMember $i 'Domains'))) -join ', '; 'Target domains' = $TargetDomains -join ', ' 'Relationships to replace' = @($relationships | ForEach-Object { [string]$_['Name'] } | Select-Object -Unique) -join ', '; Proposal = $(if ($level.Count) { $level[0] } else { 'AvailabilityOnly' }) } } $decisions = @(Get-TcDecision -Name 'CMP-FREE-BUSY' -Proposal @($proposals) -Key 'Tenant' -Choice 'AvailabilityOnly', 'LimitedDetails', 'Remove', 'None') foreach ($d in $decisions | Where-Object { (Get-TcMember $_ 'Tenant') -in $c.Tenants -and (Get-TcMember $_ 'Decision') -ne 'None' }) { $t = (Get-TcMember $d 'Tenant') foreach ($p in Get-PairList $t) { $base = "$PartnerUri/$((Get-TcMember $p 'Partner'))" if ((Get-TcMember $d 'Decision') -eq 'Remove') { foreach ($name in $Capabilities.Values) { Add-TcGraphAction -Tenant (Get-TcMember $p 'Tenant') -Method DELETE -Uri "$base/m365Capabilities/$name" -Object "$name $((Get-TcMember $p 'Name'))" -Description ("{0}: remove the capability {1} from {2}" -f (Get-TcMember $p 'Tenant'), $name, (Get-TcMember $p 'Name')) ` -Test { param($a) -not (Get-PartnerCapability $a.tenant (Get-PartnerUri $a.uri) ($a.uri -replace '^.*/', '')) } ` -Undo $null -Reason 'Recreate with this script if sharing needs to come back.' } foreach ($r in (Get-TcMember $p 'Relationships')) { Add-TcCommandAction -Tenant (Get-TcMember $p 'Tenant') -Service Exo -Command 'Remove-OrganizationRelationship' -Parameters @{ Identity = [string]$r['Name'] } -Object ([string]$r['Name']) ` -Description ("Remove the organization relationship '{0}' from {1}" -f $r['Name'], (Get-TcMember $p 'Tenant')) ` -Test { param($a) -not (Get-Relationship $a.tenant $a.parameters.Identity) } ` -Reason 'Relationship already replaced by the cross-tenant policy.' } continue } $capability = $Capabilities[(Get-TcMember $d 'Decision')] Add-TcGraphAction -Tenant (Get-TcMember $p 'Tenant') -Method PATCH -Uri $base -Body $Trust -Object "Trust $((Get-TcMember $p 'Name'))" -Description ("{0}: open Microsoft 365 trust to {1}" -f (Get-TcMember $p 'Tenant'), (Get-TcMember $p 'Name')) ` -Test { param($a) $x = Get-Partner $a.tenant (Get-PartnerUri $a.uri) if (-not $x) { return (Test-TcPostponed $a 'partner configuration missing: run CMP-CROSS-TENANT') } return ((Get-TcMember (Get-TcMember (Get-TcMember $x 'm365CollaborationInbound') 'users') 'accessType') -eq 'allowed') } Add-TcGraphAction -Tenant (Get-TcMember $p 'Tenant') -Method POST -Uri "$base/m365Capabilities" -Body @{ '@odata.type' = "#microsoft.graph.$capability"; inboundAccess = @{ isAllowed = $true; resourceScopes = $Scope } } ` -Object "$capability $((Get-TcMember $p 'Name'))" -Description ("{0}: allow {1} to read availability, level {2}" -f (Get-TcMember $p 'Tenant'), (Get-TcMember $p 'Name'), (Get-TcMember $d 'Decision')) ` -Undo @{ type = 'Graph'; tenant = (Get-TcMember $p 'Tenant'); method = 'DELETE'; uri = "$base/m365Capabilities/$capability" } ` -Test { param($a) try { return [bool](Get-PartnerCapability $a.tenant (Get-PartnerUri $a.uri) ($a.body.'@odata.type' -replace '^.*\.', '')) } catch { return (Test-TcPostponed $a 'Microsoft 365 capabilities unreadable: policy not yet deployed in this tenant, or missing permission') } } foreach ($r in (Get-TcMember $p 'Relationships')) { Add-TcBlockAction -Tenant (Get-TcMember $p 'Tenant') -Service Exo -Object ([string]$r['Name']) -Description ("Disable in {0} the relationship {1}, replaced by the capability" -f (Get-TcMember $p 'Tenant'), $r['Name']) ` -Data @{ Name = [string]$r['Name']; Partner = (Get-TcMember $p 'Partner'); Capability = $capability } ` -Undo @{ command = 'Set-OrganizationRelationship'; parameters = @{ Identity = [string]$r['Name']; Enabled = $true } } ` -Test { param($a) $x = Get-Relationship $a.tenant $a.data.Name if (-not $x -or -not (Get-TcMember $x 'Enabled')) { return $true } if (-not (Get-PartnerCapability $a.tenant $a.data.Partner $a.data.Capability)) { return (Test-TcPostponed $a 'relationship kept: the capability that replaces it is not in place') } return $false } ` -Block { param($a) Invoke-TcServiceCommand -Tenant $a.tenant -Service Exo -Command 'Set-OrganizationRelationship' -Parameters @{ Identity = $a.data.Name; Enabled = $false } } } } if ((Get-TcMember $d 'Decision') -ne 'Remove') { Add-TcManualAction -Tenant 'Target' -Object "Test $t" -Description ("Test availability in both directions with {0}" -f $t) ` -Instruction @(("With a target account and an account of {0}, open the Outlook scheduling assistant." -f $t), 'Each account must see the availability of the other.', 'If it fails, re-enable the relationship with Undo-TcRemediation and read the log again.') } } Invoke-TcRemediationPlan -Verify { param($t) $d = $decisions | Where-Object Tenant -eq $t | Select-Object -First 1 if (-not $d -or (Get-TcMember $d 'Decision') -eq 'None') { return } foreach ($p in Get-PairList $t) { $presentOnes = @($Capabilities.Values | Where-Object { Get-PartnerCapability (Get-TcMember $p 'Tenant') (Get-TcMember $p 'Partner') $_ }) if ((Get-TcMember $d 'Decision') -eq 'Remove') { if ($presentOnes.Count) { New-TcRemediationResult Blocking ("{0}: capability still present for {1}: {2}" -f (Get-TcMember $p 'Tenant'), (Get-TcMember $p 'Name'), ($presentOnes -join ', ')) } else { New-TcRemediationResult OK ("{0}: no capability for {1}" -f (Get-TcMember $p 'Tenant'), (Get-TcMember $p 'Name')) } continue } $wantedItem = $Capabilities[(Get-TcMember $d 'Decision')] if ($wantedItem -in $presentOnes) { New-TcRemediationResult OK ("{0}: {1} allowed, level {2}" -f (Get-TcMember $p 'Tenant'), (Get-TcMember $p 'Name'), (Get-TcMember $d 'Decision')) } elseif ($presentOnes.Count) { New-TcRemediationResult Warning ("{0}: {1} has {2}, the decision is {3}" -f (Get-TcMember $p 'Tenant'), (Get-TcMember $p 'Name'), ($presentOnes -join ', '), (Get-TcMember $d 'Decision')) } else { New-TcRemediationResult Blocking ("{0}: no capability for {1}" -f (Get-TcMember $p 'Tenant'), (Get-TcMember $p 'Name')) } foreach ($r in (Get-TcMember $p 'Relationships')) { $x = Get-Relationship (Get-TcMember $p 'Tenant') ([string]$r['Name']) if ($x -and (Get-TcMember $x 'Enabled')) { New-TcRemediationResult Warning ("{0}: the relationship {1} is still active and takes priority over the capability" -f (Get-TcMember $p 'Tenant'), $r['Name']) } } } } Close-TcRemediation |