Purview as code: why -⁠WhatIf deployed everything anyway

Nov 10, 2026 · Samantha Kloos-Kilkens

I set up the basics of Purview in my own tenant first. When I started doing the same for clients, I wrote the labels, policies and rules down as JSON and built PowerShell around it, so every deployment would go the same way. I've now made that public as purview-as-code, so others can use it too: a PowerShell module that deploys sensitivity labels, label policies, DLP and retention from JSON. Before making it public I went through it properly, and found two bugs that had been there from the start. One of them meant that the safest-looking command in the README did the opposite of what it promised. Fixing that properly turned into a rebuild, and testing the rebuild against a real tenant found two more.

Hands typing on a laptop that lies on architectural drawings Photo: Getty Images on Unsplash

What the project does

You describe the configuration you want in JSON: labels and sublabels with their encryption and markings, label groups for tenants on the modern label scheme, label policies, DLP policies and rules, retention labels and policies. Invoke-PurviewDeployment first checks that your session is connected to the tenant named in the config. Then it reads the tenant, compares every object with the config and prints a plan: what it will create, what it will change (with the old and the new value), what already matches, and, for sensitivity labels, published labels and locations, what exists only in the tenant. With -WhatIf it stops there. Without it, it applies only the differences, so a second run on an unchanged tenant changes nothing. Test-PurviewConfig checks the JSON against a schema, plus the things a schema can't check, before anything connects.

Two helper scripts handle the parts around a deploy: the Conditional Access policy for app enforced restrictions that makes the unmanaged-devices setting of a container label do anything at all, and default retention labels on SharePoint libraries.

It doesn't delete labels or policies. Removing a label from a label policy, or a location from a policy, only happens with -AllowRemoval. Without it, the difference shows up in the plan as drift and stays a decision for a person.

-WhatIf that wasn't

The README told you to run the deploy with -WhatIf first, to see what would change. The deploy script supported ShouldProcess, and every function in the modules checked $PSCmdlet.ShouldProcess before it changed anything. That looks right, and it's what most people would write.

It doesn't work. Preference variables like $WhatIfPreference don't flow from a script into a script module. A module has its own session state, so when the script calls a module function without passing -WhatIf explicitly, the function looks up $WhatIfPreference in the module's scope and the global scope, finds $false, and goes ahead. On top of that, Security & Compliance PowerShell itself ignores -WhatIf; the cmdlet documentation says so plainly. So there's no second safety net either.

This reproduces it in a few lines:
# Demo.psm1
function Set-Thing {
    [CmdletBinding(SupportsShouldProcess)]
    param()
    if ($PSCmdlet.ShouldProcess("tenant", "Change setting")) { "CHANGED: this ran for real" }
}

# Deploy.ps1
[CmdletBinding(SupportsShouldProcess)]
param()
Import-Module (Join-Path $PSScriptRoot "Demo.psm1") -Force
Set-Thing
Run .\Deploy.ps1 -WhatIf and you get "CHANGED: this ran for real". Change the last line to Set-Thing -WhatIf:$WhatIfPreference and you get the expected "What if: Performing the operation...". Within the module, calls between functions are fine, because they share the module's scope; it's only the boundary between script and module that loses the preference.

To see what it did to the real project, I replaced the Purview cmdlets with stubs that count every create and update call and ran the deploy with -WhatIf against one of the example configs. The original version made 31 changes. Passing -WhatIf:$WhatIfPreference explicitly to each module function brought that to zero. That was the quick fix.

A validation script that never found anything

The second bug was quieter. Test-PurviewConfig.ps1 collected errors and warnings in $errors and $warnings, and checked labels in a nested function so it could walk sublabels recursively. Inside that function, $errors += "..." doesn't add to the script's array: it creates a new local variable with a copy. When the function returns, the copy is gone. Every label check (missing display names, duplicate names, encryption without a protection type) ran, found problems, and threw them away. A config with a duplicate label name passed validation.

The fix was the $script: scope modifier on those variables. It's a classic PowerShell trap, and a good reason to test a validation script with a config that should fail, not only with one that should pass.

From a quick fix to a plan

With -WhatIf fixed, the deploy still never looked at what was in the tenant before it acted, and several smaller bugs came from that. Labels were only configured when they were created; on later runs the script reported "already exists" and moved on. DLP rules with BlockAccess set to false were skipped because the check treated false as "not set". Changing the locations of an existing DLP policy failed, because Set-DlpCompliancePolicy has no -ExchangeLocation or -SharePointLocation; for those it only takes the Add and Remove variants. A retention policy whose rule failed to create was never repaired on later runs.

So version 0.2.0 is a module that reads before it writes. Every change is added to the plan first and then goes through one function that checks ShouldProcess, and because you call the module function directly, there's no script boundary left for -WhatIf to get lost at. Pester tests run the module against stand-ins for the Security & Compliance cmdlets and check that a -WhatIf run calls nothing that changes the tenant, and that a second run after a deploy calls nothing either. GitHub Actions runs them on every push to main and every pull request.

Tenants created from 1 October 2025 use label groups instead of parent labels, so the project supports "IsLabelGroup": true. I tested that part against a real tenant: New-Label -IsLabelGroup creates the group, a label created with -ParentId set to its GUID ends up inside it, and trying to publish the group fails, which the validation catches before you get there.

What a real tenant found

Stand-ins only prove that the module agrees with my idea of the cmdlets. So I ran it against my own tenant: first the baseline example with -WhatIf, then a config with two test labels that the module created and changed for real. The labels were never published and a script removed them afterwards. DLP policies, label policies and retention labels were only planned against the tenant, not created.

Most of it held. The plan matched what the tenant had, down to a header that existed but was switched off. After creating the test labels and after changing one of them, the next run reported no changes. Purview keeps label settings as key/value pairs in LabelActions, and I couldn't find the key names documented; the test confirmed every key the module reads.

Two things didn't hold:
  • Get-RetentionCompliancePolicy doesn't return reliable location values unless you add -DistributionDetail. The documentation says so; I hadn't read that far. An existing retention policy with Exchange set to All showed up as a change.
  • A label without a scope covers files and email, and for that combination Set-Label rejects user-defined encryption unless PromptUser and either DoNotForward or EncryptOnly are set. My examples only set PromptUser. The label was created, the encryption step failed, and the module reported it as a failed change, which is what it should do.
Both are fixed, and the validation now catches the second one before you deploy.

The public version has no tenant names, client configs or site URLs; the examples use Contoso.

What I learned

Most of what went wrong wasn't Purview. It was PowerShell scoping, documentation I hadn't read to the end, and my own assumptions about what the service would accept. Three things I'd pass on:

If you write PowerShell modules with SupportsShouldProcess, test -WhatIf from the script that calls them, not just from the module function itself. If the tool talks to Security & Compliance PowerShell, don't rely on the cmdlets to protect you: the only -WhatIf there is the one you implement. And run it against a real tenant before you trust the stand-ins, because they agree with you by design.

To try it, clone the repo, copy one of the examples to a new configs folder, set your own tenant domain in it, and run:
Import-Module ./PurviewAsCode
Test-PurviewConfig -ConfigFile ./configs/yours.json
Connect-IPPSSession
Invoke-PurviewDeployment -ConfigFile ./configs/yours.json -UseExistingSession -WhatIf
The demo script from this post is also on GitHub, in purview-scripts.

Written by an Argus IT expert

Want an expert like this in your team, or work like this yourself in security, IAM or cloud?

Samantha Kloos-Kilkens

Samantha Kloos-Kilkens

Sam has worked in security and identity for more than ten years, eight of them at Microsoft in the Identity and Network Access product group. She founded Argus IT in 2023 and has a soft spot for the hidden gems in Entra ID. CISSP, CCSP, CISM, CCSK and Microsoft Certified Trainer.

Stay in the Loop

Subscribe to our newsletter for a friendly nudge whenever we publish a new article or have news to share.