Skip to content

Configuring Azure Authentication

This guide covers how to securely configure Azure authentication for Controlinfra to scan your infrastructure.

Quick Setup Checklist

For Azure VM runners with Managed Identity, you need:

  1. Enable System Managed Identity on your Azure VM
  2. Assign these roles to the managed identity:
    • Reader on the subscription or resource group
    • Storage Blob Data Contributor on the Terraform state storage account
    • Contributor on the resource group containing your infrastructure
  3. Configure environment variables in the runner service:
    ARM_USE_MSI=true
    ARM_SUBSCRIPTION_ID=your-subscription-id
    ARM_TENANT_ID=your-tenant-id
  4. Set use_azuread_auth = true in your Terraform backend configuration

Overview

Controlinfra needs Azure credentials to:

  1. Access your Terraform state (if using Azure Storage backend)
  2. Run terraform plan to detect drift
  3. Query Azure APIs to compare actual vs. desired state

Authentication Methods

Controlinfra supports three authentication methods for Azure:

MethodRunner TypeSecurity LevelBest For
Service PrincipalCloud or Self-HostedStandardQuick setup, CI/CD pipelines
Managed IdentitySelf-Hosted OnlyHighAzure VM runners with system-assigned identity
OIDC (Workload Identity Federation)Self-Hosted OnlyHighestNo secrets, short-lived tokens, production

Option 1: Service Principal

Use an Azure AD Service Principal with client ID and secret.

When to Use

  • Getting started quickly
  • Cloud runner (Controlinfra-managed)
  • CI/CD pipelines
  • Development/testing environments

Setup Steps

Step 1: Create a Service Principal

bash
# Login to Azure CLI
az login

# Create Service Principal with Reader role on your subscription
az ad sp create-for-rbac \
  --name "controlinfra-scanner" \
  --role "Reader" \
  --scopes /subscriptions/YOUR_SUBSCRIPTION_ID \
  --output json

Output:

json
{
  "appId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",      // Client ID
  "displayName": "controlinfra-scanner",
  "password": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",       // Client Secret
  "tenant": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"     // Tenant ID
}

Save the Password

The client secret is only shown once. Save it securely before continuing.

Step 2: Grant Additional Permissions

For Terraform state stored in Azure Storage:

bash
# Get your storage account resource ID
STORAGE_ID=$(az storage account show \
  --name YOUR_STORAGE_ACCOUNT \
  --resource-group YOUR_RESOURCE_GROUP \
  --query id -o tsv)

# Assign Storage Blob Data Contributor role
az role assignment create \
  --assignee YOUR_APP_ID \
  --role "Storage Blob Data Contributor" \
  --scope $STORAGE_ID

Step 3: Configure in Controlinfra

  1. Go to Add RepositoryCloud ProviderAzure
  2. Select "Service Principal" authentication
  3. Enter your credentials:
    • Subscription ID: Your Azure subscription ID
    • Tenant ID: Your Azure AD tenant ID
    • Client ID: The appId from the service principal
    • Client Secret: The password from the service principal

Security Considerations

  • Credentials are encrypted at rest with AES-256-GCM
  • Rotate client secrets regularly (recommended: every 90 days)
  • Use least-privilege permissions

Option 2: Managed Identity Recommended

Use the managed identity attached to your Azure VM. No credentials to manage!

Self-Hosted Runner Required

This option is only available when using a self-hosted runner on an Azure VM.

When to Use

  • Self-hosted runner on Azure VM
  • Enhanced security requirements
  • No credential rotation needed
  • Production environments

How It Works

┌─────────────────────┐     ┌──────────────────┐     ┌─────────────────┐
│  Controlinfra       │────▶│  Self-Hosted     │────▶│  Azure APIs     │
│  Cloud              │     │  Runner (VM)     │     │                 │
└─────────────────────┘     └──────────────────┘     └─────────────────┘

                            System Managed Identity
                            (automatic credentials)

Setup Steps

Step 1: Create Azure VM with Managed Identity

bash
# Create resource group
az group create \
  --name controlinfra-runner-rg \
  --location eastus

# Create VM with system-assigned managed identity
az vm create \
  --resource-group controlinfra-runner-rg \
  --name controlinfra-runner-vm \
  --image Ubuntu2204 \
  --size Standard_B2s \
  --admin-username adminuser \
  --generate-ssh-keys \
  --assign-identity

Or enable on an existing VM:

bash
az vm identity assign \
  --resource-group YOUR_RESOURCE_GROUP \
  --name YOUR_VM_NAME

Step 2: Get the Managed Identity Principal ID

bash
az vm identity show \
  --resource-group controlinfra-runner-rg \
  --name controlinfra-runner-vm \
  --query principalId -o tsv

Step 3: Assign Required Roles

The managed identity needs permissions to:

  1. Read Azure resources for drift detection
  2. Access Terraform state in Azure Storage
bash
# Get the principal ID
PRINCIPAL_ID=$(az vm identity show \
  --resource-group controlinfra-runner-rg \
  --name controlinfra-runner-vm \
  --query principalId -o tsv)

# Assign Reader role on the resource group containing your infrastructure
az role assignment create \
  --assignee-object-id $PRINCIPAL_ID \
  --assignee-principal-type ServicePrincipal \
  --role "Reader" \
  --scope /subscriptions/YOUR_SUBSCRIPTION_ID/resourceGroups/YOUR_INFRA_RG

# Assign Storage Blob Data Contributor on your Terraform state storage account
az role assignment create \
  --assignee-object-id $PRINCIPAL_ID \
  --assignee-principal-type ServicePrincipal \
  --role "Storage Blob Data Contributor" \
  --scope /subscriptions/YOUR_SUBSCRIPTION_ID/resourceGroups/YOUR_RG/providers/Microsoft.Storage/storageAccounts/YOUR_STORAGE_ACCOUNT

# For full infrastructure management, assign Contributor role
az role assignment create \
  --assignee-object-id $PRINCIPAL_ID \
  --assignee-principal-type ServicePrincipal \
  --role "Contributor" \
  --scope /subscriptions/YOUR_SUBSCRIPTION_ID/resourceGroups/YOUR_INFRA_RG

Required Permissions

All three roles are typically required for successful drift detection:

RoleScopePurpose
ReaderResource Group or SubscriptionBasic read access to Azure resources
Storage Blob Data ContributorStorage AccountAccess Terraform state file
ContributorResource GroupRequired for listKeys/action and full resource state access

Why Contributor? Terraform needs Microsoft.Storage/storageAccounts/listKeys/action and similar permissions to read the full state of resources like Storage Accounts. Reader role alone will cause "AuthorizationFailed" errors during terraform plan.

Step 4: Configure the Runner Service

Create the systemd service file at /etc/systemd/system/controlinfra-runner.service:

ini
[Unit]
Description=Controlinfra Self-Hosted Runner
After=network.target

[Service]
Type=simple
User=controlinfra
WorkingDirectory=/opt/controlinfra-runner

# Azure Managed Identity configuration
Environment="ARM_USE_MSI=true"
Environment="ARM_SUBSCRIPTION_ID=YOUR_SUBSCRIPTION_ID"
Environment="ARM_TENANT_ID=YOUR_TENANT_ID"

ExecStart=/opt/controlinfra-runner/bin/controlinfra-agent
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

Enable and start the service:

bash
sudo systemctl daemon-reload
sudo systemctl enable controlinfra-runner
sudo systemctl start controlinfra-runner

Step 5: Verify the Setup

SSH into your runner VM and verify:

bash
# Check the managed identity is working
az login --identity

# Get current identity
az account show

# Test reading a resource
az vm list --resource-group YOUR_INFRA_RG --output table

Benefits

  • No credentials stored - Azure SDK automatically uses VM metadata service
  • No rotation needed - Azure manages credentials automatically
  • Audit-friendly - All actions tracked under the managed identity
  • Secure - Credentials never leave your Azure environment

When Managed Identity Won't Work

If your Terraform providers.tf hardcodes client_id from a variable (e.g., client_id = var.azurerm_sp.client_id), the provider will try to use that specific identity instead of the VM's system-assigned identity. In this case, use OIDC or Service Principal authentication instead.


Option 3: OIDC (Workload Identity Federation) Recommended

Use OpenID Connect federation to authenticate without storing any secrets. Controlinfra acts as an OIDC identity provider — it issues short-lived signed tokens that Azure exchanges for access tokens.

Self-Hosted Runner Required

This option is only available when using a self-hosted runner on an Azure VM.

When to Use

  • Terraform provider hardcodes client_id in providers.tf
  • No client secrets to manage or rotate
  • Short-lived tokens (10 minutes) for maximum security
  • Compliance requirements that prohibit stored credentials

How It Works

┌─────────────────────┐     ┌──────────────────┐     ┌─────────────────┐
│  Controlinfra       │──1──│  Self-Hosted     │──3──│  Azure AD       │
│  (OIDC Provider)    │     │  Runner (VM)     │     │  Token Exchange │
└─────────────────────┘     └──────────────────┘     └─────────────────┘
        │                           │                        │
   1. Signs JWT token         2. Sets ARM_OIDC_TOKEN    3. Exchanges JWT
   with repo context          + ARM_USE_OIDC=true       for access token
                              + ARM_CLIENT_ID

Setup Steps

Step 1: Create an Azure App Registration

If you don't already have one, create an app registration for Controlinfra:

bash
az ad app create --display-name "controlinfra-oidc"

Note the Application (client) ID from the output.

Step 2: Add a Federated Credential

This tells Azure to trust tokens issued by Controlinfra. You only need to do this once per organization — a single federated credential covers all repositories in your Controlinfra org.

bash
az ad app federated-credential create \
  --id YOUR_APP_CLIENT_ID \
  --parameters '{
    "name": "controlinfra",
    "issuer": "https://api.controlinfra.com",
    "subject": "org:YOUR_ORG_ID",
    "audiences": ["api://AzureADTokenExchange"],
    "description": "Controlinfra OIDC federation"
  }'

Finding Your Org ID

Your Org ID is shown in the Controlinfra UI at Settings → Organization, or in the URL when viewing your organization page: /orgs/{ORG_ID}.

Use https://api.controlinfra.com as the issuer for production, or https://api-stage.controlinfra.com for staging.

Subject Must Match Exactly

The subject field must match exactly what Controlinfra generates. The format is:

org:{orgId}

If the subject doesn't match, Azure will return error AADSTS700211: No matching federated identity record found.

Step 3: Assign Required Roles

Grant the app registration access to your Azure resources:

bash
# Get the app's service principal object ID
SP_OBJECT_ID=$(az ad sp show --id YOUR_APP_CLIENT_ID --query id -o tsv)

# Reader role on the subscription or resource group
az role assignment create \
  --assignee-object-id $SP_OBJECT_ID \
  --assignee-principal-type ServicePrincipal \
  --role "Reader" \
  --scope /subscriptions/YOUR_SUBSCRIPTION_ID

# Storage Blob Data Contributor for Terraform state
az role assignment create \
  --assignee-object-id $SP_OBJECT_ID \
  --assignee-principal-type ServicePrincipal \
  --role "Storage Blob Data Contributor" \
  --scope /subscriptions/YOUR_SUBSCRIPTION_ID/resourceGroups/YOUR_RG/providers/Microsoft.Storage/storageAccounts/YOUR_STORAGE_ACCOUNT

# Contributor for full infrastructure access (if needed)
az role assignment create \
  --assignee-object-id $SP_OBJECT_ID \
  --assignee-principal-type ServicePrincipal \
  --role "Contributor" \
  --scope /subscriptions/YOUR_SUBSCRIPTION_ID/resourceGroups/YOUR_INFRA_RG

Step 4: Configure in Controlinfra

  1. Go to Add Repository (or edit existing) -> Cloud Provider -> Azure
  2. Select "OIDC" authentication
  3. Enter:
    • Subscription ID: Your Azure subscription ID
    • Tenant ID: Your Azure AD tenant ID
    • Client ID: The app registration's Application (client) ID

No client secret is needed — Controlinfra handles the token exchange automatically.

Step 5: Verify OIDC Endpoints

Controlinfra exposes standard OIDC discovery endpoints that Azure uses to validate tokens:

  • Discovery: https://api.controlinfra.com/.well-known/openid-configuration
  • JWKS: https://api.controlinfra.com/.well-known/jwks.json

You can verify these are accessible:

bash
curl -s https://api.controlinfra.com/.well-known/openid-configuration | jq .

Benefits

  • No secrets stored - Only client ID, tenant ID, and subscription ID are saved
  • Short-lived tokens - JWT tokens expire in 10 minutes
  • No rotation needed - Tokens are generated fresh for each scan
  • Audit-friendly - Each token includes workspace and repository context
  • Works with hardcoded providers - Compatible with providers.tf that uses client_id variable

Troubleshooting OIDC

"No matching federated identity record found" (AADSTS700211)

AADSTS700211: No matching federated identity record found for presented assertion issuer
  1. Check the issuer URL matches exactly — https://api.controlinfra.com (production) or https://api-stage.controlinfra.com (staging)
  2. Check the subject matches the format workspace:{workspaceId}:repo:{repoConfigId}
  3. Check the audience is api://AzureADTokenExchange
  4. Wait 1-2 minutes after creating the federated credential — propagation can take time

"Identity not found"

ManagedIdentityAuthorizer: Identity not found

This means Terraform is trying managed identity instead of OIDC. Ensure:

  1. You selected OIDC (not Managed Identity) in the Controlinfra wizard
  2. The ARM_USE_OIDC=true and ARM_OIDC_TOKEN env vars are being set by the runner

Required Azure Permissions

Controlinfra needs two types of permissions:

  1. Read-Only Access to Azure resources for drift detection
  2. Terraform State Access to read state from Azure Storage

The simplest approach using Azure built-in roles:

RoleScopePurpose
ReaderResource Group or SubscriptionQuery Azure resources
Storage Blob Data ContributorStorage AccountRead/write Terraform state
bash
# Reader role on the resource group
az role assignment create \
  --assignee YOUR_PRINCIPAL_ID \
  --role "Reader" \
  --scope /subscriptions/SUB_ID/resourceGroups/RG_NAME

# Storage Blob Data Contributor on state storage
az role assignment create \
  --assignee YOUR_PRINCIPAL_ID \
  --role "Storage Blob Data Contributor" \
  --scope /subscriptions/SUB_ID/resourceGroups/RG_NAME/providers/Microsoft.Storage/storageAccounts/STORAGE_NAME

Alternative: Custom Role Definition

For more restrictive access, create a custom role:

json
{
  "Name": "Controlinfra Drift Scanner",
  "Description": "Read-only access for drift detection",
  "Actions": [
    "Microsoft.Compute/virtualMachines/read",
    "Microsoft.Compute/disks/read",
    "Microsoft.Network/*/read",
    "Microsoft.Storage/storageAccounts/read",
    "Microsoft.Storage/storageAccounts/listKeys/action",
    "Microsoft.Storage/storageAccounts/blobServices/containers/read",
    "Microsoft.KeyVault/vaults/read",
    "Microsoft.Sql/servers/read",
    "Microsoft.Sql/servers/databases/read",
    "Microsoft.Web/sites/read",
    "Microsoft.ServiceBus/namespaces/read",
    "Microsoft.Resources/subscriptions/resourceGroups/read"
  ],
  "NotActions": [],
  "AssignableScopes": [
    "/subscriptions/YOUR_SUBSCRIPTION_ID"
  ]
}

Create the custom role:

bash
az role definition create --role-definition custom-role.json

Terraform Backend Configuration

When using Azure Storage as your Terraform backend, ensure the backend block includes Azure AD authentication:

hcl
terraform {
  backend "azurerm" {
    resource_group_name  = "terraform-state-rg"
    storage_account_name = "tfstatestorage"
    container_name       = "tfstate"
    key                  = "prod/terraform.tfstate"
    use_azuread_auth     = true  # Required for Managed Identity
  }
}

Important

The use_azuread_auth = true setting is required when using Managed Identity or OIDC authentication. Without it, Terraform will attempt to use storage account keys which may not be available.


Environment Variables Reference

Service Principal Authentication

bash
ARM_SUBSCRIPTION_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
ARM_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
ARM_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
ARM_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Managed Identity Authentication

bash
ARM_USE_MSI=true
ARM_SUBSCRIPTION_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
ARM_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

OIDC Authentication

bash
ARM_USE_OIDC=true
ARM_OIDC_TOKEN=eyJhbGciOiJSUzI1NiIs...  # Auto-generated by Controlinfra
ARM_CLIENT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
ARM_TENANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
ARM_SUBSCRIPTION_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

INFO

ARM_OIDC_TOKEN is generated automatically by Controlinfra for each scan. You do not need to set this manually.

Optional Settings

bash
# For Azure Government, China, or German clouds
ARM_ENVIRONMENT=public  # or usgovernment, china, german

Security Best Practices

1. Use Managed Identity for Production

  • Eliminates credential management
  • Automatic rotation handled by Azure
  • Better audit trail

2. Use Least Privilege

Only grant permissions for resources Terraform manages:

bash
# Scope to specific resource group instead of subscription
az role assignment create \
  --assignee $PRINCIPAL_ID \
  --role "Reader" \
  --scope /subscriptions/SUB_ID/resourceGroups/prod-infra-rg

3. Monitor Credential Usage

Enable Azure Activity Log to track API calls:

bash
# View recent activity for your service principal
az monitor activity-log list \
  --caller YOUR_APP_ID \
  --start-time 2024-01-01 \
  --output table

4. Rotate Service Principal Secrets

If using Service Principal, rotate secrets regularly:

bash
# Create new secret (old one remains valid)
az ad sp credential reset \
  --name controlinfra-scanner \
  --credential-description "rotated-$(date +%Y%m%d)"

# Update Controlinfra with new secret
# Then delete old credential

Troubleshooting

"AuthorizationFailed" Error

The client does not have authorization to perform action
  1. Verify the role is assigned:

    bash
    az role assignment list --assignee YOUR_PRINCIPAL_ID --output table
  2. Check the scope is correct (subscription vs resource group)

  3. Wait a few minutes - role assignments can take up to 10 minutes to propagate

"AuthorizationPermissionMismatch" Error

This request is not authorized to perform this operation using this permission
  • The identity needs Storage Blob Data Contributor role on the storage account
  • Reader role alone is not sufficient for blob access

"Azure CLI auth only supported as User" Error

Authenticating using the Azure CLI is only supported as a User (not a Service Principal)

This occurs when running az login --identity but Terraform expects ARM environment variables.

Solution: Add ARM_USE_MSI to your runner configuration:

bash
# In systemd service file or environment
Environment="ARM_USE_MSI=true"
Environment="ARM_SUBSCRIPTION_ID=your-subscription-id"
Environment="ARM_TENANT_ID=your-tenant-id"

Managed Identity Not Found

ManagedIdentityCredential: IMDS endpoint unavailable
  1. Verify managed identity is enabled on the VM:

    bash
    az vm identity show --resource-group RG --name VM_NAME
  2. Check from inside the VM:

    bash
    curl -s -H "Metadata: true" \
      "http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/"

State File Access Issues

Error: Failed to get existing workspaces: containers.Client#ListBlobs: StatusCode=403
  1. Verify Storage Blob Data Contributor role on the storage account
  2. Check use_azuread_auth = true in Terraform backend config
  3. Ensure the container exists and is accessible

Multi-Subscription Setup

For scanning infrastructure across multiple Azure subscriptions:

Option 1: Multiple Service Principals

Create separate service principals for each subscription and configure in Controlinfra per-repository.

Option 2: Cross-Subscription Managed Identity

Grant the managed identity roles in multiple subscriptions:

bash
# Get principal ID
PRINCIPAL_ID=$(az vm identity show \
  --resource-group runner-rg \
  --name runner-vm \
  --query principalId -o tsv)

# Assign in subscription 1
az role assignment create \
  --assignee-object-id $PRINCIPAL_ID \
  --assignee-principal-type ServicePrincipal \
  --role "Reader" \
  --scope /subscriptions/SUBSCRIPTION_1_ID

# Assign in subscription 2
az role assignment create \
  --assignee-object-id $PRINCIPAL_ID \
  --assignee-principal-type ServicePrincipal \
  --role "Reader" \
  --scope /subscriptions/SUBSCRIPTION_2_ID

Next Steps