DEV Community

Jonny Diamond
Jonny Diamond

Posted on Originally published at Medium on

Least privilege as a service for Azure deployments — part 1

Least privilege is a concept that garners a lot of attention from Azure practitioners. It means using the exact required permissions to perform a task, and no more. This sounds great, but in practice it is difficult to achieve and is often added to security assurances as a tick box: “we will follow least privilege.” But when it comes to Azure IaC or template deployments, how can we ever know if a deploying security principal is using the least amount of privilege?

Let’s take an example of an Azure DevOps service connection, created using workload identity federation, which is the recommended approach for creating service connections with ARM roles at scopes such as subscriptions or resource groups. By default, the service principal automatically created at the scope of a subscription has the Contributor role, which in terms of ARM roles is second only to Owner (which can create role assignments). If the IaC template deployed by the service connection only has a storage account and an app service (and only ever will), then it is not following a least-privilege approach. It only needs to deploy an app service and a storage account, no more than this.

Another example might be a Cloud Adoption Framework landing zone scenario. This is where a workload team has a landing zone subscription assigned to it. Then, through proper controls and governance, the workload team can deploy the Azure services they require into this landing zone. What role should the deploying principal get in this scenario? Contributor? Or Owner? Or should it be exactly what it needs to be?

Tracking down the precise permissions a template requires would be fairly arduous, and maybe through a mix of guesswork and trial and error, a lesser amount of privileges could be found. Or we could use a tool that can do this for us.

Azure Minimum Permission Finder (MPF) is a tool we can use to find exactly what permissions IaC templates need to deploy resources. It works by creating a test deployment at the resource group scope. This deployment is used to find the exact permissions ARM needs.

To install azmpf, follow the instructions here: https://github.com/Azure/mpf/blob/main/docs/installation-and-quickstart.md#quickstart--usage

Installation requires downloading and installing the azmpf utility and creating a dedicated MPF service principal.

Let’s create a main.bicep template and parameter file (MPF doesn’t support bicepparam files) which we can point MPF at.

param parStorageAccountName string
param parLocation string = 'uksouth'

resource storageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' = {
  name: parStorageAccountName
  location: parLocation
  sku: {
    name: 'Standard_LRS'
  }
  kind: 'StorageV2'
}

{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "parStorageAccountName": {
      "value": "sa5crtvuybghkujk"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Once we have created these files we can invoke MPF using the following command

./azmpf bicep --bicepFilePath main.bicep --parametersFilePath params.parameters.json --verbose
Enter fullscreen mode Exit fullscreen mode

More details on how MPF works can be found here: https://github.com/Azure/mpf/tree/main#how-it-works. The long and short of it is that MPF will perform dummy deployments of a template until it gets no authorisation errors, at which point it displays the exact permissions required. Below we can see the output.

------------------------------------------------------------------------------------------------------------------------------------------
Permissions Required:
------------------------------------------------------------------------------------------------------------------------------------------
Microsoft.Resources/deployments/read
Microsoft.Resources/deployments/write
Microsoft.Storage/storageAccounts/read
Microsoft.Storage/storageAccounts/write
------------------------------------------------------------------------------------------------------------------------------------------
Enter fullscreen mode Exit fullscreen mode

So what now? Now that we know the permissions required, we can create a role containing these permissions and assign a service principal this role. Then, using that service principal for the Bicep deployment, we will actually be using least privilege.

But there’s an important distinction to make here. MPF tells us the permissions the template needs, but the template isn’t deploying itself.

Deployment tooling such as the Azure CLI has its own permission requirements on top of what the template needs. MPF works by calling the ARM deployment API directly (PUT on the deployments endpoint). But when you run az deployment group create, the command performs a pre-flight validation (POST on /validate) and checks deployment status (GET on /operationStatuses). These additional API calls require additional permissions that MPF never triggers. This may vary for Azure DevOps built-in tasks, GitHub Actions, or Terraform deployments.

In practice, for our az deployment group create command, this means we need to add these permissions to the MPF output:

Microsoft.Resources/deployments/validate/action
Microsoft.Resources/deployments/operationStatuses/read
Enter fullscreen mode Exit fullscreen mode

Without these, the deployment will fail with AuthorizationFailed errors even though the template permissions are correct.

So the full set of permissions we actually need is the MPF output plus the deployment tooling permissions. This is like two layers: the template permissions (what MPF finds) and the deployment permissions (what the tooling needs to orchestrate the deployment). Both are required for a successful deployment.

So let’s create the role with the complete set of permissions:

​az role definition create --role-definition '{
 "Name": "Storage deployer",
 "Description": "Deploy a storage account.",
 "Actions": [
     "Microsoft.Resources/deployments/read",
     "Microsoft.Resources/deployments/write",
     "Microsoft.Resources/deployments/validate/action",
     "Microsoft.Resources/deployments/operationStatuses/read",
     "Microsoft.Storage/storageAccounts/read",
     "Microsoft.Storage/storageAccounts/write"
 ],
 "DataActions": [],
 "NotDataActions": [],
 "AssignableScopes": ["/subscriptions/SUB_ID"] 
}'
Enter fullscreen mode Exit fullscreen mode

Then let’s create a service principal (take note of the app id, password, and tenant id).

az ad sp create-for-rbac -n app-storage-deployer
Enter fullscreen mode Exit fullscreen mode

And finally, assign the role to the app using the role’s GUID as the role:

az role assignment create --assignee APP_ID --role 53f635da-1c50-4f76-8ad2-ba4ba2ac21e9 --scope /subscriptions/SUB_ID
Enter fullscreen mode Exit fullscreen mode

Now sign in as the service principal and perform the deployment.

az login --service-principal --username APP_ID --password SP_PSWD --tenant TENANT_ID
az deployment group create -n storage-deployer -g rg -f main.bicep -p param.parameter.json

{
  "id": "/subscriptions/SUB_ID/resourceGroups/rg/providers/Microsoft.Resources/deployments/storage-deployer",
  "location": null,
  "name": "storage-deployer",
  "properties": {
    "correlationId": "e32d27b5-0d6b-4c40-9103-1ccdf29d896f",
    "debugSetting": null,
    "dependencies": [],
    "duration": "PT2.0994047S",
    "error": null,
    "mode": "Incremental",
    "onErrorDeployment": null,
    "outputResources": [
      {
        "id": "/subscriptions/SUB_ID/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/sa5crtvuybghkujk",
        "resourceGroup": "rg"
      }
    ],
    "outputs": null,
    "parameters": {
      "parLocation": {
        "type": "String",
        "value": "uksouth"
      },
      "parStorageAccountName": {
        "type": "String",
        "value": "sa5crtvuybghkujk"
      }
    },
    "parametersLink": null,
    "providers": [
      {
        "id": null,
        "namespace": "Microsoft.Storage",
        "providerAuthorizationConsentState": null,
        "registrationPolicy": null,
        "registrationState": null,
        "resourceTypes": [
          {
            "aliases": null,
            "apiProfiles": null,
            "apiVersions": null,
            "capabilities": null,
            "defaultApiVersion": null,
            "locationMappings": null,
            "locations": [
              "uksouth"
            ],
            "properties": null,
            "resourceType": "storageAccounts",
            "zoneMappings": null
          }
        ]
      }
    ],
    "provisioningState": "Succeeded",
    "templateHash": "3036883372851194424",
    "templateLink": null,
    "timestamp": "2026-03-26T20:09:01.653200+00:00",
    "validatedResources": null
  },
  "resourceGroup": "rg",
  "tags": null,
  "type": "Microsoft.Resources/deployments"
}
Enter fullscreen mode Exit fullscreen mode

Excellent: we’ve deployed our IaC using a service principal that has our custom role. The custom role, “Storage deployer,” has only the permissions required by the template for ARM and the Microsoft.Resources/deployments permissions required for the az deployment command (to validate our template and poll its status).

This is great. We have achieved least privilege. But running azmpf manually every time we want to deploy anything can be pretty time-consuming. Could we automate this, or provide it as a service in a landing zone context?

Top comments (0)