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 operationsMicrosoft.Compute/disks/*: the OS-disk lifecycleMicrosoft.Network/networkInterfaces/*andpublicIPAddresses/*: the per-VM NIC and optional public IPMicrosoft.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
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:
| Value | What it is | Example |
|---|---|---|
| issuer URL | The origin Ringleader signs its tokens from. No trailing slash. | https://oidc-app.ringleader.dev |
| organization id | Your 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.ComputeAlso 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.
| Path | Creates | Use when |
|---|---|---|
| Terraform | app + service principal + federated credential + custom role + assignment (+ optional network), end to end | you manage infra as code |
| ARM | the 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)
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.shdeploy.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 OperatorROLE_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 withRoleDefinitionWithSameNameExists.APP_NAME—deploy.shfinds 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 handoffThe 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.
Create the app. Microsoft Entra ID → App registrations → New 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.
Add the federated credential. On that app: Certificates & secrets → Federated credentials → Add credential → scenario Other issuer.
Field Value Issuer <issuer-url>/org/<org-id>Subject identifier org:<org-id>Name ringleader-oidcAudience api://AzureADTokenExchangeAll three pins are matched exactly — no trailing slash on the issuer.
Find the service principal’s object id. Microsoft Entra ID → Enterprise applications → your app → Object ID. This is not the client id, and it is what the template wants.
Deploy the role. Portal search → Deploy a custom template → Build your own template in the editor → load
azure/arm/azuredeploy.json→ Save. Choose your resource group (the template is resource-group scoped) and setprincipalIdto the object id from step 3. LeaveenableWorkstationIdentitiesat 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.
| Pin | Value |
|---|---|
| Issuer | <issuer-url>/org/<org-id> |
| Subject | org:<org-id> |
| Audience | api://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:
- 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. - 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
| Value | How to get it | Where it lands |
|---|---|---|
| app client id | printed by deploy.sh / terraform output | CloudAccount spec.azure.targetAppClientId |
| tenant id | az account show --query tenantId -o tsv | spec.azure.tenant |
| subscription id | az account show --query id -o tsv | CloudIdentity providerConfig.azure.subscriptionId |
| resource group, location | the ones you scoped | providerConfig.azure.resourceGroup, .location |
| subnet id (only if you created a network) | terraform output handoff | providerConfig.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: truestatus.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
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.