By the end of this you have one custom definition forked from a Microsoft built-in, carrying provenance metadata that says exactly where it came from and what you changed, running beside the original long enough to prove it differs in one respect and no others.
How this was verified: every command, setting and value below is taken from current Microsoft documentation, checked against the product’s own command reference in August 2026, and shaped by estates I have built. The sequence has not been executed end to end as a single unbroken lab run for this article. Where a step is more likely than others to differ at a console, it says so in place.
Before you start
You need Resource Policy Contributor or Owner at the Northfork intermediate root, the Azure CLI, and jq. You also need the Differ pile from the baseline build sheet earlier in this series: the list of controls that report something you want but do not quite fit. If you have not produced that list, do it first, because forking a control nobody has measured is guesswork.
MG_NAME="northfork"
MG_ID="/providers/Microsoft.Management/managementGroups/$MG_NAME"
TODAY=$(date -u +%Y-%m-%d)
Step 1. The necessity gate
This gate goes first because everything after it is irreversible in the sense that matters: once a custom definition exists and is assigned, removing it is a change-managed exercise rather than a delete. Take the built-in you think you need to fork and answer two questions from the object itself.
SRC=<the built-in definition name, a GUID, from your Differ pile>
az policy definition show --name "$SRC" --query "{
display: displayName,
type: policyType,
version: version,
mode: mode,
parameters: parameters
}" --output json
Read the parameter block in full. If the thing you want to change is a parameter value, stop: supply it at assignment time and there is no fork. If the parameter exists but the value you need is missing from its allowedValues, you have a real driver and the fork is justified.
The worked example for the rest of this sheet is the most common legitimate case. The control reports correctly, Northfork has decided it must be enforced, and the built-in offers only Audit and Disabled. Confirm that is genuinely true before continuing.
az policy definition show --name "$SRC" \
--query "parameters.effect.allowedValues" --output tsv
Expected output for a fork to be justified is a list without Deny in it. If Deny is present, close this article and change a parameter instead. If the command returns nothing at all, the definition has no effect parameter and hard-codes its effect, which is also a valid driver and changes nothing about the steps below.
Step 2. Export the original, exactly
az policy definition show --name "$SRC" > source.json
SRC_ID=$(jq -r '.id' source.json)
SRC_VER=$(jq -r '.version' source.json)
echo "source: $SRC_ID @ $SRC_VER"
Capture the identifier and the version now, into variables, because they become the provenance record in step 4 and they are the two facts nobody can reconstruct later. A fork without them is a custom definition of unknown ancestry, which is the state most inherited policy estates are already in.
Step 3. Strip what cannot travel
An exported built-in contains fields that belong to Microsoft’s object rather than to yours, and passing them back on create either fails or silently produces something you did not intend. Keep the rule, the parameters, the mode and the display material. Discard the identity of the original.
jq '.policyRule' source.json > rule.json
jq '.parameters' source.json > params.json
| Field | Carry or drop | Why |
|---|---|---|
policyRule | Carry | The logic you are preserving. Change it only if the logic is your fork driver. |
parameters | Carry, then edit | Where the change happens in this worked example. |
mode | Carry, unless mode is the driver | Microsoft’s own catalogue tells you to duplicate and change the mode when you need to reach resources Indexed excludes. |
id, name, type | Drop | Identify Microsoft’s object. Yours gets its own. |
policyType | Drop | Cannot be set. Yours will be Custom whatever you write. |
version and the metadata version | Drop | Microsoft’s version number on your object is a lie that will mislead somebody. The original’s version goes in provenance instead. |
Step 4. Make exactly one change, and record it
Add the effect value the built-in would not offer. One change, so that any behavioural difference you observe later has one possible cause.
jq '.effect.allowedValues += ["Deny"]' params.json > params-forked.json
diff <(jq -S . params.json) <(jq -S . params-forked.json)
Read that diff before continuing. It should show one added array element and nothing else. Anything more means jq reformatted something you did not intend to touch, and a fork that differs in ways you have not noticed is worse than no fork.
Now build the provenance block. These keys are a convention rather than a Microsoft schema, so pick names and use them across the estate without variation.
cat > metadata.json <<EOF
{
"category": "Northfork Custom",
"sourceDefinitionId": "$SRC_ID",
"sourceVersion": "$SRC_VER",
"forkedOn": "$TODAY",
"delta": "Added Deny to effect allowedValues. The built-in offers Audit and Disabled only and Northfork requires this control enforced. No change to policyRule.",
"reviewCadence": "quarterly against the source definition"
}
EOF
The delta string is the most valuable field on the object. Write it for somebody who has never seen the original, is looking at this definition during an incident, and needs to know in one sentence whether it behaves like the Microsoft control they are familiar with.
Step 5. Create the custom definition
SRC_DISPLAY=$(jq -r '.displayName' source.json)
az policy definition create \
--name "NFK-Def-Fork-$SRC" \
--display-name "$SRC_DISPLAY (Northfork, enforceable)" \
--description "Fork of Microsoft built-in $SRC. See metadata for provenance and delta." \
--rules rule.json \
--params params-forked.json \
--metadata "@metadata.json" \
--mode "$(jq -r '.mode' source.json)" \
--management-group "$MG_NAME"
The name keeps the source GUID in it deliberately. It is ugly and it means anybody can trace the copy to the original from the name alone, without opening the object, which is worth more than a tidy name. The display name says Northfork so nobody mistakes it for the built-in in a picker.
Step 6. Wrap it in your own initiative, alongside unforked built-ins
This step is the point of the whole exercise and it is easy to skip. Your organisational standard is an initiative you own, containing mostly Microsoft’s definitions with your parameter values, plus the small number of forks you could not avoid. That is how you get your own policy set without owning many rules.
FORK_ID=$(az policy definition show --name "NFK-Def-Fork-$SRC" \
--management-group "$MG_NAME" --query id -o tsv)
cat > std-definitions.json <<EOF
[
{
"policyDefinitionId": "$FORK_ID",
"policyDefinitionReferenceId": "forkedControl",
"parameters": { "effect": { "value": "[parameters('forkedEffect')]" } }
},
{
"policyDefinitionId": "/providers/Microsoft.Authorization/policyDefinitions/a08ec900-254a-4555-9bf5-e42af04b5c5c",
"policyDefinitionReferenceId": "allowedResourceTypes",
"definitionVersion": "1.*.*",
"parameters": { "listOfResourceTypesAllowed": { "value": "[parameters('allowedTypes')]" } }
}
]
EOF
cat > std-params.json <<'EOF'
{
"forkedEffect": {
"type": "String",
"allowedValues": [ "Audit", "Deny", "Disabled" ],
"defaultValue": "Audit"
},
"allowedTypes": { "type": "Array", "defaultValue": [] }
}
EOF
az policy set-definition create \
--name "NFK-Init-Standard" \
--display-name "Northfork platform standard" \
--definitions std-definitions.json \
--params std-params.json \
--management-group "$MG_NAME"
Note the second member. It is a Microsoft built-in referenced by identifier, pinned to a major version with the rest floating, carrying a Northfork parameter value. No copy, no maintenance, and it sits inside your standard beside the fork as an equal member. Most of a mature initiative should look like that entry and very little of it like the first.
Step 7. The end-to-end test: prove one difference, and only one
Assign the fork and the original side by side, both in audit, over the same scope. If the copy is faithful they will agree on every resource. Any disagreement is a defect introduced in step 3 or 4.
SUB_ID=$(az account show --query id -o tsv)
SCOPE="/subscriptions/$SUB_ID"
az policy assignment create --name "NFK-Asgn-ForkTest" --scope "$SCOPE" \
--policy "$FORK_ID" --params '{"effect":{"value":"Audit"}}'
az policy assignment create --name "NFK-Asgn-OrigTest" --scope "$SCOPE" \
--policy "$SRC" --params '{"effect":{"value":"Audit"}}'
sleep 300
az policy state trigger-scan --no-wait
Once the scan completes, compare the two result sets as sets rather than as counts, because equal counts over different resources is a failure that looks like a pass.
for A in NFK-Asgn-ForkTest NFK-Asgn-OrigTest; do
az policy state list --scope "$SCOPE" \
--filter "policyAssignmentName eq '$A' and complianceState eq 'NonCompliant'" \
--query "[].resourceId" -o tsv | sort > "/tmp/$A.txt"
done
diff /tmp/NFK-Asgn-ForkTest.txt /tmp/NFK-Asgn-OrigTest.txt && echo "IDENTICAL: fork is faithful"
Expected output is the words IDENTICAL and no diff lines. That is the assertion this build sheet exists to make: in audit, the fork behaves exactly like the built-in, so the only thing you have changed is the availability of an effect. Now, and only now, is it safe to set the fork’s assignment to Deny and take it through the staged rollout later in this series.
Step 8. Register the upstream watch
A fork with no watch on its source is a rule that quietly stops resembling the control it was named after. The provenance you wrote in step 4 makes the check a one-line comparison, and the whole estate can be swept at once.
az policy definition list --management-group "$MG_NAME" \
--query "[?policyType=='Custom' && metadata.sourceDefinitionId != null].{
fork: name, source: metadata.sourceDefinitionId, forkedAt: metadata.sourceVersion
}" -o tsv \
| while read -r FORK SOURCE FORKED_AT; do
NOW=$(az policy definition show --name "${SOURCE##*/}" --query version -o tsv)
[ "$NOW" != "$FORKED_AT" ] && echo "DRIFT $FORK forked at $FORKED_AT, upstream now $NOW"
done
Run it quarterly. Every line it prints is a decision: take the upstream change into the fork, or record that you have looked and chosen not to. A definition that returns nothing here is a definition with no provenance, which is the first thing to fix.
Step 9. Roll back
az policy assignment delete --name "NFK-Asgn-ForkTest" --scope "$SCOPE"
az policy assignment delete --name "NFK-Asgn-OrigTest" --scope "$SCOPE"
az policy set-definition delete --name "NFK-Init-Standard" --management-group "$MG_NAME"
az policy definition delete --name "NFK-Def-Fork-$SRC" --management-group "$MG_NAME"
Reverse dependency order, as always: the initiative will not delete while an assignment references it, and the definition will not delete while the initiative contains it.
From here
Forking answers the case where Microsoft nearly had it. The next article is the harder question: what to do when nothing in the catalogue addresses the thing at all, and how to tell that apart from not having looked properly.
Azure Policy
‹ Previous: [AP 6] Customising What Microsoft Ships, and What the Fork Costs
Next: [AP 7] Writing Your Own, and When Not To ›




