Onboarding Microsoft Azure

Grant Ringleader a custom least-privilege role to run workstation VMs in one of your Azure resource groups, keyless, with Terraform or an ARM template.

Let a Ringleader control plane run Workstation VMs in one of your Azure resource groups using a custom role narrower than built-in Contributor, scoped to that one resource group, and a keyless federation trust — no client secret.

The model

Ringleader control plane
  │  signs a short-lived OIDC token:
  │    iss = <issuer>/org/<your-org-id>,  sub = org:<your-org-id>
  │  and presents it as the client assertion for your Entra app
Federated identity credential on YOUR Entra app
  │  trusts ONLY (issuer, subject = org:<your-org-id>, audience)
service principal  ->  a CUSTOM least-privilege role
creates / manages / deletes workstation VMs in ONE resource group
  • Keyless. No client secret is created or stored. A token minted for any other Ringleader customer carries a different subject and is refused. Azure’s second, organization-scoped pin is the per-org issuer itself — Azure compares the issuer byte-for-byte.
  • Least privilege. Rather than built-in Contributor (which can touch every resource type in the group), the assets define a custom role with only the ARM actions Ringleader’s Azure provider performs.

The least-privilege role

  • Microsoft.Compute/virtualMachines/*: read/write/delete and power operations
  • Microsoft.Compute/disks/*: the OS-disk lifecycle
  • Microsoft.Network/networkInterfaces/* and publicIPAddresses/*: the per-VM NIC and optional public IP
  • Microsoft.Network/virtualNetworks/subnets/{read,join/action}: place a NIC on the workstation subnet (never modify the vnet)
  • Microsoft.Resources/tags/write: keep VM tags up to date
  • read-only size / usage / resource-group introspection

Scoped to, and assignable only within, your resource group.

Don't drop the tags action

Azure writes VM tags through the ARM tags resource, which is a different action from virtualMachines/write. Omit Microsoft.Resources/tags/write and workstations boot perfectly — then every later tag update fails with a permission error, possibly weeks afterwards. Built-in Contributor happens to include it; a least-privilege role must list it.

Before you start

Ringleader gives you two values:

ValueWhat it isExample
issuer URLThe origin Ringleader signs its tokens from. No trailing slash.https://oidc-app.ringleader.dev
organization idYour organization’s id — a UUID, never its name.0192f5bf-af83-7178-8d0a-f1c7aea06bde

You also need an existing resource group you own (the role is scoped to it; the assets do not create it), and rights to create an app registration in your Entra tenant.

One subscription feature you must register first

Register Microsoft.Compute/UseStandardSecurityType on the subscription. Ringleader creates VMs with securityType=Standard (Trusted Launch is incompatible with the nested virtualization workstations rely on), and without this feature every VM create fails — the trust will be perfect and no workstation will ever boot.

az feature register --namespace Microsoft.Compute --name UseStandardSecurityType
# poll until it says Registered (this can take several minutes), then propagate it:
az feature show --namespace Microsoft.Compute --name UseStandardSecurityType \
  --query properties.state -o tsv
az provider register --namespace Microsoft.Compute

Also confirm your chosen region has capacity for the VM size you intend to use; without it, every create returns SkuNotAvailable.

Apply the assets

Both paths live in github.com/ringleader-dev/cloud-onboarding.

PathCreatesUse when
Terraformapp + service principal + federated credential + custom role + assignment (+ optional network), end to endyou manage infra as code
ARMthe custom role + assignment (the app, service principal and credential are created with az first)you prefer ARM or the portal

Why the app is always created with `az` first (ARM path)

The Entra app registration and its federated credential are Microsoft Graph directory objects, not ARM resources, so an ARM template cannot create them. The ARM path therefore creates them with az, then deploys the role and assignment as a template. Terraform can do the whole thing, because its azuread provider talks to Graph directly.

ARM

git clone https://github.com/ringleader-dev/cloud-onboarding
cd cloud-onboarding/azure/arm

export RG=ringleader-workstations                      # an existing RG you own
export ISSUER_URL='https://oidc-app.ringleader.dev'
export ORG_UID='0192f5bf-af83-7178-8d0a-f1c7aea06bde'

az login
./deploy.sh

deploy.sh is safe to re-run, and prints the values to hand back.

Onboarding a SECOND resource group in the same tenant? Set the names.

deploy.sh takes two more optional variables, and both matter as soon as a second resource group in the same Entra tenant is onboarded (a second team, a second environment, prod alongside dev). Left at their defaults, one fails loudly and the other fails silently:

export APP_NAME='Ringleader Admin (team-b)'                  # default: ringleader-workstations
export ROLE_NAME='Ringleader Workstation Operator (team-b)'  # default: Ringleader Workstation Operator
  • ROLE_NAME — Azure requires a custom role’s display name to be unique across the whole tenant, even when the roles are scoped to different resource groups. Re-using the default for a second onboarding fails the deployment with RoleDefinitionWithSameNameExists.
  • APP_NAMEdeploy.sh finds its app registration by display name and reuses the first match, which is what makes re-runs safe. Left at the default, a second onboarding silently adopts the first onboarding’s app and adds the new federated credential to it — so two boundaries end up sharing one identity. Name the app per team or per environment and the lookup stays honest.

One resource group in a tenant needs neither variable.

Terraform

cd cloud-onboarding/azure/terraform/examples/standalone
cp terraform.tfvars.example terraform.tfvars   # then edit
az login
terraform init && terraform apply
terraform output handoff

The portal, step by step

The same result without deploy.sh. The app registration and its federated credential are directory objects, so they are created in the Entra UI; only the role and its assignment are a template.

  1. Create the app. Microsoft Entra IDApp registrationsNew registration. Give it a name, choose Accounts in this organizational directory only, and register. Copy the Application (client) ID — this is the app client id you hand back.

  2. Add the federated credential. On that app: Certificates & secretsFederated credentialsAdd credential → scenario Other issuer.

    FieldValue
    Issuer<issuer-url>/org/<org-id>
    Subject identifierorg:<org-id>
    Nameringleader-oidc
    Audienceapi://AzureADTokenExchange

    All three pins are matched exactly — no trailing slash on the issuer.

  3. Find the service principal’s object id. Microsoft Entra IDEnterprise applications → your app → Object ID. This is not the client id, and it is what the template wants.

  4. Deploy the role. Portal search → Deploy a custom templateBuild your own template in the editor → load azure/arm/azuredeploy.jsonSave. Choose your resource group (the template is resource-group scoped) and set principalId to the object id from step 3. Leave enableWorkstationIdentities at false unless you use runtime identities.

If you are onboarding a second resource group in this tenant, give roleName a distinct value here too — see the note above.

What the trust pins

Derived from the two values above, these are what confine the trust to your organization. They must match exactly — Azure compares the issuer byte-for-byte, and a stray trailing slash fails the exchange.

PinValue
Issuer<issuer-url>/org/<org-id>
Subjectorg:<org-id>
Audienceapi://AzureADTokenExchange (Microsoft’s documented value)

Get one of them wrong — that trailing slash being the classic — and Azure refuses the exchange with AADSTS700211: No matching federated identity record found. The workstation fails to create, and Azure’s own rejection is surfaced on its status, so you can tell a broken trust from a broken workstation.

Reaching your workstations

Ringleader has no bastion and no SSH tunnel: rl shell, rl tmux, port-forwards and VS Code Web all dial the workstation on TCP 22. Bringing it up needs only egress, so it is entirely possible to end up with a VM that comes up, reports Ready, and that nobody can open.

On Azure a workstation gets no public IP unless you ask for one (providerConfig.azure.publicIp: true). That has a consequence people hit in this order:

  1. No public IP and no NAT gateway means no egress — so the workstation never finishes setting up at all. Azure’s default outbound access is being retired, so a private VM with nothing in front of it cannot even reach the Ringleader gateway. The landing pad (create_network = true) gives it a NAT gateway, which fixes egress without a public IP.
  2. Egress alone still leaves nobody able to SSH in. To use the workstation, tell the assets where your engineers connect from:
create_network    = true
ssh_source_ranges = ["203.0.113.0/24"]

Leave ssh_source_ranges empty only if you reach the vnet privately (VPN, ExpressRoute, peering) from wherever you run rl.

Optional: workstations that run AS an identity

Ringleader can boot each workstation with a dedicated user-assigned managed identity it provisions per user and assigns roles to — see runtime identity. It is off by default, because it needs the Microsoft.ManagedIdentity CRUD surface and Microsoft.Authorization/roleAssignments/write — which built-in Contributor does not have either. This is not “Contributor minus a few things”: it is a capability no standard role grants by default.

Set enable_workstation_identities = true (Terraform) or pass enableWorkstationIdentities=true (ARM). Scoped to the one resource group it is far narrower than the GCP equivalent, but it is still the power to hand out access inside that boundary. Left off, the feature refuses with a 403 rather than quietly working.

Unlike GCP, “off” here means a workstation has no Azure identity at all — it cannot touch your Azure resources as itself, which is the safe default. So there is no broadly-privileged default to scope down; turn this on only when a workstation genuinely needs to act against Azure.

What you hand back to Ringleader

ValueHow to get itWhere it lands
app client idprinted by deploy.sh / terraform outputCloudAccount spec.azure.targetAppClientId
tenant idaz account show --query tenantId -o tsvspec.azure.tenant
subscription idaz account show --query id -o tsvCloudIdentity providerConfig.azure.subscriptionId
resource group, locationthe ones you scopedproviderConfig.azure.resourceGroup, .location
subnet id (only if you created a network)terraform output handoffproviderConfig.azure.subnetId

The first two become which cloud identity to use; the rest become the VM’s size and placement. See per-organization federation for the objects Ringleader builds from them.

Verifying

# The federated credential pins your issuer, subject and audience:
az ad app federated-credential list --id <client-id> \
  --query "[].{name:name, issuer:issuer, subject:subject, audiences:audiences}"

# The custom role lists exactly the expected actions:
az role definition list --name "Ringleader Workstation Operator" \
  --query "[0].permissions[0].actions" -o tsv

# The app is assigned that role on the RG, and nothing broader:
az role assignment list --assignee <client-id> \
  --resource-group ringleader-workstations \
  --query "[].roleDefinitionName" -o tsv

(If you set a custom ROLE_NAME, pass that name to az role definition list instead of the default above.)

Once an administrator has created the CloudAccount and CloudIdentity from the values you handed back, confirm the Ringleader side resolves your trust before anyone tries to boot a workstation:

rl wait cloudidentity azure -n dev --for Ready --timeout 2m
rl cloudidentity get azure -n dev -o yaml     # status.valid: true

status.valid: true means the identity is usable — the account resolves and the cloud identity it selects can be used. false points at the federated credential or the app client id; rl cloudidentity describe azure -n dev says which.

Revoking

# Cut the federation but keep the app:
az ad app federated-credential delete --id <client-id> \
  --federated-credential-id ringleader-oidc

# Or remove the app entirely:
az ad app delete --id <client-id>

Terraform: terraform destroy.

There is no client-secret alternative

Azure onboarding was once also possible with a client secret you minted and handed over. That path is retired: Ringleader stores no cloud credential at rest, and a CloudIdentity naming one is refused at apply. Federation is the only Azure model — which is the better outcome anyway: nothing to store, nothing to rotate, nothing to leak.