Capstone: build a private, keyless, least-privilege store for a team's monthly reports

capstone · 75 min · Skills measured 2.4

Task

A small finance team needs somewhere in Azure to keep its monthly report files, and nobody outside the team's own network should be able to reach them. Build it from what lessons 5 to 11 taught: one tagged resource group in a known region, whose paired region you read first; a virtual network split into an app subnet and a data subnet, with an NSG admitting only HTTPS from the app subnet into the data subnet; a storage account that allows no anonymous access and accepts no account keys; a managed identity standing in for the team's reporting app, given read-only data access; yourself given only the data role you need to upload; a private endpoint that gives the account an address inside the data subnet; and, last, public network access switched off. You prove each control works by watching a request fail, read Defender for Cloud's free posture view, map every control to its Zero Trust principle and defense-in-depth layer, and delete all of it.

Steps

  1. Choose the region and read its pair, then create the group with tags the finance team can report on:
az account list-locations --query '[?name==`"eastus"`].{region:name, pair:metadata.pairedRegion[0].name}' -o table
az group create --name az900-cap2-rg --location eastus --tags team=finance-reports env=lab

The pair for eastus is westus. Write it down: step 3 decides whether any copy of the data goes there (lessons 5 and 9). 2. Build the network. The data subnet gets an NSG that admits HTTPS from the app subnet only and refuses other traffic from inside the network, and the subnet is set so that its NSG also applies to private endpoints:

az network vnet create --resource-group az900-cap2-rg --name az900-cap2-vnet --location eastus --address-prefixes 10.20.0.0/16 --subnet-name app --subnet-prefixes 10.20.1.0/24
az network nsg create --resource-group az900-cap2-rg --name az900-cap2-data-nsg --location eastus
az network nsg rule create --resource-group az900-cap2-rg --nsg-name az900-cap2-data-nsg --name allow-https-from-app --priority 100 --direction Inbound --access Allow --protocol Tcp --source-address-prefixes 10.20.1.0/24 --destination-port-ranges 443
az network nsg rule create --resource-group az900-cap2-rg --nsg-name az900-cap2-data-nsg --name deny-other-vnet --priority 4000 --direction Inbound --access Deny --protocol '*' --source-address-prefixes VirtualNetwork --destination-port-ranges '*'
az network vnet subnet create --resource-group az900-cap2-rg --vnet-name az900-cap2-vnet --name data --address-prefixes 10.20.2.0/24 --network-security-group az900-cap2-data-nsg --private-endpoint-network-policies Enabled
  1. Create the storage account: locally redundant, so no copy leaves eastus; no anonymous access; no account keys, so only Microsoft Entra identities with a data role can read or write; and a private container made through Azure Resource Manager, which needs no data role:
az storage account check-name --name az900cap261834 --query nameAvailable -o json
az storage account create --name az900cap261834 --resource-group az900-cap2-rg --location eastus --sku Standard_LRS --kind StorageV2 --allow-blob-public-access false --allow-shared-key-access false --min-tls-version TLS1_2
az storage container-rm create --storage-account az900cap261834 --resource-group az900-cap2-rg --name reports

The first command must print true; if not, choose other digits. 4. Give two identities exactly the data access each needs, at the storage account's scope and no wider. The reporting app's managed identity gets read-only data access; you get upload rights. Being the subscription's Owner does not, on its own, let you read blob data: Owner manages resources, and data access is a separate role (verify explicitly, use least privilege):

SA_ID=$(az storage account show --name az900cap261834 --resource-group az900-cap2-rg --query id -o tsv)
ME=$(az ad signed-in-user show --query id -o tsv)
az identity create --name az900-cap2-reader-id --resource-group az900-cap2-rg --location eastus
APP_ID=$(az identity show --name az900-cap2-reader-id --resource-group az900-cap2-rg --query principalId -o tsv)
az role assignment create --assignee-object-id "$APP_ID" --assignee-principal-type ServicePrincipal --role "Storage Blob Data Reader" --scope "$SA_ID"
az role assignment create --assignee-object-id "$ME" --assignee-principal-type User --role "Storage Blob Data Contributor" --scope "$SA_ID"

Role assignments can take a few minutes to take effect. Read the next step while you wait. 5. Upload a made-up report with your Microsoft Entra sign-in, list it, and then watch an account-key request fail:

echo "month,total" > report-2026-09.csv
echo "2026-09,1200" >> report-2026-09.csv
az storage blob upload --account-name az900cap261834 --container-name reports --name report-2026-09.csv --file report-2026-09.csv --auth-mode login
az storage blob list --account-name az900cap261834 --container-name reports --auth-mode login --query '[].name' -o tsv
az storage blob list --account-name az900cap261834 --container-name reports --auth-mode key --query '[].name' -o tsv

If the upload reports an authorisation or permission mismatch error, your role from step 4 has not taken effect yet: wait two minutes and run it again. The first list prints report-2026-09.csv. The last command fails with an error saying key-based authentication is not permitted on this storage account: a key copied into a script or an email would be useless, which is assume breach in practice. 6. Give the account a private address in the data subnet with a private endpoint, and read the connection's state and the address it was given:

az network private-endpoint create --name az900-cap2-pe --resource-group az900-cap2-rg --location eastus --vnet-name az900-cap2-vnet --subnet data --private-connection-resource-id "$SA_ID" --group-id blob --connection-name az900-cap2-pe-conn
az network private-endpoint show --name az900-cap2-pe --resource-group az900-cap2-rg --query '{status:privateLinkServiceConnections[0].privateLinkServiceConnectionState.status, address:customDnsConfigs[0].ipAddresses[0]}' -o table

status reads Approved, because you own both ends, and address is inside 10.20.2.0/24. Azure also creates a network interface for the endpoint in the group. A full design would add a private DNS zone so names inside the network resolve to that address; lesson 8 describes it, and this lab leaves it out. 7. Close the public door, then try your own request again from Cloud Shell, which reaches the account over its public endpoint:

az storage account update --name az900cap261834 --resource-group az900-cap2-rg --public-network-access Disabled
az storage blob list --account-name az900cap261834 --container-name reports --auth-mode login --query '[].name' -o tsv

The list now fails with an error saying the request is not authorised to perform this operation. It can take a minute to apply; run it again if it still succeeds. Your role has not changed: identity and network are separate layers, and both must allow a request. From now on only resources inside the virtual network, through the private endpoint, could reach the data. 8. Read the posture view and map what you built. In the portal, open Microsoft Defender for Cloud, read the Secure score and open Recommendations; new resources can take hours to be assessed, so an empty list is normal. Change nothing on the Environment settings pages. In Cloud Shell, confirm no paid plan is on:

az security pricing list --query 'length(value[?pricingTier==`"Standard"`])' -o tsv

It prints 0. Then, for each control, name its defense-in-depth layer and its Zero Trust principle, and check the Notes: (a) the two data-role assignments at the account's scope; (b) account keys refused; (c) the data subnet's NSG; (d) the private endpoint plus public network access disabled; (e) encryption at rest in the storage account; (f) locally redundant storage keeping the data in one region.

Verify

Read back each control from the management plane, which still answers after public network access is off.

az storage account show --name az900cap261834 --resource-group az900-cap2-rg --query allowSharedKeyAccess -o json

Expected output: false.

az storage account show --name az900cap261834 --resource-group az900-cap2-rg --query publicNetworkAccess -o tsv

Expected output: Disabled.

az network private-endpoint show --name az900-cap2-pe --resource-group az900-cap2-rg --query 'privateLinkServiceConnections[0].privateLinkServiceConnectionState.status' -o tsv

Expected output: Approved.

az network nsg show --name az900-cap2-data-nsg --resource-group az900-cap2-rg --query 'length(subnets)' -o tsv

Expected output: 1.

az role assignment list --all --query 'sort([?contains(scope, `"az900cap261834"`)].roleDefinitionName)' -o tsv

Expected output: exactly two lines, Storage Blob Data Contributor then Storage Blob Data Reader.

Cost and clean-up

Most of this build is not charged for existing: the resource group, tags, virtual network, subnets, NSG, managed identity and role assignments. The storage account is charged for the few bytes it holds and the operations on them. The private endpoint is charged for every hour it exists and for data through it, so it is the part that makes this lab cost money; for the length of the lab the amount is small, but charges are possible, so delete everything the same day. Defender for Cloud is used only on its free tier.

Created by this lab: the resource group az900-cap2-rg, tagged; the virtual network az900-cap2-vnet with the subnets app and data; the network security group az900-cap2-data-nsg with the rules allow-https-from-app and deny-other-vnet; the storage account az900cap261834, its container reports and the blob report-2026-09.csv; the user-assigned managed identity az900-cap2-reader-id; two role assignments on az900cap261834: Storage Blob Data Reader for az900-cap2-reader-id, and Storage Blob Data Contributor for you; the private endpoint az900-cap2-pe, its connection az900-cap2-pe-conn and the network interface Azure made for it; the file report-2026-09.csv in Cloud Shell.

  1. Remove both role assignments first. Deleting each on its own first means nothing that grants access depends on the group delete. If your Cloud Shell session restarted, set SA_ID again:
SA_ID=$(az storage account show --name az900cap261834 --resource-group az900-cap2-rg --query id -o tsv)
az role assignment delete --scope "$SA_ID" --role "Storage Blob Data Reader"
az role assignment delete --scope "$SA_ID" --role "Storage Blob Data Contributor"

That removes the Reader assignment for az900-cap2-reader-id and your own Contributor assignment on az900cap261834. 2. Delete the resource group. That deletes the private endpoint az900-cap2-pe with its connection az900-cap2-pe-conn and its network interface, the storage account az900cap261834 with the container reports and the blob report-2026-09.csv, the identity az900-cap2-reader-id, the network az900-cap2-vnet with the subnets app and data, and the NSG az900-cap2-data-nsg with its rules allow-https-from-app and deny-other-vnet:

az group delete --name az900-cap2-rg --yes

It takes several minutes and returns when it has finished. 3. Remove the local copy of report-2026-09.csv (it also vanishes when the ephemeral session ends):

rm -f report-2026-09.csv

Check it is gone

az group exists --name az900-cap2-rg

Expected output: false.

az role assignment list --all --query 'length([?contains(scope, `"az900cap261834"`)])' -o tsv

Expected output: 0.

Notes

Answers to step 8, as layer then principle: (a) identity and access, use least privilege access; (b) identity and access, verify explicitly (every request proves who it is, and a stolen key proves nothing); (c) network, assume breach (segmentation limits the blast radius); (d) network and perimeter, assume breach (the data is no longer at an internet-facing address); (e) data, assume breach; (f) data, a residency choice rather than a Zero Trust principle, bought at the price of no copy outside the region (GRS would add one in the paired region you read in step 1).

Why the commands are in this order: the container is made through Azure Resource Manager in step 3 because keys are already off and your data role does not exist yet; and public network access is closed last because Cloud Shell, which is outside your virtual network, would otherwise be locked out before the upload.

If az security pricing list prints a plain list rather than an object with a value list in your CLI version, run az security pricing list --query 'length([?pricingTier=="Standard"])' -o tsv instead.

When you create the first virtual network in a region, Azure may also create a resource group named NetworkWatcherRG holding a Network Watcher for that region. It is a shared, subscription-wide monitoring setting rather than part of this lab, other networks you make later use it too, and simply having it enabled is not charged (its optional features, such as flow logs, are charged when you turn them on). This lab leaves it in place.

This is an independent study companion for the Microsoft Azure Fundamentals (AZ-900) exam and is not produced by or endorsed by Microsoft. Microsoft, Azure and Microsoft Entra are trademarks of the Microsoft group of companies.

This is an independent study companion for Microsoft Azure Fundamentals AZ-900 and is not produced by or endorsed by CompTIA.