public/Get-MsecTeamsPolicyAssignment.ps1
|
function Get-MsecTeamsPolicyAssignment { <# .SYNOPSIS How many users each Teams policy actually applies to - including the ones that apply to nobody. .DESCRIPTION Get-MsecTeamsPolicy says what each policy CONTAINS. This says who GETS it, which is the other half of the question and the half that decides whether a setting matters. A tenant can hold a carefully restrictive meeting policy and still be wide open, because the restrictive policy is assigned to three people and everyone else falls through to a permissive Global. From the policy list alone those two tenants are indistinguishable. This command is what tells them apart. POLICIES WITH ZERO USERS ARE RETURNED, NOT OMITTED. That is the finding, not an empty result - a policy nobody holds is configuration someone wrote and believes is in force. The policy list is read separately from the user list precisely so a policy with no holders still appears. ONLY PER-USER POLICY TYPES ARE COVERED. Federation and Client are tenant-wide configurations with no assignment at all, so asking who holds them is meaningless - they are in Get-MsecTeamsPolicy and deliberately absent here. A USER WITH NO EXPLICIT ASSIGNMENT GETS GLOBAL. Teams reports that as a null property rather than as the string 'Global', so those users are counted toward Global here. This is why UserCount for Global is usually the whole tenant. UNREADABLE IS NOT ZERO. If the user list cannot be read, UserCount is $null on every row and a warning says so, rather than reporting every policy as applying to nobody - which is both wrong and the most alarming possible reading. .PARAMETER PolicyType Which per-user policy areas to report. Default is all of them. .PARAMETER IncludeUser Return one row per user holding an EXPLICIT (non-Global) assignment instead of the per-policy counts. Use it to see who the exceptions actually are. .EXAMPLE Get-MsecTeamsPolicyAssignment Every per-user policy with the number of users it applies to. .EXAMPLE Get-MsecTeamsPolicyAssignment | Where-Object UserCount -eq 0 The policies that apply to nobody. .EXAMPLE Get-MsecTeamsPolicyAssignment -PolicyType Meeting -IncludeUser Who holds a meeting policy other than Global. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [ValidateSet('Meeting', 'Messaging', 'AppPermission', 'Files')] [string[]] $PolicyType = @('Meeting', 'Messaging', 'AppPermission', 'Files'), [switch] $IncludeUser ) # Same self-connect contract as Get-MsecTeamsPolicy - see the note there for why this is # not a Get-Command check, and why a deliberate -AsCurrentUser session is left alone. if ($script:MsecSession -and -not $script:MsecTeamsAsCurrentUser) { Connect-MsecTeams } elseif (-not (Get-Command Get-CsOnlineUser -ErrorAction SilentlyContinue)) { throw 'Not connected to Teams and no msec session to connect with. Run Connect-Msec, or Connect-MicrosoftTeams yourself.' } # The policy-listing cmdlet and the Get-CsOnlineUser property that records the assignment. # They are not derivable from each other: the property is 'Teams<X>Policy' for some areas # and not for others, so both are written down. $areas = @{ Meeting = @{ Cmdlet = 'Get-CsTeamsMeetingPolicy'; UserProperty = 'TeamsMeetingPolicy' } Messaging = @{ Cmdlet = 'Get-CsTeamsMessagingPolicy'; UserProperty = 'TeamsMessagingPolicy' } AppPermission = @{ Cmdlet = 'Get-CsTeamsAppPermissionPolicy'; UserProperty = 'TeamsAppPermissionPolicy' } Files = @{ Cmdlet = 'Get-CsTeamsFilesPolicy'; UserProperty = 'TeamsFilesPolicy' } } # -ResultSize is a 32-bit integer on this cmdlet, NOT the 'Unlimited' keyword the rest of # the Exchange-family cmdlets take. Passing 'Unlimited' throws a transformation error, and # passing [uint32]::MaxValue overflows the Int32 it actually binds to. $users = $null try { $users = @(Get-CsOnlineUser -ResultSize ([int]::MaxValue) -ErrorAction Stop) } catch { # Counts are reported as $null below rather than 0. A failed read that prints zero # users on every policy reads as a tenant where no policy applies to anyone. Write-Warning "Could not read the Teams user list via Get-CsOnlineUser, so UserCount is reported as null rather than as 0 on every row. The policies themselves are still listed. Teams said: $($_.Exception.Message)" } # Get-CsOnlineUser returns guests and resource accounts alongside staff. They are counted, # not filtered: deciding which account "really" holds a policy is a judgement this command # should not make silently. -IncludeUser shows exactly who is behind a count. $totalUsers = if ($null -eq $users) { $null } else { $users.Count } foreach ($type in $PolicyType) { $spec = $areas[$type] $policies = $null try { $policies = @(& $spec.Cmdlet -ErrorAction Stop) } catch { $hint = if ($_.Exception.Message -match 'unauthor|forbidden|denied|privilege|access') { ' The likeliest cause is the missing DIRECTORY ROLE: app permissions alone are not enough for Teams, and the app also needs Teams Administrator, Teams Communications Administrator or Global Reader.' } else { '' } Write-Warning "Could not list $type policies via $($spec.Cmdlet), so they are NOT covered by this output.$hint Teams said: $($_.Exception.Message)" [PSCustomObject]@{ PSTypeName = 'MsecTeamsPolicyAssignment' PolicyType = $type PolicyName = 'Unreadable' IsGlobal = $null UserCount = $null PercentOfUsers = $null TotalUsers = $totalUsers } continue } # Explicit assignments, by policy name. A null or empty property means the user was # never assigned anything and therefore gets Global - counted against Global below, # not dropped. $explicit = @{} $noAssignment = 0 if ($null -ne $users) { foreach ($u in $users) { $raw = $u.($spec.UserProperty) # An assigned policy arrives as a UserPolicyDefinition carrying .Name; # older module versions hand back a plain string. Both are accepted. $name = if ($null -eq $raw) { '' } elseif ($raw.PSObject.Properties.Name -contains 'Name') { [string] $raw.Name } else { [string] $raw } if ([string]::IsNullOrWhiteSpace($name)) { $noAssignment++; continue } $name = $name -replace '^Tag:', '' if ($explicit.ContainsKey($name)) { $explicit[$name]++ } else { $explicit[$name] = 1 } } } if ($IncludeUser) { if ($null -eq $users) { continue } foreach ($u in $users) { $raw = $u.($spec.UserProperty) $name = if ($null -eq $raw) { '' } elseif ($raw.PSObject.Properties.Name -contains 'Name') { [string] $raw.Name } else { [string] $raw } if ([string]::IsNullOrWhiteSpace($name)) { continue } [PSCustomObject]@{ PSTypeName = 'MsecTeamsPolicyAssignmentUser' PolicyType = $type PolicyName = ($name -replace '^Tag:', '') UserPrincipalName = $u.UserPrincipalName AccountType = $u.AccountType } } continue } foreach ($policy in $policies) { $identity = [string] $policy.Identity if (-not $identity) { $identity = 'Global' } $display = $identity -replace '^Tag:', '' $isGlobal = ($display -eq 'Global') $count = if ($null -eq $users) { $null } elseif ($isGlobal) { # Everyone who was never assigned anything, plus anyone pinned to # Global explicitly. $noAssignment + $(if ($explicit.ContainsKey('Global')) { $explicit['Global'] } else { 0 }) } else { if ($explicit.ContainsKey($display)) { $explicit[$display] } else { 0 } } [PSCustomObject]@{ PSTypeName = 'MsecTeamsPolicyAssignment' PolicyType = $type PolicyName = $display IsGlobal = $isGlobal UserCount = $count PercentOfUsers = if ($null -eq $count -or -not $totalUsers) { $null } else { [math]::Round(($count / $totalUsers) * 100, 2) } TotalUsers = $totalUsers } } } } |