Skip to content
CAMPUX Cloud Bootcamp Lab · Pipeline Security ← All labs
Hands-On Lab · Intermediate
~30 min · ~$0 · Cloud Shell + browser
Azure CLI · Azure DevOps · torn down at the end
Pipeline Security

The keyless car: scoped to one garage.

A federated service connection removes the stolen-secret risk. It says nothing about how far a compromised pipeline could reach. Here you build a real Azure DevOps service connection with workload identity federation — no stored secret at all — scope its RBAC to a single resource group, deploy through it successfully, then send the same pipeline at a second resource group and watch Azure refuse it.

Fig. 1 · One connection, one garage
The federated service connection deploys successfully into resource group A, the one it is scoped to, and is refused by Azure when the same pipeline targets resource group B. pipeline run two stages service connection no stored secret rg-a in scope rg-b out of scope AuthorizationFailed on rg-b, every time
● Screen walkthrough Not yet recorded · ~7 min
Reel · 00:00 / 07:00

Victor creates the federated connection, deploys into rg-a, then watches the same pipeline get refused on rg-b.

Placeholder — the page below stands alone until the reel lands
Why

No secret is not the same as no risk

The class taught two separate controls and one dangerous way to confuse them: workload identity federation removes the stored secret a service connection would otherwise carry, and resource-group scoping limits what that connection can do once Azure trusts it. A perfectly federated connection with Contributor on the whole subscription is a keyless car that can still drive anywhere. This lab builds the connection the right way — federated and scoped — then proves the scoping is real by sending the exact same pipeline at a resource group it was never granted, and reading the refusal in the log.

No secret is necessary. It is not sufficient.

Before you begin

You need the Owner role on your Azure subscription — the automatic app-registration flow this lab uses requires it — an Azure DevOps organisation and project (the one from the first-pipeline lab works, or create a new one), and either Azure Cloud Shell or a local az login.

Cost & honesty

The two resource groups this lab creates hold no billable resources — just an on-demand tag write, to prove a deploy happened. Left running, an empty resource group costs nothing; the teardown removes them anyway, along with the connection.

Step 1

Two resource groups: one allowed, one forbidden

In Cloud Shell, create the resource group the connection will be scoped to, and a second one it should never be able to touch.

# Cloud Shell (Bash) or local az CLI, signed in
LOCATION="eastus"
RG_A="campux-lab-scoped-a-rg"
RG_B="campux-lab-scoped-b-rg"

az group create --name "$RG_A" --location "$LOCATION" -o table
az group create --name "$RG_B" --location "$LOCATION" -o table

az account show --query "{name:name, id:id}" -o table   # note your subscription — you pick it in the ADO wizard next
Checkpoint az group list -o table shows both campux-lab-scoped-a-rg and campux-lab-scoped-b-rg.
Step 2

A federated connection, scoped to rg-a only

Build the service connection in the Azure DevOps portal. The automatic method reads your subscription, creates the app registration and the federated credential, and assigns RBAC — narrowed to the one resource group you name.

# in the browser, Azure DevOps project settings
1. Project settings > Service connections > New service connection
2. Azure Resource Manager > Next
3. "App registration (automatic)"
   Authentication method: Workload identity federation
4. Scope level: Subscription
   Subscription:      <your subscription>
   Resource group:     campux-lab-scoped-a-rg     # the field that does the scoping — do not leave it blank
5. Service connection name:  azure-scoped-a
6. Security: leave "Grant access permission to all pipelines" UNCHECKED
7. Save

Unchecking that box means no pipeline can use azure-scoped-a until you authorise it by name — the per-pipeline authorisation the class called the small tax that keeps the door narrow. The first pipeline run in Step 3 will hit a permission prompt; approve it there.

Checkpoint Project settings > Service connections > azure-scoped-a shows Workload identity federation as the authentication method, and its Manage link to the Entra app registration confirms a federated credential exists — no client secret field appears anywhere.
If you don't have Owner on the subscription

The automatic flow needs it to create the app registration and its role assignment in one pass. Without Owner, use the manual path instead — create the app registration and federated credential yourself in Microsoft Entra, then a service connection using an existing app registration — following Microsoft Learn's manual workload-identity setup, and assign it the Contributor role scoped to campux-lab-scoped-a-rg yourself with az role assignment create.

Step 3

Deploy into rg-a — and try rg-b with the same connection

Commit this pipeline to the repository's main branch (the same repo from the first-pipeline lab, or any Azure Repos Git repo). It uses azure-scoped-a twice: once against the resource group it is scoped to, once — deliberately — against the one it is not. Instead of creating a billable resource, both stages just tag the resource group, which is enough to prove a deploy happened and costs nothing.

# azure-pipelines.yml
trigger:
  branches:
    include: [ main ]

stages:
  - stage: DeployToA
    jobs:
      - deployment: deployA
        pool:
          vmImage: ubuntu-latest
        environment: scoped-a
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureCLI@2
                  displayName: Tag the resource group this connection is scoped to
                  inputs:
                    azureSubscription: azure-scoped-a
                    scriptType: bash
                    scriptLocation: inlineScript
                    inlineScript: |
                      az group update --name campux-lab-scoped-a-rg --tags touched-by=pipeline

  - stage: DeployToB
    dependsOn: DeployToA
    jobs:
      - deployment: deployB
        pool:
          vmImage: ubuntu-latest
        environment: scoped-b
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureCLI@2
                  displayName: Try the same connection against the forbidden resource group
                  inputs:
                    azureSubscription: azure-scoped-a   # same connection, on purpose — it was never scoped to rg-b
                    scriptType: bash
                    scriptLocation: inlineScript
                    inlineScript: |
                      az group update --name campux-lab-scoped-b-rg --tags touched-by=pipeline

Push it, create the pipeline against this file (Pipelines > Create Pipeline > Azure Repos Git > Existing YAML file, same as the first-pipeline lab), and approve the azure-scoped-a connection and the two new environments the first time each is asked for.

Checkpoint DeployToA succeeds. DeployToB fails, and its log shows an error whose code is AuthorizationFailed — Azure Resource Manager reporting that the identity behind azure-scoped-a has no role assignment over campux-lab-scoped-b-rg, worded close to: "...does not have authorization to perform action 'Microsoft.Resources/subscriptions/resourcegroups/write' over scope '.../resourceGroups/campux-lab-scoped-b-rg' or the scope is invalid."

Step 4

Prove it from the outside, not just the log

A red X in a pipeline log is one kind of proof. Ask Azure directly, from Cloud Shell, and get the same answer a second way.

az group show --name campux-lab-scoped-a-rg --query tags   # -> {"touched-by": "pipeline"}
az group show --name campux-lab-scoped-b-rg --query tags   # -> null, or {}, or the field is simply absent
Checkpoint rg-a carries the tag the pipeline wrote. rg-b does not — the failed stage never touched it, which is the point: a scoped connection does not partially succeed, it is refused outright.
Down

Tear it down

Remove the resource groups, the pipeline, and the connection. Check Entra for a leftover app registration too — Azure DevOps does not document deleting it automatically alongside the connection, so confirm rather than assume.

# Cloud Shell
az group delete --name campux-lab-scoped-a-rg --yes
az group delete --name campux-lab-scoped-b-rg --yes
az group exists --name campux-lab-scoped-a-rg   # -> false
az group exists --name campux-lab-scoped-b-rg   # -> false
# in the browser
1. Pipelines > select the pipeline > ⋯ > Delete
2. Pipelines > Environments > scoped-a and scoped-b > ⋯ > Delete environment
3. Project settings > Service connections > azure-scoped-a > ⋯ > Delete
4. Entra admin center > App registrations > search for an app named after
   azure-scoped-a or your ADO org — if one remains, delete it there too
Checkpoint az group exists returns false for both groups, the connection no longer lists under Service connections, and no leftover app registration remains in Entra.
End

What you can now honestly claim

You created an Azure Resource Manager service connection with workload identity federation — verified, with your own eyes on the connection's details pane, that it holds no stored secret — scoped its RBAC to one resource group at creation time, and ran a pipeline that proved the scope both ways: it succeeded exactly where it was supposed to, and Azure refused it, by name and error code, exactly where it was not. You did this without ever putting a password in a YAML file, and without granting the connection to every pipeline in the project. The next time someone proposes "just give the shared connection Owner on the subscription so nothing is ever blocked" — the exact debate the class staged — you have watched the narrow, correct answer work.