RESTORE(2)

Automating Microsoft Fabric Workspace Creation with Azure DevOps Pipelines

In today’s fast-paced analytics landscape, Microsoft Fabric has become the leader of enterprise BI implementations, one of the fundamental concepts that allows us to work with Fabric and Power BI is workspace which acts as a starting point in every implementation. But manually creating and configuring workspaces across all the environments is error-prone, time-consuming, and hard to audit. Because of that in most cases I recommend to automate this work.

There is many ways to automate workspace creation, but today I would like to show you how to achieve that via Azure DevOps Pipelines. In this post, we’ll walk through a production-ready YAML pipeline that automatically creates Fabric workspaces with consistent naming, assigns them to a Fabric premium capacity, and applies role-based access control (RBAC) using Microsoft Entra security groups — all in a single, idempotent run. Let’s see how it works.

First of all you can you can ask why we should bother and automate workspace creation. First of all consistency because we can enforce proper naming convention, second is speed because with automated approach we can setup everything in just few clicks, next we have auditability where we can see who created workspace and why. Last but not least it is coming with predefined roles without manual clicks.
Let’s take this cake piece by piece.
Our YAML pipeline starts with defining trigger and pool:
trigger:
- none

pool:
  vmImage: 'ubuntu-latest'
  • Manual trigger only (trigger: none) – perfect for controlled deployments that are executed on demand.
  • Runs on Ubuntu agent for broad tool compatibility, in this case we use REST API only so there is no need to use Windows specific machine.

After that we have parameters that we will modify every single time:

parameters:
  - name: numberOfEnvironments
    displayName: 'Number of environments'
    type: string
    default: '3'
    values: ['1', '3']

  - name: functionalName
    displayName: 'Functional name'
    type: string
    default: ''

  - name: unit
    displayName: 'Unit'
    type: string
    default: 'All'
    values: ['All', 'Denmark', 'Poland', 'USA']

  - name: businessReadersGroupId
    displayName: 'Business Readers Group ID'
    type: string
    default: ''

These parameters are descriptive enough, but just to be clear:

  • numberOfEnvironments – How many workspaces we want to create. For me it’s typically 3 (DEV, TEST, PROD), but sometimes only one is needed. In this approach we treat each workspace as an “environment”.
  • functionalName – Just a meaningful name for the workspace.
  • unit – Indicates which branch or organizational unit this workspace belongs to in your organization.
  • businessReadersGroupId – The object ID (from Entra ID) of the group that should get Reader access by default. We could also retrieve this automatically from Entra ID if the Service Principal used in the Service Connection has permission to read groups — here I’ve kept it simple by allowing you to enter the ID manually.

Let’s move on to the next section, which contains a simple variable that holds the workspace name — it’s built dynamically.

 # Map unit to code
 case "${{ parameters.unit }}" in
        All) unitCode="" ;;
        Denmark) unitCode="DK" ;;
        Poland) unitCode="PL" ;;
        "USA") unitCode="US" ;;
        *) unitCode="" ;;
      esac
baseName="IT"
[ -n "$unitCode" ] && baseName="$baseName - $unitCode"
[ -n "$functionalName" ] && baseName="$baseName - $functionalName"
workspaceName="$baseName - [$env]"

Next we determine based on parameter environment and how many of them we want:

if [ "${{ parameters.numberOfEnvironments }}" == "3" ]; then
  envs=("DEV" "TEST" "PROD")
else
  envs=("PROD")
fi

It works in a very straightforward way:

  • If you ask for three environments → DEV, TEST, and PROD are created.
  • If you ask for only one → it automatically becomes PROD.

Our workspace naming is done so let’s try to authenticate. Below one-liner uses the Azure CLI to fetch a short-lived access token specifically for the Power BI API and stores it in the token variable – ready to be used in the next curl or REST API calls.

token=$(az account get-access-token --resource https://analysis.windows.net/powerbi/api --query accessToken -o tsv)

The curl part is pretty clear: we use the GET method on the https://api.powerbi.com/v1.0/myorg/groups endpoint to locate the workspace and capture its ID if it already exists.

token=$(az account get-access-token --resource https://analysis.windows.net/powerbi/api --query accessToken -o tsv)

      for env in "${envs[@]}"; do
        if [ -n "$env" ]; then
          workspaceName="$baseName - [$env]"
        else
          workspaceName="$baseName"
        fi

        echo "Processing workspace: $workspaceName"

        echo "Checking if workspace exists..."
        groups_response=$(curl -s -X GET "https://api.powerbi.com/v1.0/myorg/groups" \
          -H "Authorization: Bearer $token" \
          -H "Content-Type: application/json")
        workspaceId=$(echo "$groups_response" | jq -r ".value[] | select(.name==\"$workspaceName\") | .id")

        if [ -z "$workspaceId" ]; then
          echo "Workspace does not exist. Creating $workspaceName..."
          create_response=$(curl -s -X POST "https://api.powerbi.com/v1.0/myorg/groups" \
            -H "Authorization: Bearer $token" \
            -H "Content-Type: application/json" \
            -d "{\"name\": \"$workspaceName\"}")
          workspaceId=$(echo "$create_response" | jq -r '.id')
          if [ -z "$workspaceId" ] || [ "$workspaceId" == "null" ]; then
            echo "Workspace creation failed. Response: $create_response"
            exit 1
          fi
          echo "Created workspace: $workspaceId"
        else
          echo "Workspace '$workspaceName' already exists with ID: $workspaceId"
        fi

        if [ -z "${{ variables.capacityId }}" ] || [ "${{ variables.capacityId }}" == "null" ]; then
          echo "Capacity ID is null or empty. Exiting."
          exit 1
        fi
  • If exists → skip creation
  • If not → POST /groups → extract id

Next part is assigning to capacity – it is just another REST API call (we have to know capacityId so it is good to keep it as a variable in the script):

curl -X POST "/groups/$workspaceId/AssignToCapacity" -d "{\"capacityId\": \"${{ variables.capacityId }}\"}"

In many cases, I have some predefined technical roles that should be applied to all workspaces. The table below is just a quick summary of how those groups are assigned and where:

Role Group Applied To
Admin adminGroupId, adminGroupId2 All workspaces
Contributor opsGroupId All workspaces
Viewer viewerGroupId All workspaces
Viewer (Business) Readers, Testers, Creators PROD only
Viewer (Testers/Creators) Testers, Creators TEST only

Environment Business Readers Business Testers Business Creators
PROD Viewer Viewer Viewer
TEST Viewer Viewer
DEV

Only relevant stakeholders get access — least privilege principle applied by default.

Error Handling & Safety

  • set -e → fail fast on any error
  • Every API call checks for “error” in response
  • Logs clear success/failure messages
  • Exits with code 1 on failure → pipeline fails visibly

How to Use This Pipeline

  1. Store in Azure Repos in my case it is fabric-workspace-creation.yml
  2. Create service connection: in my case it power-bi-automation-service-connection (with Power BI + Entra permissions if you don’t want to hardcode groups IDs)
  3. Set variables in pipeline (Library or YAML):

variables:
capacityId: ‘DFE4E7B2-B6EC-429A-A922-A9SAB6821E23A’
adminGroupId: ‘7eefcaca-…’
# … etc

Run manually on demand fill parameters and  it is done!

Conclusion

This pipeline turns a 30-minute manual process into a 3-minute automated, governed, and repeatable deployment. Whether you’re managing 1 or 100 analytics solutions, this pattern scales.

Below you can find entire script that you can adjust for your needs, I didn’t explain every single detail but I think right now it is pretty simple.

trigger:
- none

pool:
  vmImage: 'ubuntu-latest'

parameters:
  - name: numberOfEnvironments
    displayName: 'Number of environments'
    type: string
    default: '3'
    values:
      - '1'
      - '3'
  - name: functionalName
    displayName: 'Functional name'
    type: string
    default: ''
  - name: unit
    displayName: 'Unit'
    type: string
    default: 'Group'
    values:
      - 'Denmark'
      - 'Poland'
      - 'USA'
      - 'All'
  - name: businessReadersGroupId
    displayName: 'Business Readers Group ID'
    type: string
    default: ''
  - name: businessTestersGroupId
    displayName: 'Business Testers Group ID'
    type: string
    default: ''
  - name: businessCreatorsGroupId
    displayName: 'Business Creators Group ID'
    type: string
    default: ''

variables:
   adminGroupId:      'a1b2c3d4-5678-90ef-ghij-klmnopqrst12'
   adminGroupId2:     'e5f6g7h8-9012-34ij-klmn-opqrstuvwx34'
   opsGroupId:        'b9c8d7e6-f5a4-3b2c-1d0e-f9g8h7i6j5k4'
   viewerGroupId:     'p0o9i8u7-y6t5-r4e3-w2q1-abcdef123456'
   capacityId:        'F8G9H1J2-K3L4-M5N6-O7P8-Q9R0S1T2U3V4'

steps:
- task: AzureCLI@2
  displayName: 'Create Fabric Workspace(s) with naming pattern'
  inputs:
    azureSubscription: 'platform.bi-automation-service-connection'
    scriptType: 'bash'
    scriptLocation: 'inlineScript'
    inlineScript: |
      set -e

      # Map unit to code
      case "${{ parameters.unit }}" in
        Denmark) unitCode="DK" ;;
        Poland) unitCode="PL" ;;
        USA) unitCode="US" ;;
        "All") unitCode="" ;;
        *) unitCode="" ;;
      esac

      # Prepare workspace base name
      baseName="IT"
      if [ -n "$unitCode" ]; then
        baseName="$baseName - $unitCode"
      fi
      if [ -n "${{ parameters.functionalName }}" ]; then
        baseName="$baseName - ${{ parameters.functionalName }}"
      fi

      # Determine environments
      if [ "${{ parameters.numberOfEnvironments }}" == "3" ]; then
        envs=("DEV" "TEST" "PROD")
      else
        envs=("PROD")
      fi

      echo "Getting access token..."
      token=$(az account get-access-token --resource https://analysis.windows.net/powerbi/api --query accessToken -o tsv)

      for env in "${envs[@]}"; do
        if [ -n "$env" ]; then
          workspaceName="$baseName - [$env]"
        else
          workspaceName="$baseName"
        fi

        echo "Processing workspace: $workspaceName"

        echo "Checking if workspace exists..."
        groups_response=$(curl -s -X GET "https://api.powerbi.com/v1.0/myorg/groups" \
          -H "Authorization: Bearer $token" \
          -H "Content-Type: application/json")
        workspaceId=$(echo "$groups_response" | jq -r ".value[] | select(.name==\"$workspaceName\") | .id")

        if [ -z "$workspaceId" ]; then
          echo "Workspace does not exist. Creating $workspaceName..."
          create_response=$(curl -s -X POST "https://api.powerbi.com/v1.0/myorg/groups" \
            -H "Authorization: Bearer $token" \
            -H "Content-Type: application/json" \
            -d "{\"name\": \"$workspaceName\"}")
          workspaceId=$(echo "$create_response" | jq -r '.id')
          if [ -z "$workspaceId" ] || [ "$workspaceId" == "null" ]; then
            echo "Workspace creation failed. Response: $create_response"
            exit 1
          fi
          echo "Created workspace: $workspaceId"
        else
          echo "Workspace '$workspaceName' already exists with ID: $workspaceId"
        fi

        if [ -z "${{ variables.capacityId }}" ] || [ "${{ variables.capacityId }}" == "null" ]; then
          echo "Capacity ID is null or empty. Exiting."
          exit 1
        fi

        echo "Assigning workspace $workspaceId to capacity ${{ variables.capacityId }}..."
        assign_capacity_response=$(curl -s -X POST "https://api.powerbi.com/v1.0/myorg/groups/$workspaceId/AssignToCapacity" \
          -H "Authorization: Bearer $token" \
          -H "Content-Type: application/json" \
          -d "{\"capacityId\": \"${{ variables.capacityId }}\"}")
        if echo "$assign_capacity_response" | grep -q '"error"'; then
          echo "Failed to assign workspace to capacity. Response: $assign_capacity_response"
          exit 1
        fi

        # Get current users for the workspace
        users_response=$(curl -s -X GET "https://api.powerbi.com/v1.0/myorg/groups/$workspaceId/users" \
          -H "Authorization: Bearer $token" \
          -H "Content-Type: application/json")

        # Assign Admin permissions to both admin groups
        for adminGroup in "${{ variables.adminGroupId }}" "${{ variables.adminGroupId2 }}"; do
          has_admin=$(echo "$users_response" | jq -r ".value[] | select(.identifier==\"$adminGroup\" and .groupUserAccessRight==\"Admin\") | .identifier")
          if [ -z "$has_admin" ]; then
            echo "Assigning Admin permission to group $adminGroup..."
            assign_response=$(curl -s -X POST "https://api.powerbi.com/v1.0/myorg/groups/$workspaceId/users" \
              -H "Authorization: Bearer $token" \
              -H "Content-Type: application/json" \
              -d "{\"identifier\": \"$adminGroup\", \"groupUserAccessRight\": \"Admin\", \"principalType\": \"Group\"}")
            if echo "$assign_response" | grep -q '"error"'; then
              echo "Failed to assign admin group. Response: $assign_response"
              exit 1
            fi
          else
            echo "Group $adminGroup already has Admin permission. Skipping."
          fi
        done

        # Assign Contributor (Ops)
        has_contributor=$(echo "$users_response" | jq -r ".value[] | select(.identifier==\"${{ variables.opsGroupId }}\" and .groupUserAccessRight==\"Contributor\") | .identifier")
        if [ -z "$has_contributor" ]; then
          echo "Assigning Contributor permission to group ${{ variables.opsGroupId }}..."
          assign_ops_response=$(curl -s -X POST "https://api.powerbi.com/v1.0/myorg/groups/$workspaceId/users" \
            -H "Authorization: Bearer $token" \
            -H "Content-Type: application/json" \
            -d "{\"identifier\": \"${{ variables.opsGroupId }}\", \"groupUserAccessRight\": \"Contributor\", \"principalType\": \"Group\"}")
          if echo "$assign_ops_response" | grep -q '"error"'; then
            echo "Failed to assign ops group. Response: $assign_ops_response"
            exit 1
          fi
        else
          echo "Group ${{ variables.opsGroupId }} already has Contributor permission. Skipping."
        fi

        # Assign Viewer
        has_viewer=$(echo "$users_response" | jq -r ".value[] | select(.identifier==\"${{ variables.viewerGroupId }}\" and .groupUserAccessRight==\"Viewer\") | .identifier")
        if [ -z "$has_viewer" ]; then
          echo "Assigning Viewer permission to group ${{ variables.viewerGroupId }}..."
          assign_viewer_response=$(curl -s -X POST "https://api.powerbi.com/v1.0/myorg/groups/$workspaceId/users" \
            -H "Authorization: Bearer $token" \
            -H "Content-Type: application/json" \
            -d "{\"identifier\": \"${{ variables.viewerGroupId }}\", \"groupUserAccessRight\": \"Viewer\", \"principalType\": \"Group\"}")
          if echo "$assign_viewer_response" | grep -q '"error"'; then
            echo "Failed to assign viewer group. Response: $assign_viewer_response"
            exit 1
          fi
        else
          echo "Group ${{ variables.viewerGroupId }} already has Viewer permission. Skipping."
        fi

        # Assign Business groups as Viewers on PROD only
        if [ "$env" == "PROD" ]; then
          for groupId in "${{ parameters.businessReadersGroupId }}" "${{ parameters.businessTestersGroupId }}" "${{ parameters.businessCreatorsGroupId }}"; do
            if [ -n "$groupId" ]; then
              has_biz_viewer=$(echo "$users_response" | jq -r ".value[] | select(.identifier==\"$groupId\" and .groupUserAccessRight==\"Viewer\") | .identifier")
              if [ -z "$has_biz_viewer" ]; then
                echo "Assigning Viewer permission to business group $groupId on PROD..."
                assign_biz_response=$(curl -s -X POST "https://api.powerbi.com/v1.0/myorg/groups/$workspaceId/users" \
                  -H "Authorization: Bearer $token" \
                  -H "Content-Type: application/json" \
                  -d "{\"identifier\": \"$groupId\", \"groupUserAccessRight\": \"Viewer\", \"principalType\": \"Group\"}")
                if echo "$assign_biz_response" | grep -q '"error"'; then
                  echo "Failed to assign business group as viewer. Response: $assign_biz_response"
                  exit 1
                fi
              else
                echo "Business group $groupId already has Viewer permission on PROD. Skipping."
              fi
            fi
          done
        fi

        # Only assign TEST permissions if numberOfEnvironments is 3
        if [ "${{ parameters.numberOfEnvironments }}" == "3" ]; then
          # Assign Business Testers and Creators as Viewers on TEST
          if [ "$env" == "TEST" ]; then
            for groupId in "${{ parameters.businessTestersGroupId }}" "${{ parameters.businessCreatorsGroupId }}"; do
              if [ -n "$groupId" ]; then
                has_biz_viewer=$(echo "$users_response" | jq -r ".value[] | select(.identifier==\"$groupId\" and .groupUserAccessRight==\"Viewer\") | .identifier")
                if [ -z "$has_biz_viewer" ]; then
                  echo "Assigning Viewer permission to business group $groupId on TEST..."
                  assign_biz_response=$(curl -s -X POST "https://api.powerbi.com/v1.0/myorg/groups/$workspaceId/users" \
                    -H "Authorization: Bearer $token" \
                    -H "Content-Type: application/json" \
                    -d "{\"identifier\": \"$groupId\", \"groupUserAccessRight\": \"Viewer\", \"principalType\": \"Group\"}")
                  if echo "$assign_biz_response" | grep -q '"error"'; then
                    echo "Failed to assign business group as viewer. Response: $assign_biz_response"
                    exit 1
                  fi
                else
                  echo "Business group $groupId already has Viewer permission on TEST. Skipping."
                fi
              fi
            done
          fi
        fi

        echo "Workspace $workspaceName processed successfully."
      done

I hope you find it useful!

Adrian Chodkowski
Follow me

Leave a Reply