The three routes into a user
- Direct assignment —
Grant-CsTeamsMeetingPolicy -Identity user -PolicyName X. Sticks until explicitly removed. Highest precedence, no exceptions. - Group assignment — policy attached to an Entra group with a rank. A user in several policy-bearing groups gets the policy from the lowest-rank (=highest priority) group. Membership changes flow through automatically — this is the scalable route.
- Global (Org-wide default) — what's left when nothing above applies.
Precedence is absolute, not additive: direct beats group beats Global, and the winning policy supplies EVERY setting. There is no per-setting merge — a common and expensive misunderstanding ("we changed Global, why didn't anyone get it?" — because everyone has direct assignments from a 2022 migration script).
The engine's sharp edges
- Propagation is not instant. Direct grants usually land in minutes; group assignment adds membership-evaluation time; a full org-wide change can take hours to reach every client. The client also caches — sign-out/in forces a re-fetch.
- An empty direct assignment is still an assignment.
Grant ... -PolicyName $nullREMOVES the direct route (falls back to group/Global); assigning the policy that merely matches Global still pins the user forever. - Batch assignment (
New-CsBatchPolicyAssignmentOperation) is fire-and-forget with an operation id — completion is not success; read the per-user errors. - Group assignment doesn't cover everything — a few policy types remain direct/Global only; check per type before designing a group-based model.
Design pattern that survives audits
Global = the safe default for everyone. Groups = the exceptions, named for intent
(pol-teams-recording-allowed), ranks documented. Direct = break-glass only, reviewed
quarterly. Anything else decays into archaeology.
What to watch (proofs)
- The verdict for one user:
Get-CsUserPolicyAssignment -Identity <upn>— shows each policy type, the winning policy, and the ROUTE (Direct/Group <id>) — this single cmdlet is the ticket-closer. - Group model inventory:
Get-CsGroupPolicyAssignment— every group-attached policy with rank; two groups same rank + same type = order you didn't choose. - Batch results:
Get-CsBatchPolicyAssignmentOperation -OperationId <id>— readUserStateper user; the failures hide here. - Drift over time: our scan snapshots every policy instance nightly — when the transcription verdicts said "7 of 11 policies", that census is this engine's output.