Home› Blog› Week 6 — I Gave Myself Owner, Then Couldn't Delete…
Azure Weekly Lab Azure Terraform

Week 6 — I Gave Myself Owner, Then Couldn't Delete My Own Storage Account

Every guardrail this lab has built so far can be removed by the person it is meant to stop. This week builds one that cannot — and proves it by holding Owner and User Access Administrator, being told by Azure that I have permission to delete, and being refused anyway.

Deployment Stacks Deny Assignments Management Groups ARM Templates Azure CLI Terraform
Azure Platform Engineering Lab · Week 6 of 52

Lab Overview

Here is an uncomfortable thing about most Azure guardrails: the person they are designed to stop can usually remove them.

A resource lock is itself a resource. An Owner deletes the lock, then deletes the thing the lock was protecting. Two commands.

An Azure Policy with a deny effect is an assignment. An Owner with rights at that scope unassigns it, does what they wanted, and re-assigns it if they are tidy. Also two commands.

In both cases the control and the target live at the same scope, under the same identity. So the guardrail is exactly as strong as the discipline of the person standing next to it at 2am during an incident — which is to say, it is not a guardrail. It is a reminder.

A deployment stack is different in kind, not in degree. The stack owns a set of resources and holds a deny assignment over them. A deny assignment is not a role assignment and cannot be out-voted by one. No amount of Owner gets around it — you have to go to where the stack lives and remove the stack.

So the claim for the week, stated so that it can fail: an Owner with full rights over the subscription cannot delete a resource a deployment stack is protecting, and can delete an otherwise identical resource sitting beside it.

DifficultyIntermediate — two resources, but the control lives at management group scope and is created outside Terraform
Time to completeUnder an hour. The validation genuinely deletes a storage account, so allow for a redeploy if you re-run it
DeploymentTerraform (azurerm) for the control pair; az stack mg create for the protected half — deployment stacks are an ARM object with no Terraform resource
Measured cost$0.00 — two empty storage accounts, deleted the same day
Full lab guideLAB.md — prerequisites, variables, commands, expected outputs, validation and cleanup

Cloud Services and Concepts

ServicePurpose hereWhat it is
Deployment stack Owns the resources it deployed An Azure resource that tracks what it created and can place a deny assignment over it.
denySettingsMode What the stack refuses denyWriteAndDelete blocks both, and it is enforced by Azure rather than by RBAC on the caller.
Deny assignment Why holding Owner is not enough A deny assignment outranks a role assignment. The refusal reads DenyAssignmentAuthorizationFailed and states that the caller has permission to perform the action — and still cannot.
Management group scope Why the stack lives above the subscription A Contributor inside the subscription cannot reach a stack at the management group, so it cannot lift its own restriction.
action-on-unmanage What teardown does, and its default The default is detach: omit the flag and every resource keeps running, untracked and unowned, while the command reports success.

Architecture and Workflow

Architecture diagram. A deployment stack created at the mg-lz-dev management group carries denySettingsMode denyWriteAndDelete, and it lives ABOVE the subscription on purpose so a Contributor inside the subscription cannot reach it to lift its own restriction. The stack places a deny assignment on one of two otherwise identical storage accounts in the lab subscription. The control storage account, built by Terraform with no protection, is deleted successfully. The protected one, deployed from ARM JSON and owned by the stack, refuses the same delete with DenyAssignmentAuthorizationFailed, attempted by the same signed-in Owner.
The whole week on one page, and it is a controlled experiment. Two identical storage accounts, one caller holding Owner, two delete attempts. The only variable is which account a deployment stack owns.

Two storage accounts. Same subscription, same region, same SKU, same TLS floor, same public access setting, same identity doing the deleting. One variable differs: a deployment stack is holding one of them.

Prerequisites

  • A management group you can create a deployment stack in. This is the unusual prerequisite and the one most likely to stop you: the protected half is an az stack mg create at management group scope, not a subscription. You need rights at the management group, not just inside the subscription under it.
  • Owner on the subscription — and here that is a test requirement rather than a deployment one. Check 5 asserts that the deletion was refused for a caller holding Owner and User Access Administrator. Run it as a limited principal and the result proves nothing, because a limited principal would have been refused anyway.
  • Azure CLI 2.60+ with the az stack mg commands. Note that create accepts --yes and validate does not — see Troubleshooting.
  • Terraform ≥ 1.10 with azurerm 5.x, which builds only the control pair. There is no Terraform resource for a deployment stack.
  • Two resource providers: Microsoft.Resources and Microsoft.Storage.
  • Builds on week 01 for the management group hierarchy the stack is scoped to (mg-lz-dev here). Any management group works.

Every variable and the exact stack command are in the lab guide, which is the reproducible half of this write-up.

Implementation

0. How any of this gets deployed

This week is the first one in the lab that is not pure Terraform, and the reason is worth being blunt about.

Deployment stacks have no Terraform resource. I checked the azurerm provider's own documentation tree on 20 September 2026: there are *_template_deployment resources, and there is nothing for Microsoft.Resources/deploymentStacks. Microsoft's own guidance says the same thing — "To create and update a deployment stack, use the Azure CLI, Azure PowerShell, or the Azure portal with Bicep files." Terraform is not on that list, so this week does not pretend otherwise.

So the week splits along that line, and the split turns out to be useful:

the control
  Terraform, into sub-lab-dev.
  An ordinary storage account, no protection.

the protected
  an ARM template, deployed as a deployment
  stack created at mg-lz-dev scope, into
  sub-lab-dev, with deny settings on.

Both halves run from one script, in one command, so nothing here is typed into a terminal by hand and nothing is clicked in the portal:

./scripts/deploy.sh      # the control in Terraform, then the stack via the CLI
./scripts/validate.sh    # tries BOTH deletes, reports which one Azure refused
./scripts/cleanup.sh     # deletes the stack, then destroys the control

deploy.sh also registers Microsoft.Storage on the target subscription before either half runs. Provider registration is subscription-wide state and a real precondition, not a formality — a stack that hits an unregistered provider reports a template error, which sends you looking in the wrong file.

The Terraform half is the usual setup — state in HCP Terraform, the apply running locally against whatever az login session is on the machine:

The cloud {} block and provider pin are in versions.tf.

And the control itself is deliberately boring:

The control is an ordinary storage account — main.tf — deliberately matched to the stack template's SKU and kind so the only difference between the two accounts is which one a stack owns.

The control is not decoration and it is not padding. Without it, "the delete failed" has several possible explanations — a typo in the account name, a transient Azure error, a role I thought I had and didn't. With it, there is exactly one difference between the two attempts.

The workspace, read back from the HCP Terraform API rather than screenshotted:

GET /api/v2/organizations/Katta/workspaces/azure-week-06-dev

name=azure-week-06-dev  execution-mode=local  resource-count=0  terraform=1.16.3

execution-mode=local confirms HCP is the state backend and not the runner. resource-count=0 is the teardown: the control was destroyed, and HCP's state says so.

1. The protected half — an ARM template the stack will own

The template is subscription-scoped, and it creates its own resource group:

The protected half is an ARM template — stack/protected.json — because a deployment stack takes a template, and there is no Terraform resource for a stack.

That the stack makes its own resource group rather than reusing one Terraform created is the important detail. A resource with two owners has an ambiguous teardown — and an ambiguous teardown is precisely the failure this whole week is about avoiding. The stack owns everything it protects, right down to the container it lives in.

2. Creating the stack — and why the scope is the control

The stack is created at mg-lz-dev, a management group, and deploys into sub-lab-dev:

az stack mg create \
  --name stack-wk06-protected \
  --management-group-id mg-lz-dev \
  --location southcentralus \
  --deployment-subscription "$SUB" \
  --template-file stack/protected.json \
  --parameters storageAccountName="$PROTECTED_SA" \
  --action-on-unmanage deleteAll \
  --deny-settings-mode denyWriteAndDelete \
  --yes

A deny assignment lives where the stack lives. Put the stack in the subscription, and an Owner of that subscription can reach the stack, delete the stack, and take the deny assignment with it — you are back to two commands. Put the stack a level up, and lifting the restriction requires rights at the management group: a different grant, held by different people, reviewed on a different cadence.

Two flags in that command carry real weight:

  • --deny-settings-mode denyWriteAndDelete, not denyDelete. denyDelete leaves a wide door open: an account whose network rules can still be rewritten, whose public access can be re-enabled and whose TLS floor can be dropped is not meaningfully protected. It just cannot be removed while being quietly ruined.
  • --action-on-unmanage deleteAll — which matters at teardown, and is the subject of the Cleanup section below.

The deploy script runs az stack mg validate first, because a stack that fails halfway still exists, and a half-failed stack with deny settings on is harder to remove than one that never started.

3. What ended up deployed

Azure portal resource group rg-wk06-protected-dev-scus-001 containing the storage account created by the deployment stack
The treatment. Identical account, same subscription, same caller — but created and owned by the deployment stack.

And the control — built by Terraform, not by the stack — which looks identical because it is meant to:

Azure portal resource group rg-wk06-control-dev-scus-001 containing the unprotected storage account created by Terraform
The control. An ordinary Terraform-built storage account with nothing protecting it. Check 3 deletes this one successfully — which is what makes the refusal in check 4 evidence rather than coincidence.

The difference is not in either resource group. It is here, under the subscription's Access control (IAM) → Deny assignments:

Azure portal deny assignments blade showing a deny assignment created by Deployment stack, with Denied set to All principals and Excluded principals set to No
What actually does the refusing: a deny assignment Azure created on the stack's behalf, applying to All principals with no exclusions. Deny assignments beat role assignments, which is why Owner loses.

Note what that blade says: created by Deployment stack, Denied: All principals, Excluded principals: No. Nobody made it by hand, and nobody can edit it there. The stack is the only thing that changes it.

Validation and Evidence

A successful deploy proves a stack exists with deny settings on. It proves nothing about whether the deny actually stops anybody — so check 3 genuinely deletes a storage account. A test that only ever attempts the delete it expects to fail proves nothing about whether deletes work at all.

TestExpectedActualStatus
The stack exists and reports managed resourcesa stack at mg-lz-dev, denyWriteAndDelete2 managed resources, mode denyWriteAndDeletePASS
Managed resources carry a deny statusevery managed resource reports the deny2 of 2, status=managed denyStatus=denyWriteAndDeletePASS
Deleting the control accountsucceeds — the control for the whole experimentdeletedPASS
Deleting the protected accountrefused by the deny assignmentREFUSED — DenyAssignmentAuthorizationFailedPASS
Who the refusal applied toa caller holding OwnerOwner + User Access Administrator, still refusedPASS

The third row is why the fourth means anything. Both accounts are identical and in the same subscription. The only difference is which one a stack owns.

What the five checks print

What the five checks print:

3. Deleting the CONTROL storage account succeeds
   RESULT: deleted - an ordinary account, deletable by an Owner

4. Deleting the PROTECTED storage account is refused
   RESULT: REFUSED by the stack's deny assignment
     DenyAssignmentAuthorizationFailed) The client '<signed-in user>' with
     object id '<oid>' has permission to perform action
     'Microsoft.Storage/storageAccounts/delete' ...

5. The refusal applied to an Owner, not a limited principal
   caller roles on the subscription: Owner User Access Administrator
   RESULT: the caller holds Owner and was still refused

Read check 4's error text again, slowly. Azure is not saying I lack permission. It is saying I have permission — has permission to perform action 'Microsoft.Storage/storageAccounts/delete' — and refusing anyway. The deny assignment is evaluated after the role grant, and overrides it. That one sentence is the entire week.

Check 5 exists because the result is worthless without it. "A delete was refused" is unremarkable if the caller was a Reader. It is the whole point if the caller holds Owner and User Access Administrator — the role that exists specifically to let you grant yourself things.

Security and Cost

Security

  • The control outranks the subscription's Owner, which is the first time in this lab that has been true. Every previous guardrail was removable by the identity it constrained.
  • The stack sits above the subscription it protects. Removing the restriction requires rights at mg-lz-dev, not at sub-lab-dev.
  • denyWriteAndDelete, not denyDelete — a resource that cannot be deleted but can be reconfigured is not protected.
  • The stack owns its own resource group, so there is exactly one authority over every protected resource and no ambiguity about who removes what.
  • The deny assignment is not hand-made and not hand-editable. It exists because the stack exists, and it is inventoried in the portal under the subscription's IAM blade where an auditor will look for it.

Cost

Two empty Standard_LRS storage accounts. Storage bills for capacity and transactions; these hold nothing and serve nothing. The stack, the deny assignment and the resource groups are free.

What this week cost: $0.00. After week 5's firewall ran 47 minutes longer than planned, a week with no hourly meter in it was a deliberate choice.

Troubleshooting and Lessons

A marker file two scripts disagreed about.

  • Problem: validation reported "Could not resolve both storage accounts. Run deploy.sh first." against a deploy that had succeeded ninety seconds earlier.
  • Root cause: deploy.sh wrote the generated account name to .protected-storage-account at the week root; validate.sh read it from scripts/.
  • Resolution: resolve the marker path from the script's own location in both scripts, rather than from the working directory.
  • Lesson learned: the error pointed confidently at the wrong cause. "Run deploy.sh first" is a guess the script makes when a file is absent, and a guess in an error message is read as a diagnosis.

Sibling CLI commands that do not take the same arguments.

  • Problem: az stack mg validate failed with unrecognized arguments: --yes.
  • Root cause: az stack mg create accepts --yes; validate does not. They read like one command with a different verb and are not.
  • Resolution: drop --yes from the validate call.
  • Lesson learned: a shared command prefix is not a shared argument list. Check each verb's own help.

ARM rejects the JSON comment convention.

  • Problem: InvalidRequestContent: Could not find member '//' on object of type 'Template'.
  • Root cause: ARM templates have no comments, and the common top-level "//" key is not a schema member at template root.
  • Resolution: put commentary in metadata, which is a real member.
  • Lesson learned: a convention that works in one JSON dialect is not portable to a schema-validated one.

Management-group stacks have no portal UI

I went looking for a portal view of the stack itself, to screenshot it. There isn't one:

This feature isn't implemented at this time.

Stacks at subscription and resource group scope render in the portal. The management group blade does not, as of 25 September 2026. This is worth knowing before you standardise on management-group-scoped stacks: you will operate them entirely from the CLI, and your change-review evidence has to come from az stack mg show and the deny assignments blade, because there is no stack page to link to.

Cleanup and Key Takeaways

Cleanup — The Hard Part

The protected resources cannot be deleted directly. That is the entire point of the week, and it means teardown is not the usual terraform destroy. You delete the stack, which drops the deny assignment and then acts on what it managed:

az stack mg delete --name stack-wk06-protected \
  --management-group-id mg-lz-dev \
  --action-on-unmanage deleteAll --yes

deleteAll is not optional. The documented default is detach: deployment stacks detach and do not delete unmanaged resources. Omit the flag and the stack goes away, the deny assignment goes with it, and every resource it was protecting keeps running — unowned, untracked, still billing — while the command exits 0 and tells you nothing is wrong. On this week that costs nothing. On a week with a firewall in it, that is the bill that arrives a month later.

There are three documented ways the delete itself can fail, and the cleanup script handles all three rather than assuming success:

  • Permission. Updating or deleting a stack whose deny setting is anything other than none needs rights at the stack's scope. Deployment Stack Contributor cannot do it; Owner at the management group can.
  • Stack out of sync. Azure refuses the delete rather than risk removing something unexpected. There is a bypass flag. I deliberately don't use it — it deletes whatever the managed list happens to contain, which is exactly the situation the error exists to stop.
  • A resource group containing resources the stack does not manage is not removed, and neither are those resources.

So cleanup.sh checks the delete's exit code explicitly, and then verifies against Azure that both resource groups are actually gone before reporting clean. That is a direct correction from last week, where a teardown script reported success while an Azure Firewall carried on billing.

References

Key takeaways

  • Deny assignments beat role assignments. Azure's own error message says the caller has permission to perform the action, and refuses. If you have only ever reasoned about RBAC as "what roles do I hold", this is the other half of the model.
  • A guardrail at the same scope as its target is not a guardrail. Locks and policy assignments are removable by the Owner they constrain. The scope you create the stack at is the strength of the control.
  • Always build the control. A test that only attempts the delete it expects to fail cannot distinguish "protected" from "broken". The unprotected twin costs nothing and turns an anecdote into evidence.
  • Choose denyWriteAndDelete. Undeletable but reconfigurable is not protected — it is just harder to clean up afterwards.
  • The teardown default is detach, and it is silent. A teardown that leaves everything running while exiting 0 is worse than one that fails loudly, because nobody goes looking.
  • Not everything is Terraform, and that's fine. There is no azurerm resource for deployment stacks. Reaching for the CLI where the provider genuinely doesn't reach is honest; pretending otherwise produces a worse design.

Comments

How was your experience?
Your feedback helps improve this site.
PoorExcellent