public/Get-MsecEntraCrossTenantAccess.ps1
|
function Get-MsecEntraCrossTenantAccess { <# .SYNOPSIS Cross-tenant access settings - which external tenants may collaborate, and which of their security claims this tenant accepts in place of its own. .DESCRIPTION Cross-tenant access decides two things for every external organisation: whether their users may be invited in at all, and whether their MFA and device claims are accepted here. Both are set once in a place nobody looks at again, and the defaults govern far more partners than the explicit entries do. AN INHERITED SETTING IS NOT A SETTING OF FALSE, AND THIS IS THE DEFECT THE COMMAND EXISTS TO PREVENT. Graph returns inboundTrust as NULL on a partner that has no override - it inherits the tenant default - and null renders as blank or False in almost any table. If the default later turns MFA trust ON, every one of those partners silently follows, while a report built from the raw field still shows them as not trusting. TrustsMfa here is the EFFECTIVE value, resolved against the default; IsInherited says whether the partner set it or merely follows. THE DEFAULT ROW IS RETURNED AS A ROW, not as context for the others. On a measured tenant, four partners were configured - all IT suppliers - while the guests that actually mattered, 25 from one CRO and 24 from another, had no entry at all and were governed entirely by the default. Reading only the partner list describes the organisations someone once thought about, not the ones with access. TRUSTING MFA CANNOT ADMIT AN UN-AUTHENTICATED USER, which is worth stating because the setting is routinely read as a weakening. It accepts an MFA claim the home tenant actually issued; where the home tenant performed no MFA there is no claim, and a Conditional Access policy requiring MFA still blocks. What it avoids is making someone who has already done MFA do it again here - in a tenant where the methods available to a guest may well be weaker than the ones they just used. Runs as the msec app. Needs 'Policy.Read.All'. Tenant names are resolved through findTenantInformationByTenantId, which is unauthenticated tenant metadata; a tenant that cannot be resolved reports its id rather than a blank. .PARAMETER TrustingMfaOnly Only entries whose effective setting accepts the partner's MFA claim. .PARAMETER BlockedOnly Only entries where inbound B2B collaboration is blocked outright. .EXAMPLE Get-MsecEntraCrossTenantAccess The default row plus every configured partner, with what each one's claims buy them. .EXAMPLE # Partners that merely follow the default - they move the moment the default moves. Get-MsecEntraCrossTenantAccess | Where-Object IsInherited .OUTPUTS PSCustomObject per scope, PSTypeName 'MsecEntraCrossTenantAccess'. .NOTES Read-only. Read with Get-MsecEntraGuest: this says which organisations may hold accounts here, that says which ones actually do. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [switch] $TrustingMfaOnly, [switch] $BlockedOnly ) Assert-MsecSession try { $default = Invoke-MsecGraphRequest -Path '/v1.0/policies/crossTenantAccessPolicy/default' $partners = @(Invoke-MsecGraphRequest -All -Path '/v1.0/policies/crossTenantAccessPolicy/partners') } catch { if ($_.Exception.Message -match '403|Forbidden') { throw ("Forbidden reading the cross-tenant access policy. The msec app needs " + "'Policy.Read.All' (admin consent required). Original error: $($_.Exception.Message)") } throw } # The default's own trust block. Every partner without an override resolves to these. $dTrust = $default.inboundTrust $dMfa = [bool]$dTrust.isMfaAccepted $dComp = [bool]$dTrust.isCompliantDeviceAccepted $dHybr = [bool]$dTrust.isHybridAzureADJoinedDeviceAccepted $names = @{} $resolveName = { param([string] $Id) if ($names.ContainsKey($Id)) { return $names[$Id] } $n = $Id try { $info = Invoke-MsecGraphRequest -Path "/v1.0/tenantRelationships/findTenantInformationByTenantId(tenantId='$Id')" if ($info.displayName) { $n = [string]$info.displayName } } catch { Write-Verbose "Could not resolve tenant $Id" } $names[$Id] = $n $n } # accessType is absent on an entry that has never been narrowed, and absent means allowed - # the B2B default. Reporting a blank there would read as 'unknown' for the common case. $access = { param($Block) $t = [string]$Block.usersAndGroups.accessType if ($t) { $t } else { 'allowed' } } $rows = [System.Collections.Generic.List[object]]::new() $rows.Add([PSCustomObject]@{ PSTypeName = 'MsecEntraCrossTenantAccess' Scope = 'Default' TenantName = '(every tenant without its own entry)' TenantId = $null InboundB2B = & $access $default.b2bCollaborationInbound OutboundB2B = & $access $default.b2bCollaborationOutbound TrustsMfa = $dMfa TrustsCompliantDevice = $dComp TrustsHybridJoin = $dHybr # The default cannot inherit from anything, so this is always false here. IsInherited = $false Raw = $default }) foreach ($p in $partners) { $tid = [string]$p.tenantId # NULL MEANS INHERIT, NOT FALSE. Resolving against the default is the whole point. $inherited = ($null -eq $p.inboundTrust) $mfa = if ($inherited -or $null -eq $p.inboundTrust.isMfaAccepted) { $dMfa } else { [bool]$p.inboundTrust.isMfaAccepted } $comp = if ($inherited -or $null -eq $p.inboundTrust.isCompliantDeviceAccepted) { $dComp } else { [bool]$p.inboundTrust.isCompliantDeviceAccepted } $hybr = if ($inherited -or $null -eq $p.inboundTrust.isHybridAzureADJoinedDeviceAccepted) { $dHybr } else { [bool]$p.inboundTrust.isHybridAzureADJoinedDeviceAccepted } $rows.Add([PSCustomObject]@{ PSTypeName = 'MsecEntraCrossTenantAccess' Scope = 'Partner' TenantName = & $resolveName $tid TenantId = $tid InboundB2B = & $access $p.b2bCollaborationInbound OutboundB2B = & $access $p.b2bCollaborationOutbound # Effective, not raw - see the description. TrustsMfa = $mfa TrustsCompliantDevice = $comp TrustsHybridJoin = $hybr # True means this partner follows the default and will move when it moves. IsInherited = $inherited Raw = $p }) } foreach ($r in $rows) { if ($TrustingMfaOnly -and -not $r.TrustsMfa) { continue } if ($BlockedOnly -and $r.InboundB2B -ne 'blocked') { continue } $r } } |