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.
Photo: Getty Images on Unsplash
What the project does
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
-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
.\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
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
-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
-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-RetentionCompliancePolicydoesn'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-Labelrejects user-defined encryption unlessPromptUserand eitherDoNotForwardorEncryptOnlyare set. My examples only setPromptUser. The label was created, the encryption step failed, and the module reported it as a failed change, which is what it should do.
The public version has no tenant names, client configs or site URLs; the examples use Contoso.
What I learned
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
Sources
- purview-as-code on GitHub
- about_Preference_Variables (Microsoft Learn)
- about_Scopes: modules and session state (Microsoft Learn)
- Set-Label: the WhatIf switch doesn't work in Security & Compliance PowerShell (Microsoft Learn)
- Get-RetentionCompliancePolicy: -DistributionDetail (Microsoft Learn)
- Migrate parent sensitivity labels to label groups (Microsoft Learn)