Harden Azure Blob Access
Use Bicep and GitLab to enforce identity-only Azure Blob access.
Introduction
30 Second Summary
Locks only work when every entrance follows the same rule. A forgotten spare key can undo carefully planned permissions.
In this project, you will harden an existing Azure Blob Storage account so account keys can no longer read its documents. You will use Bicep in a GitLab CI/CD pipeline to give one managed identity read-only access to the documents container.
What You'll Build
Picture showing someone a failed key-based Blob read beside an Azure RBAC result that limits the approved identity to the documents container.
By the end of this project, you'll have:
- Identity-only Blob access that rejects the original Shared Key read after deployment.
- A least-privilege identity whose query shows Storage Blob Data Reader only at the documents container scope.
- A GitLab deployment flow where a feature branch previews the IAM change before main applies it.
- Secret Mission: A third pipeline stage that fails when Shared Key access returns or the required container-scoped role assignment disappears.
Are there any prerequisites?
This project starts from the completed Automate Blob Cold Storage with Bicep project. Your existing pipeline identity needs permission to create managed identities plus role assignments.
Before We Start
Before any hands-on work, commit to reusing the existing baseline environment without creating another Azure account, storage account, or human user. Shared Key authorization gives way to a user-assigned managed identity with read-only Azure RBAC access scoped only to the existing documents container.
Reconnect to the Baseline Environment
Identity changes to Azure Blob Storage can lock out clients that still depend on account keys. This step confirms the exact baseline before you change its authorization.
You will sync the existing Git repository before checking its current branch. The pinned deployment toolchain then proves this workspace is ready for an IAM preview.
In this step, get ready to:
- Synchronize the existing baseline repository.
- Verify the pinned Azure deployment toolchain.
- Confirm the GitLab deployment boundary.
Synchronize the existing workspace
The baseline repository already contains the storage resources you will harden. A clean main branch gives every upcoming IAM change a known starting point.
- Press Cmd+Space to open macOS search.
- Type Terminal into the search field.
- Press Enter to open macOS Terminal.
- Synchronize the baseline branch by running these commands:
cd ~/Desktop/alz-governance
git switch main
git pull origin main
git status
What do these commands do?
- The first command returns you to the existing alz-governance workspace on your Desktop.
- The second command selects the baseline main branch.
- The third command downloads the latest committed baseline from GitLab.
- The final command reports the branch state without changing any files.
You should see that main is current. Git should also report that the workspace has no uncommitted changes.
Repository Not Clean?
Keep any work you recognize by committing it through your normal workflow. Remove only changes you are certain you no longer need.
If the pull cannot reach the remote repository, confirm that your existing GitLab access still works. Help me diagnose why my baseline Git repository will not synchronize.
- Open the synchronized workspace in Visual Studio Code by running this command:
code .
What Does This Command Do?
The code . command opens the current alz-governance folder in Visual Studio Code. The terminal remains inside the same repository for later checks.
Visual Studio Code should open with the existing repository files visible in the left file sidebar.
Visual Studio Code Did Not Open?
Open Visual Studio Code through macOS search if its terminal launcher is unavailable. Use the app's folder-opening control to select ~/Desktop/alz-governance.
Need help connecting the terminal launcher? Help me open my existing alz-governance folder in Visual Studio Code.
Verify the pinned Azure toolchain
The Azure CLI controls which subscription receives your commands. The Bicep CLI compiles the infrastructure templates used by the pipeline.
- Check the installed Azure CLI version by running this command:
az version
What Does This Check?
This command reports the locally installed Azure CLI components. Use the Azure CLI version in the output to choose the matching path below.
✔️ I see the required Azure CLI version
Your Azure CLI reports 2.91.0. That matches the project pin.
ⓧ I see another Azure CLI version
The project uses Azure CLI 2.91.0 so every local check matches the pipeline environment.
- Update Azure CLI through Homebrew by running this command:
brew update && brew upgrade azure-cli
What Does This Command Do?
Homebrew refreshes its package information before upgrading the existing Azure CLI installation. The command prints progress while it updates the package.
- Check the upgraded version by running this command:
az version
What Should I See?
The reported Azure CLI version should now be 2.91.0.
Azure CLI Upgrade Failed?
Confirm that Homebrew is available on your Mac. A network or package-manager problem can stop the upgrade before Azure CLI changes.
Share the non-sensitive command output for targeted help. Help me troubleshoot my Azure CLI upgrade on macOS.
ⓧ The Azure CLI command is unavailable
Azure CLI needs to be installed before this repository can inspect the subscription.
- Install Azure CLI through Homebrew by running this command:
brew update && brew install azure-cli
What Does This Command Do?
Homebrew refreshes its package information before installing Azure CLI. The installation makes the az command available in Terminal.
- Confirm the installation by running this command:
az version
What Should I See?
The command should report Azure CLI 2.91.0.
Azure CLI Installation Failed?
Confirm that Homebrew is installed before retrying the Azure CLI installation. Keep the full package-manager output because its last lines usually identify the blocked dependency.
Need a second pair of eyes? Help me troubleshoot my Azure CLI installation on macOS.
- Check the active Bicep CLI version by running this command:
az bicep version
What Does This Check?
Azure CLI reports the Bicep CLI version it uses for template builds. Choose the path that matches the output.
✔️ I see the required Bicep version
Bicep reports 0.47.16. Your local compiler matches the project pin.
ⓧ I see another Bicep version
Install the project pin so local builds use Bicep CLI 0.47.16.
- Install the pinned Bicep CLI release by running this command:
az bicep install --version v0.47.16
What Does This Command Do?
Azure CLI installs the specified Bicep release for subsequent template commands. The explicit version keeps local builds aligned with the pipeline.
- Confirm the active Bicep version by running this command:
az bicep version
What Should I See?
Bicep should now report 0.47.16.
Bicep Version Did Not Change?
Close Terminal after the installation completes. Open it again before repeating the version check.
If the old version remains active, share the non-sensitive version output. Help me activate Bicep CLI 0.47.16 through Azure CLI.
ⓧ The Bicep command is unavailable
Azure CLI can download the pinned Bicep release directly.
- Install the pinned Bicep CLI release by running this command:
az bicep install --version v0.47.16
What Does This Command Do?
Azure CLI downloads Bicep CLI 0.47.16 for local template builds. No separate Bicep package manager is needed.
- Confirm the installation by running this command:
az bicep version
What Should I See?
Bicep should report 0.47.16.
Bicep Installation Failed?
Confirm that Azure CLI can reach its download source. Keep the exact non-sensitive output from the installation attempt.
Use that output to narrow down the failure. Help me troubleshoot the pinned Bicep CLI installation.
- Display the active Azure subscription by running this command:
az account show --output table
What Does This Check?
This command shows the subscription currently selected by Azure CLI. Every resource check in this project uses that active subscription.
You should see the subscription that contains the baseline document-storage environment.
Wrong Subscription or Sign-In Problem?
Return to the Azure sign-in flow you used for the baseline project. Select the subscription that contains rg-document-lifecycle before continuing.
Avoid sharing tenant IDs or subscription IDs when asking for help. Help me point Azure CLI at my existing baseline subscription.
Confirm the deployment boundary
The existing GitLab project stores the identifiers used by its deployment jobs. The OIDC flow uses those identifiers to request temporary Azure credentials.
- Go to your existing alz-governance project on GitLab.com.
- Select Settings in the left sidebar.
- Select CI/CD.
- Expand Variables.
- Confirm that AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_SUBSCRIPTION_ID are listed.
- Keep every variable value concealed.
Why Keep the Values Concealed?
The variable keys prove that the OIDC pipeline has its Azure identifiers. Their values are unnecessary for this check.
- Select Build in the left sidebar.
- Select Pipelines.
- Confirm that the baseline project has an existing pipeline history.
The pipeline history confirms that the GitLab OIDC flow exists. A later feature-branch preview performs the final permission preflight for managed identities and role assignments.
- Return to macOS Terminal.
- Confirm the resource group plus the baseline subscription deployment by running these commands:
az group show --name rg-document-lifecycle --output table
az deployment sub show --name document-storage-cicd --query "properties.outputs.storageAccountName.value" --output tsv
What Do These Commands Prove?
- The first command confirms that rg-document-lifecycle still exists in the active subscription.
- The second command reads the storage account name from the existing document-storage-cicd deployment output.
You should see the resource group details followed by a storage account name beginning with docstore.
Baseline Resource Missing?
Confirm that the active subscription is the one used by the baseline project. A missing deployment can also mean the baseline project was deleted after completion.
Share resource names without sharing credentials. Help me locate my existing Azure baseline resources.
- Switch back to Visual Studio Code.
- Expand the storage folder in the left file sidebar.
- Confirm that storage/main.bicep is present.
- Confirm that storage/storage.bicep is present.
Before the final check, what do you expect the repository status, active subscription, and Bicep version to show?
- Run the combined baseline verification in Terminal:
git status && az account show --output table && az bicep version
What Does the Final Check Cover?
- Git confirms that the local repository remains clean on main.
- Azure CLI displays the subscription that contains the baseline resources.
- Bicep confirms that the pinned compiler is active.
You should see a clean main branch. The subscription table should identify the intended Azure subscription.
Bicep should report 0.47.16. That is your safe starting line for the IAM changes ahead.
Your baseline is reconnected. Next, you will test the current Shared Key path against the existing sample blob.
Prove the Shared Key Bypass
Your baseline Azure CLI session now points to the intended Azure subscription. The existing document store is ready for an authorization test.
A storage account key creates a Shared Key authorization path into Azure Blob Storage. Azure RBAC evaluates identity-based access separately.
In this step, get ready to:
- Load a temporary account key without displaying its value.
- Read sample-policy.txt through Shared Key authorization.
- Remove the temporary account key from the shell.
Load the temporary account key
The existing resource group contains the storage account from the baseline project. Shell command substitution can capture its name without changing any Azure resources.
Account keys grant broad data access. The temporary value stays inside the current shell without being printed.
- Load both authorization inputs by running these commands:
STORAGE_ACCOUNT_NAME=$(az storage account list --resource-group rg-document-lifecycle --query "[0].name" --output tsv)
STORAGE_ACCOUNT_KEY=$(az storage account keys list --account-name "$STORAGE_ACCOUNT_NAME" --resource-group rg-document-lifecycle --query "[0].value" --output tsv)
What do these commands load?
- The first command stores the existing account name in STORAGE_ACCOUNT_NAME.
- The second command stores the first account key in STORAGE_ACCOUNT_KEY.
- Command substitution captures each result without displaying the live key.
- Confirm that the terminal returns to its prompt without displaying any key value.
Authorization inputs not loading?
- Confirm that Azure CLI still points to the subscription containing rg-document-lifecycle.
- Check that your signed-in identity can list keys for the existing storage account.
- Keep the key private while diagnosing the permission issue.
Help me diagnose why the storage account name or key was not loaded.
Prove the Shared Key bypass
The next request supplies the captured account key to the existing blob endpoint. Its result reveals whether identity-scoped Azure RBAC controls this authorization path.
Before you run the request, do you expect Azure RBAC to stop a valid account key?
- Request the existing blob name through Shared Key by running this command:
az storage blob show --account-name "$STORAGE_ACCOUNT_NAME" --account-key "$STORAGE_ACCOUNT_KEY" --container-name documents --name sample-policy.txt --auth-mode key --query name --output tsv
What does this result prove?
- You should see sample-policy.txt in the terminal.
- The --auth-mode key option authorizes the request with the supplied account key.
- The successful response proves that the baseline account accepts Shared Key outside identity-scoped role assignments.
You have exposed the bypass. The valid account key reached the blob without an identity-scoped assignment.
Blob read did not succeed?
- Confirm that STORAGE_ACCOUNT_NAME came from rg-document-lifecycle.
- Confirm that the existing container is named documents.
- Confirm that the existing blob is named sample-policy.txt.
Help me troubleshoot the Shared Key read without exposing my storage account key.
Remove the temporary credential
The authorization test is complete. Clearing the key closes its exposure window while leaving the storage account unchanged.
Before you run the cleanup, what should the second command reveal about the temporary key?
- Remove the temporary key from the current shell by running these commands:
unset STORAGE_ACCOUNT_KEY
printenv STORAGE_ACCOUNT_KEY
What does the empty result mean?
The unset command removes STORAGE_ACCOUNT_KEY from the current shell. It does not rotate the account key in Azure.
You should see no value before the terminal prompt returns. That empty result confirms the temporary credential is no longer present in the shell.
Key value still appearing?
Keep the value private. Repeat the cleanup in the same terminal session that loaded the key.
Help me remove the temporary storage account key safely.
The bypass is proven. Next, you will use Bicep to close the Shared Key path with identity-only access.
Codify Identity-Only Access
The successful key-authenticated read proved that Shared Key can bypass identity-scoped Azure RBAC. Leaving that path active would weaken every container-level role assignment you add.
Now you will encode the authorization boundary in Bicep.
The storage account will require Microsoft Entra ID authorization. A dedicated managed identity will receive read access only at the documents container.
In this step, get ready to:
- Disable Shared Key authorization for the existing storage account.
- Define a read-only managed identity at container scope.
- Compile the templates locally with evidence outputs.
Harden the storage account
The storage account needs two related controls. allowSharedKeyAccess closes the bypass that you proved in the previous step.
The defaultToOAuthAuthentication property makes OAuth the preferred authorization method. Together, these properties move Blob access toward identity-based authorization.
- Switch back to storage/storage.bicep in Visual Studio Code.
- Find the properties block inside storageAccount.
- Update the authorization properties so they match this reference:
allowSharedKeyAccess: false
defaultToOAuthAuthentication: true
What Do These Properties Change?
- The allowSharedKeyAccess value tells Azure Storage to reject requests authorized with an account key.
- The defaultToOAuthAuthentication value records OAuth as the default authentication preference.
- The remaining storage properties preserve the private container from the baseline project.
- Save storage/storage.bicep.
Before you compile, do you expect this local check to change the live storage account?
- Compile the updated templates by running this command in the Terminal from earlier:
az bicep build --file storage/main.bicep
What Does This Check Prove?
The Bicep compiler checks the template structure before any deployment begins. This command only creates the local storage/main.json file.
Your live Azure resources remain unchanged. Deployment happens later through the GitLab pipeline.
The command returns without diagnostics. You will also find the generated storage/main.json file in the repository.
Seeing a Bicep Diagnostic?
- Check that allowSharedKeyAccess is set to the lowercase value false.
- Check that defaultToOAuthAuthentication sits inside the storage account's properties block.
- Check that both property lines end before minimumTlsVersion.
Help me diagnose my Bicep storage-account diagnostic.
Add the identity and role assignment
A user-assigned managed identity gives a future Azure workload a reusable identity without adding a password or access key. Its role assignment controls which Blob data that identity can read.
Why Use a Separate Managed Identity?
The GitLab deployment identity manages Azure resources. id-documents-reader represents the future workload that reads Blob data.
Keeping those responsibilities separate prevents deployment permissions from becoming data-access permissions.
- Find the storageAccountName variable near the top of storage/storage.bicep.
- Add this identity-name variable directly below it:
var dataAccessIdentityName = 'id-documents-reader'
What Does This Variable Hold?
The dataAccessIdentityName variable keeps the managed identity name in one place. The resource declaration uses the value id-documents-reader.
- Find the closing brace of lifecyclePolicy.
- Add the managed identity resource directly below that brace using this reference:
resource dataAccessIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = {
name: dataAccessIdentityName
location: location
properties: {
isolationScope: 'None'
}
}
What Does This Resource Define?
- The resource type creates a user-assigned managed identity.
- The name property reuses dataAccessIdentityName.
- The location property keeps the identity in the resource group's deployment location.
- Save storage/storage.bicep.
- Compile the identity resource by running this command:
az bicep build --file storage/main.bicep
What Does This Build Confirm?
This build confirms that the managed identity resource fits the existing module. The generated template now contains the identity definition without changing Azure.
The command returns without diagnostics. Your local template now includes id-documents-reader.
Identity Resource Not Compiling?
- Check that dataAccessIdentityName is declared before the resource uses it.
- Check that the identity resource sits outside lifecyclePolicy.
- Check the braces around the properties block.
Help me fix my managed identity Bicep resource.
The identity still needs permission to read Blob data. The built-in Storage Blob Data Reader role grants read access without granting write access.
Container scope keeps that permission on documents. This is the smallest useful scope for the project.
- Add the existing role definition directly below dataAccessIdentity using this reference:
resource storageBlobDataReaderRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
scope: subscription()
name: '2a2b9908-6ea1-4ae2-8e65-a410df84e7d1'
}
Why Reference an Existing Role?
The role definition already exists in Azure. The ID 2a2b9908-6ea1-4ae2-8e65-a410df84e7d1 identifies the built-in Storage Blob Data Reader role.
Declaring it with existing lets the assignment reference that role without creating another definition.
- Add the container-scoped assignment directly below storageBlobDataReaderRole using this reference:
resource documentsReaderAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
scope: documentsContainer
name: guid(documentsContainer.id, dataAccessIdentity.id, storageBlobDataReaderRole.id)
properties: {
roleDefinitionId: storageBlobDataReaderRole.id
principalId: dataAccessIdentity.properties.principalId
principalType: 'ServicePrincipal'
}
}
How Does the Assignment Stay Narrow?
- The scope points directly to documentsContainer.
- The deterministic guid() name produces the same assignment identity during repeat deployments.
- The principalId connects the assignment to the managed identity's service principal.
- The principalType value identifies that principal as a ServicePrincipal.
- Save storage/storage.bicep.
Before you compile, do you expect the role assignment to create permission at the storage-account scope or the container scope?
- Compile the identity-access boundary by running this command:
az bicep build --file storage/main.bicep
What Does This Build Confirm?
The compiler resolves the managed identity principal against the built-in role definition. It also validates the assignment's extension-resource scope.
A successful build confirms the local relationship between the identity, role, and container.
The command returns without diagnostics. The template now expresses read-only Blob access at the documents container.
Role Assignment Not Compiling?
- Check that storageBlobDataReaderRole appears before documentsReaderAssignment.
- Check that scope points to documentsContainer.
- Check that principalId uses dataAccessIdentity.properties.principalId.
Help me diagnose my container-scoped role assignment.
Expose the evidence outputs
The GitLab pipeline needs stable values for later checks. Module outputs expose the identity name, principal ID, and exact container resource ID without reconstructing them elsewhere.
- Find the existing output declarations at the bottom of storage/storage.bicep.
- Add these three outputs below containerName using this reference:
output dataAccessIdentityName string = dataAccessIdentity.name
output dataAccessPrincipalId string = dataAccessIdentity.properties.principalId
output containerResourceId string = documentsContainer.id
What Evidence Do These Outputs Provide?
- The identity name identifies the data-access principal.
- The principal ID gives later checks the service principal object ID.
- The container resource ID preserves the exact least-privilege scope.
- Save storage/storage.bicep.
- Compile the module outputs by running this command:
az bicep build --file storage/main.bicep
What Does This Build Confirm?
This build verifies that every new output refers to a valid property. It also refreshes the generated storage/main.json file.
The command returns without diagnostics. The storage module now exposes all three IAM evidence values.
✔️ Awesome, I've got everything!
Great. Double-check that you saved storage/storage.bicep.
ⓧ I'd like to double check the full code
param location string = resourceGroup().location
var storageAccountName = 'docstore${uniqueString(resourceGroup().id)}'
var dataAccessIdentityName = 'id-documents-reader'
resource storageAccount 'Microsoft.Storage/storageAccounts@2026-04-01' = {
name: storageAccountName
location: location
sku: {
name: 'Standard_LRS'
}
kind: 'StorageV2'
properties: {
accessTier: 'Hot'
allowBlobPublicAccess: false
allowSharedKeyAccess: false
defaultToOAuthAuthentication: true
minimumTlsVersion: 'TLS1_2'
publicNetworkAccess: 'Enabled'
supportsHttpsTrafficOnly: true
}
}
resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2026-04-01' = {
parent: storageAccount
name: 'default'
properties: {}
}
resource documentsContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2026-04-01' = {
parent: blobService
name: 'documents'
properties: {
publicAccess: 'None'
}
}
resource lifecyclePolicy 'Microsoft.Storage/storageAccounts/managementPolicies@2026-04-01' = {
parent: storageAccount
name: 'default'
properties: {
policy: {
rules: [
{
name: 'move-documents-to-cold'
enabled: true
type: 'Lifecycle'
definition: {
filters: {
blobTypes: [
'blockBlob'
]
prefixMatch: [
'documents/records/'
]
}
actions: {
baseBlob: {
tierToCold: {
daysAfterModificationGreaterThan: 30
}
}
}
}
}
]
}
}
}
resource dataAccessIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = {
name: dataAccessIdentityName
location: location
properties: {
isolationScope: 'None'
}
}
resource storageBlobDataReaderRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
scope: subscription()
name: '2a2b9908-6ea1-4ae2-8e65-a410df84e7d1'
}
resource documentsReaderAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
scope: documentsContainer
name: guid(documentsContainer.id, dataAccessIdentity.id, storageBlobDataReaderRole.id)
properties: {
roleDefinitionId: storageBlobDataReaderRole.id
principalId: dataAccessIdentity.properties.principalId
principalType: 'ServicePrincipal'
}
}
output storageAccountName string = storageAccount.name
output containerName string = documentsContainer.name
output dataAccessIdentityName string = dataAccessIdentity.name
output dataAccessPrincipalId string = dataAccessIdentity.properties.principalId
output containerResourceId string = documentsContainer.id
The subscription-level template must pass those module outputs to the deployment. This gives the pipeline one stable place to retrieve the evidence.
- Switch to storage/main.bicep in Visual Studio Code.
- Find the existing storageAccountName output near the bottom of the file.
- Add these three outputs below containerName using this reference:
output dataAccessIdentityName string = storageModule.outputs.dataAccessIdentityName
output dataAccessPrincipalId string = storageModule.outputs.dataAccessPrincipalId
output containerResourceId string = storageModule.outputs.containerResourceId
Why Pass the Outputs Through?
The subscription deployment is the pipeline's entry point. Passing the module outputs through storage/main.bicep lets later jobs read the identity and scope from document-storage-cicd.
- Save storage/main.bicep.
✔️ Awesome, I've got everything!
Great. Double-check that you saved storage/main.bicep.
ⓧ I'd like to double check the full code
targetScope = 'subscription'
param location string = deployment().location
param resourceGroupName string = 'rg-document-lifecycle'
resource documentResourceGroup 'Microsoft.Resources/resourceGroups@2025-04-01' = {
name: resourceGroupName
location: location
}
module storageModule './storage.bicep' = {
scope: documentResourceGroup
params: {
location: location
}
}
output storageAccountName string = storageModule.outputs.storageAccountName
output containerName string = storageModule.outputs.containerName
output dataAccessIdentityName string = storageModule.outputs.dataAccessIdentityName
output dataAccessPrincipalId string = storageModule.outputs.dataAccessPrincipalId
output containerResourceId string = storageModule.outputs.containerResourceId
Before the final build, do you expect Bicep to report any unresolved resource or output references?
- Compile the completed templates by running this command:
az bicep build --file storage/main.bicep
What Does the Final Build Prove?
The final build checks both Bicep files as one subscription-scoped deployment. A clean result confirms that the storage controls, managed identity, role assignment, and outputs resolve together.
The command completes without diagnostics. That is your local proof that the identity-only access design is ready for a pipeline preview.
Final Build Still Failing?
- Compare both files with the full-code references above.
- Check that each output name matches between storage/storage.bicep and storage/main.bicep.
- Check that storageModule.outputs uses the exact capitalization shown in the reference.
Help me diagnose my final Bicep build failure.
- Check the repository's changed-file list in Visual Studio Code for storage/main.json.
- Delete storage/main.json through the file tree if it appears as untracked.
Strong work. Your source now disables Shared Key, prefers OAuth, and grants one managed identity read access at the container boundary.
Your IAM controls are now versioned locally. Next, you will preview their exact Azure impact through the feature-branch GitLab pipeline.
Preview the IAM Change
Your Bicep templates now encode identity-only access for the existing document store. Azure has not received those changes yet.
An IAM deployment can fail when the GitLab OIDC principal lacks permission to create role assignments. A feature-branch preview exposes that blocker before the live account changes.
In this step, get ready to:
- Publish the hardened Bicep files on a feature branch.
- Inspect the IAM changes in the what-if preview.
- Confirm the deployment identity can create the managed identity and role assignment.
Publish the feature branch
A Git feature branch creates a review boundary around the two files that compiled locally. The existing pipeline can inspect those files without deploying them from the feature branch.
- Compare the two Bicep files with the completed versions before publishing them.
✔️ Awesome, I've got everything!
Both Bicep files already match the compiled versions from the previous step.
ⓧ I'd like to double check the full code
targetScope = 'subscription'
param location string = deployment().location
param resourceGroupName string = 'rg-document-lifecycle'
resource documentResourceGroup 'Microsoft.Resources/resourceGroups@2025-04-01' = {
name: resourceGroupName
location: location
}
module storageModule './storage.bicep' = {
scope: documentResourceGroup
params: {
location: location
}
}
output storageAccountName string = storageModule.outputs.storageAccountName
output containerName string = storageModule.outputs.containerName
output dataAccessIdentityName string = storageModule.outputs.dataAccessIdentityName
output dataAccessPrincipalId string = storageModule.outputs.dataAccessPrincipalId
output containerResourceId string = storageModule.outputs.containerResourceId
This file keeps the subscription deployment boundary and passes the evidence outputs through the storage module.
param location string = resourceGroup().location
var storageAccountName = 'docstore${uniqueString(resourceGroup().id)}'
var dataAccessIdentityName = 'id-documents-reader'
resource storageAccount 'Microsoft.Storage/storageAccounts@2026-04-01' = {
name: storageAccountName
location: location
sku: {
name: 'Standard_LRS'
}
kind: 'StorageV2'
properties: {
accessTier: 'Hot'
allowBlobPublicAccess: false
allowSharedKeyAccess: false
defaultToOAuthAuthentication: true
minimumTlsVersion: 'TLS1_2'
publicNetworkAccess: 'Enabled'
supportsHttpsTrafficOnly: true
}
}
resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2026-04-01' = {
parent: storageAccount
name: 'default'
properties: {}
}
resource documentsContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2026-04-01' = {
parent: blobService
name: 'documents'
properties: {
publicAccess: 'None'
}
}
resource lifecyclePolicy 'Microsoft.Storage/storageAccounts/managementPolicies@2026-04-01' = {
parent: storageAccount
name: 'default'
properties: {
policy: {
rules: [
{
name: 'move-documents-to-cold'
enabled: true
type: 'Lifecycle'
definition: {
filters: {
blobTypes: [
'blockBlob'
]
prefixMatch: [
'documents/records/'
]
}
actions: {
baseBlob: {
tierToCold: {
daysAfterModificationGreaterThan: 30
}
}
}
}
}
]
}
}
}
resource dataAccessIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = {
name: dataAccessIdentityName
location: location
properties: {
isolationScope: 'None'
}
}
resource storageBlobDataReaderRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
scope: subscription()
name: '2a2b9908-6ea1-4ae2-8e65-a410df84e7d1'
}
resource documentsReaderAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
scope: documentsContainer
name: guid(documentsContainer.id, dataAccessIdentity.id, storageBlobDataReaderRole.id)
properties: {
roleDefinitionId: storageBlobDataReaderRole.id
principalId: dataAccessIdentity.properties.principalId
principalType: 'ServicePrincipal'
}
}
output storageAccountName string = storageAccount.name
output containerName string = documentsContainer.name
output dataAccessIdentityName string = dataAccessIdentity.name
output dataAccessPrincipalId string = dataAccessIdentity.properties.principalId
output containerResourceId string = documentsContainer.id
This file disables Shared Key access and prefers OAuth authentication. It also grants the managed identity read access at the documents container scope.
- Publish the Bicep changes on harden-storage-iam by running these commands in the Terminal from earlier:
git switch -c harden-storage-iam
git add storage/main.bicep storage/storage.bicep
git commit -m "Enforce identity-only blob access"
git push --set-upstream origin harden-storage-iam
What do these commands do?
- The first command creates the harden-storage-iam branch and switches your workspace to it.
- The second command stages only storage/main.bicep and storage/storage.bicep.
- The third command records the staged files with the Enforce identity-only blob access commit message.
- The final command publishes the branch to the existing remote and starts its feature-branch pipeline.
You should see confirmation that the new branch was pushed to the remote. The branch now contains the exact Bicep files that passed the local compile check.
Branch not published?
- Confirm Terminal still points to ~/Desktop/alz-governance.
- Confirm your existing Git remote can reach the alz-governance project on GitLab.com.
Ask for help with the message shown in your terminal: Help me troubleshoot why my harden-storage-iam branch did not push to GitLab.
Inspect the what-if preview
The what-if preview compares the proposed Bicep deployment with the current Azure state. It reports planned changes without applying them.
The preview can take a few minutes while Azure evaluates the existing resources. A running job during that calculation is expected.
Before you inspect validate, do you expect the deployment identity to have permission to create both new IAM resources?
- Open your alz-governance project on GitLab.com in a browser.
- Select the pipeline for branch harden-storage-iam.
- Select the validate job.
- Wait for the job to finish evaluating the deployment.
What should the preview show?
- The storage account is modified so allowSharedKeyAccess becomes false.
- The same storage account modification sets defaultToOAuthAuthentication to true.
- The preview creates id-documents-reader.
- The preview creates a Microsoft.Authorization/roleAssignments extension resource at the documents container scope.
- The existing storage account and container stay in place.
- The deploy job is skipped on the feature branch.
The feature-branch job only calculates a preview. Your existing Azure resources remain unchanged.
Resolve the permissions checkpoint
Creating a role assignment requires the deployment identity to have management permission at the target scope or higher. The preview reveals that requirement before the storage account starts rejecting Shared Key requests.
✔️ validate passes
- Confirm the validate job passes.
- Confirm the preview includes one id-documents-reader creation.
- Confirm the preview includes one container-scoped role assignment creation.
- Confirm the preview modifies the storage account without replacing it.
- Confirm the feature-branch pipeline skips deploy.
That successful preview proves the existing OIDC deployment identity can evaluate the IAM changes before deployment.
ⓧ Role assignment is blocked
A permission failure here is useful because the live storage account is still untouched. The job has exposed the missing management boundary before enforcement.
- Share the permission failure from the validate job with your Azure administrator.
- Ask the administrator to grant the existing GitLab deployment identity the management permission required to create the assignment at the target scope.
- Leave the id-documents-reader data assignment scoped to the documents container.
- Rerun the same feature-branch pipeline after the permission is granted.
- Inspect the new validate result.
- Confirm the preview includes the storage modification and both IAM resource creations.
Keep the identities separate
The GitLab deployment identity manages Azure resources through the pipeline. id-documents-reader remains a data identity with read-only access to one container.
Broadening the data identity cannot supply the pipeline's missing management permission. Its narrow scope is the security boundary this project protects.
Still blocked by authorization?
- Confirm the administrator granted permission to the existing GitLab deployment identity.
- Confirm the permission applies at the role-assignment scope or higher.
- Rerun the feature-branch pipeline after the permission change is active.
Ask for help with the latest job details: Help me diagnose why the GitLab OIDC deployment identity still cannot preview the Azure role assignment.
You should now see a passing validate job with the storage update and two IAM resource creations. You should also see deploy skipped for harden-storage-iam.
That is the safety check complete. Your pipeline can preview the narrow IAM boundary while Azure remains unchanged.
Next up, you will merge the reviewed Bicep and prove that Shared Key access is rejected after deployment.
Deploy and Prove the Restriction
The feature-branch preview showed how GitLab plans to change your Azure resources. The preview left the live storage account unchanged.
In this step, you will merge the reviewed Bicep into main. Your existing OIDC pipeline applies the restriction before you prove that Shared Key access is blocked.
In this step, get ready to:
- Merge the reviewed IAM changes into main.
- Confirm that the main pipeline validates the templates before deploying them.
- Prove that the managed identity has container-scoped read access while Shared Key requests fail.
Merge and deploy the reviewed Bicep
The two Bicep files already passed the feature-branch preview. You can compare them with the reviewed state before merging.
✔️ Awesome, I've got everything!
Your reviewed files match the deployment state. Continue to the merge request.
ⓧ I'd like to double check the full code
- Compare storage/main.bicep with this reference:
targetScope = 'subscription'
param location string = deployment().location
param resourceGroupName string = 'rg-document-lifecycle'
resource documentResourceGroup 'Microsoft.Resources/resourceGroups@2025-04-01' = {
name: resourceGroupName
location: location
}
module storageModule './storage.bicep' = {
scope: documentResourceGroup
params: {
location: location
}
}
output storageAccountName string = storageModule.outputs.storageAccountName
output containerName string = storageModule.outputs.containerName
output dataAccessIdentityName string = storageModule.outputs.dataAccessIdentityName
output dataAccessPrincipalId string = storageModule.outputs.dataAccessPrincipalId
output containerResourceId string = storageModule.outputs.containerResourceId
What should this file contain?
- The storageModule module deploys the storage resources into rg-document-lifecycle.
- The dataAccessPrincipalId output exposes the managed identity principal ID.
- The containerResourceId output exposes the exact role-assignment scope.
- Compare storage/storage.bicep with this reference:
param location string = resourceGroup().location
var storageAccountName = 'docstore${uniqueString(resourceGroup().id)}'
var dataAccessIdentityName = 'id-documents-reader'
resource storageAccount 'Microsoft.Storage/storageAccounts@2026-04-01' = {
name: storageAccountName
location: location
sku: {
name: 'Standard_LRS'
}
kind: 'StorageV2'
properties: {
accessTier: 'Hot'
allowBlobPublicAccess: false
allowSharedKeyAccess: false
defaultToOAuthAuthentication: true
minimumTlsVersion: 'TLS1_2'
publicNetworkAccess: 'Enabled'
supportsHttpsTrafficOnly: true
}
}
resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2026-04-01' = {
parent: storageAccount
name: 'default'
properties: {}
}
resource documentsContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2026-04-01' = {
parent: blobService
name: 'documents'
properties: {
publicAccess: 'None'
}
}
resource lifecyclePolicy 'Microsoft.Storage/storageAccounts/managementPolicies@2026-04-01' = {
parent: storageAccount
name: 'default'
properties: {
policy: {
rules: [
{
name: 'move-documents-to-cold'
enabled: true
type: 'Lifecycle'
definition: {
filters: {
blobTypes: [
'blockBlob'
]
prefixMatch: [
'documents/records/'
]
}
actions: {
baseBlob: {
tierToCold: {
daysAfterModificationGreaterThan: 30
}
}
}
}
}
]
}
}
}
resource dataAccessIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' = {
name: dataAccessIdentityName
location: location
properties: {
isolationScope: 'None'
}
}
resource storageBlobDataReaderRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
scope: subscription()
name: '2a2b9908-6ea1-4ae2-8e65-a410df84e7d1'
}
resource documentsReaderAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
scope: documentsContainer
name: guid(documentsContainer.id, dataAccessIdentity.id, storageBlobDataReaderRole.id)
properties: {
roleDefinitionId: storageBlobDataReaderRole.id
principalId: dataAccessIdentity.properties.principalId
principalType: 'ServicePrincipal'
}
}
output storageAccountName string = storageAccount.name
output containerName string = documentsContainer.name
output dataAccessIdentityName string = dataAccessIdentity.name
output dataAccessPrincipalId string = dataAccessIdentity.properties.principalId
output containerResourceId string = documentsContainer.id
What should this file enforce?
- The allowSharedKeyAccess property disables key-based authorization.
- The defaultToOAuthAuthentication property makes OAuth the preferred authorization method.
- The dataAccessIdentity resource creates id-documents-reader.
- The documentsReaderAssignment resource grants read access at the documents container scope.
- Return to the GitLab project from the previous step.
- Select Code in the left sidebar.
- Select Merge requests.
- Select New merge request.
- Select harden-storage-iam as the Source branch.
- Select main as the Target branch.
- Select Compare branches and continue.
- Select Create merge request.
Why merge through a request?
A merge request keeps the reviewed diff beside its pipeline evidence. The protected main branch becomes the only path that starts the live deployment.
- Select the Changes tab.
- Check that the reviewed diff covers storage/main.bicep plus storage/storage.bicep.
- Return to the Overview tab.
- Select Merge.
The merge starts a new pipeline for main. This is the point where the preview becomes live enforcement.
The deployment can take several minutes while Azure updates the account and creates the IAM resources. A running job during this wait is expected.
- Open the new main pipeline from the merge request.
- Wait for the validate job to pass.
- Wait for the deploy job to pass.
You should see both jobs in a passed state. The document-storage-cicd deployment has now applied the reviewed controls.
Pipeline fails after the merge?
- Open the failed job log to identify whether the failure happened during sign-in or deployment.
- Confirm that AZURE_CLIENT_ID remains configured in the GitLab project.
- Confirm that AZURE_TENANT_ID remains configured in the GitLab project.
- Confirm that AZURE_SUBSCRIPTION_ID remains configured in the GitLab project.
If the log mentions role-assignment authorization, ask the Azure administrator to confirm that the deployment identity has management permission at the target scope.
Help me diagnose the failed GitLab deployment job.
Verify the live IAM boundary
A passed deployment proves that Azure accepted the template. The deployment outputs let you verify the exact storage account and least-privilege scope that were deployed.
This check uses the Azure RBAC control plane. It confirms the grant for the managed identity without pretending that your local Mac can authenticate as that identity.
- Load the deployment output values into your current shell by running:
STORAGE_ACCOUNT_NAME=$(az deployment sub show --name document-storage-cicd --query "properties.outputs.storageAccountName.value" --output tsv)
DATA_ACCESS_PRINCIPAL_ID=$(az deployment sub show --name document-storage-cicd --query "properties.outputs.dataAccessPrincipalId.value" --output tsv)
CONTAINER_SCOPE=$(az deployment sub show --name document-storage-cicd --query "properties.outputs.containerResourceId.value" --output tsv)
printf '%s\n' "$STORAGE_ACCOUNT_NAME" "$DATA_ACCESS_PRINCIPAL_ID" "$CONTAINER_SCOPE"
What do these values prove?
- The first line identifies the deployed storage account.
- The second line identifies the service principal behind id-documents-reader.
- The third line ends at the documents container resource.
You should see a storage account name followed by the identity principal ID. The final value should end with the documents container path.
- Check both live storage authorization properties by running:
az storage account show --name "$STORAGE_ACCOUNT_NAME" --resource-group rg-document-lifecycle --query allowSharedKeyAccess --output tsv
az storage account show --name "$STORAGE_ACCOUNT_NAME" --resource-group rg-document-lifecycle --query defaultToOAuthAuthentication --output tsv
What does this check prove?
The first query reads whether account keys can authorize storage requests. The second query reads whether OAuth is the preferred authorization method.
You should see false followed by true. The live account now requires identity-based authorization for the path you are testing.
- List the matching role assignment plus its exact count by running:
az role assignment list --all --assignee-object-id "$DATA_ACCESS_PRINCIPAL_ID" --role "Storage Blob Data Reader" --scope "$CONTAINER_SCOPE" --output table
az role assignment list --all --assignee-object-id "$DATA_ACCESS_PRINCIPAL_ID" --role "Storage Blob Data Reader" --scope "$CONTAINER_SCOPE" --query "length(@)" --output tsv
Why query the principal and scope together?
The principal filter ties the assignment to id-documents-reader. The scope filter limits the evidence to the output container resource ID.
The role filter excludes broader management roles. The count makes duplicate or missing assignments visible.
You should see Storage Blob Data Reader at the container scope. The final line should contain exactly 1.
Role assignment missing from the output?
- Confirm that the deploy job passed before rerunning the query.
- Check that DATA_ACCESS_PRINCIPAL_ID contains the deployment output value.
- Check that CONTAINER_SCOPE ends with the documents container path.
Help me debug the missing container-scoped role assignment.
Repeat the original key access attempt
A storage account key is a live credential. This test keeps the temporary value inside your current shell without printing it.
The cleanup command removes the key immediately after the negative access test.
- Load the first storage account key into the temporary shell variable by running:
STORAGE_ACCOUNT_KEY=$(az storage account keys list --account-name "$STORAGE_ACCOUNT_NAME" --resource-group rg-document-lifecycle --query "[0].value" --output tsv)
How is the key handled?
The command stores the key in STORAGE_ACCOUNT_KEY without printing it. The value remains available only in the current shell until you unset it.
Before you repeat the read, do you expect the same key-authenticated request to return the blob name after the deployment?
- Prove that Shared Key access is blocked by running these commands:
az storage blob show --account-name "$STORAGE_ACCOUNT_NAME" --account-key "$STORAGE_ACCOUNT_KEY" --container-name documents --name sample-policy.txt --auth-mode key --query name --output tsv
unset STORAGE_ACCOUNT_KEY
Why is this failure the successful result?
You should see KeyBasedAuthenticationNotPermitted or an HTTP 403 response. The request no longer returns sample-policy.txt.
The second command clears the temporary key from your shell. The original authorization bypass is now closed.
- Confirm that the shell no longer holds the key by running this command:
printenv STORAGE_ACCOUNT_KEY
What should the cleanup check show?
The command should return no value. Your shell no longer contains the temporary storage account key.
That closes the bypass you proved earlier. Your storage account now rejects Shared Key access while id-documents-reader retains one read-only assignment at the documents container scope.
Secret mission
Add an Automated IAM Security Gate
Your manual checks prove that the storage account is secure today. Add a main-branch verification job that fails the pipeline if Shared Key returns or the container-scoped role assignment disappears.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep the hardened environment active, pause your work, or remove it completely. Existing Azure Blob Storage usage remains billable even though the managed identity and Azure RBAC controls add no extra cost.
Cost warning
Your existing Blob Storage account remains usage-billed. Charges depend on stored volume, operations, and data transfer.
Azure Blob Storage has no pause state. Choose Delete if you want to remove ongoing storage usage.
Resources you used:
- The existing rg-document-lifecycle resource group holds the usage-billed storage account. That account includes the private documents container, its Blob data, and the records lifecycle policy.
- The resource group also holds id-documents-reader. Its container-scoped Storage Blob Data Reader role assignment grants read-only access.
- The local storage folder contains the Bicep templates for the document store and its IAM controls.
- The updated GitLab CI/CD pipeline in .gitlab-ci.yml contains the storage validation, deployment, and security gate.
Keep everything running
No action needed. Choose this if you want the identity-only document store and its security gate to remain ready for further work.
- Keep rg-document-lifecycle active with Shared Key disabled.
- Retain id-documents-reader with its container-scoped read assignment.
- Leave the validate, deploy, and verify pipeline stages in place.
- Monitor ongoing Blob Storage usage while the account remains available.
Pause - I'll come back to this later
Azure Blob Storage has no pause state. Pause your project work while keeping the hardened account and source code available for your return.
- Stop triggering new GitLab pipeline runs while you are away.
- Keep the storage folder as the source of truth for the hardened configuration.
- Retain rg-document-lifecycle with its identity-only access controls.
- Review ongoing Blob Storage usage before resuming the project.
Delete - I don't want to use this again
Deleting this environment is deliberately final. It removes the original document store, its Blob data, the managed identity, and the scoped role assignment.
- Switch back to .gitlab-ci.yml in Visual Studio Code.
- Remove the storage what-if command from the validate job.
- Remove the storage deployment command from the deploy job.
- Remove the verify entry from the stages list.
- Remove the complete verify job.
- Save .gitlab-ci.yml.
- Commit this pipeline-only cleanup to main with the Git workflow you used earlier.
- Push the commit to GitLab.
- Return to the GitLab pipeline page from earlier.
- Open the latest main pipeline.
The pipeline should pass without a storage what-if command, a storage deployment command, or an IAM verification job.
- Switch back to the macOS Terminal from earlier.
- Remove the local storage folder from ~/Desktop/alz-governance by running this command:
rm -rf storage
What does this command remove?
This deletes only the local storage folder. The rest of ~/Desktop/alz-governance remains in place.
- Confirm the folder is gone by listing the remaining repository contents:
ls
What should I see?
The repository listing should no longer include storage. This confirms the local Bicep templates have been removed.
Good progress. The local storage templates are gone while the rest of your governance repository remains intact.
Still see the storage folder?
- Check that Terminal is still in ~/Desktop/alz-governance.
- Rerun the removal command after returning to the correct folder.
- Help me remove the local storage folder safely..
- Commit the deleted storage folder to main with your earlier Git workflow.
- Push the deletion commit to GitLab.
- Return to the latest main pipeline.
- Open the pipeline to confirm it passes without referencing the deleted folder.
Your repository is now safe to run without recreating the storage resources.
The next deletion is irreversible. Every blob inside the original document store disappears with the resource group.
- Use the search bar in the Microsoft Azure portal to search for Resource groups.
- Select rg-document-lifecycle from the resource group list.
- Choose Delete resource group from the resource group page.
- Enter rg-document-lifecycle in the confirmation field.
- Choose Delete to confirm the deletion.
- Return to Resource groups to confirm rg-document-lifecycle no longer appears.
That is the irreversible part complete. Azure no longer holds the storage account, Blob data, managed identity, lifecycle policy, or container-scoped role assignment.
Nice Work!
Nice Work!
That is a security boundary worth celebrating: your Azure Blob Storage account now enforces identity-only access through version-controlled Bicep. Your GitLab CI/CD OIDC pipeline now guards the live configuration against IAM security drift.
What you learned:
- Proved the Shared Key bypass with a successful key-authenticated Blob read. Disabled Shared Key authorization through Bicep so the same read fails after deployment. Set OAuth as the preferred authentication path.
- Created a dedicated user-assigned managed identity named id-documents-reader. Granted Storage Blob Data Reader through Azure RBAC only at the documents container scope.
- Used a feature-branch what-if preview to expose permission problems before deployment. Deployed the approved Bicep through the existing GitLab OIDC pipeline. Confirmed the live authorization boundary with command-line evidence.
- Secret Mission: Added an automated IAM security gate to the main-branch pipeline. The verify job now fails if Shared Key access returns. It also fails if the container-scoped reader assignment disappears.
Ready to quiz yourself?