Skip to content
bartovsky_
Theme
martin@bartovsky.com

Notes / · 14 min

Azure DevOps for Power BI with external developers: the set-up, step by step

Entra security groups, B2B guests, group rules that hand out licences, a protected main branch and a Fabric workspace that follows it - the PowerShell and Azure CLI to set it up, and what to check on the way.

Martin BartovskyPower BIMicrosoft FabricHow-toshare on LinkedIn

The technical companion to More than one Power BI supplier? The repo belongs in the client’s tenant. The model: an Azure DevOps organisation connected to the client’s Entra tenant, external developers as B2B guests, access only through security groups, a protected main, and a Fabric workspace that syncs from it.

It runs in two organisations - my own and a client’s. Placeholders: <tenant-id> is the client’s tenant, <org> the Azure DevOps organisation, <project> the project and repo, <PROJECT> its name in group names, <guest-mail> the external developer.

Who does what: steps 1-5 need a Global Administrator (or equivalent Entra roles) of the client’s tenant and an Azure DevOps organisation owner. The guest only needs their own company account.

0. Tools

Install-Module Microsoft.Graph.Authentication, Microsoft.Graph.Identity.SignIns, Microsoft.Graph.Identity.DirectoryManagement, Microsoft.Graph.Users, Microsoft.Graph.Groups -Scope CurrentUser

winget install -e --id Microsoft.AzureCLI
az extension add --name azure-devops
$Tenant = '<tenant-id>'
Connect-MgGraph -TenantId $Tenant -Scopes "Organization.Read.All","Policy.Read.All","User.Read.All","Group.ReadWrite.All","User.Invite.All"
Get-MgOrganization | Select-Object Id, DisplayName      # the right tenant?

1. Check the tenant before inviting anyone

Who may invite guests, and what guests may see:

$ap = Get-MgPolicyAuthorizationPolicy
$guestRole = @{
  'a0b1b346-4d3e-4e8b-98f8-753987be4970' = 'same as members'
  '10dae51f-b6af-4016-8d66-8c2a99b929b3' = 'limited (default)'
  '2af84b1e-32c8-42b7-82bc-daa82404023b' = 'most restricted'
}
[pscustomobject]@{ 'Who can invite' = $ap.AllowInvitesFrom; 'Guest permissions' = $guestRole[$ap.GuestUserRoleId] }

Also check Collaboration restrictions in the portal (Entra ID → External Identities → External collaboration settings) - the supplier’s domain must not be blocked.

Cross-tenant access - is inbound B2B allowed, and is MFA from other tenants trusted?

$def = Get-MgPolicyCrossTenantAccessPolicyDefault
[pscustomobject]@{
  'Inbound B2B - users'   = $def.B2BCollaborationInbound.UsersAndGroups.AccessType
  'Inbound B2B - apps'    = $def.B2BCollaborationInbound.Applications.AccessType
  'Trust external MFA'    = $def.InboundTrust.IsMfaAccepted
}

Optionally trust MFA from the supplier’s tenant, so the guest doesn’t do MFA twice:

Connect-MgGraph -TenantId $Tenant -Scopes "Policy.ReadWrite.CrossTenantAccess"
New-MgPolicyCrossTenantAccessPolicyPartner -TenantId '<supplier-tenant-id>' -InboundTrust @{ IsMfaAccepted = $true }
# if the partner entry already exists: Update-MgPolicyCrossTenantAccessPolicyPartner ...

Conditional access policies that apply to guests - the usual reason a correctly invited guest still can’t get in:

Get-MgIdentityConditionalAccessPolicy -All | Where-Object State -eq 'enabled' | ForEach-Object {
  $u = $_.Conditions.Users
  $appliesToGuests = ($u.IncludeUsers -contains 'All') -or ($u.IncludeUsers -contains 'GuestsOrExternalUsers') -or
                     [bool]$u.IncludeGuestsOrExternalUsers.GuestOrExternalUserTypes
  if ($appliesToGuests) {
    [pscustomobject]@{
      Policy     = $_.DisplayName
      Requires   = ($_.GrantControls.BuiltInControls -join ', ')
      Apps       = ($_.Conditions.Applications.IncludeApplications -join ', ')
      Exclusions = (@($u.ExcludeUsers) + @($u.ExcludeGroups) | Where-Object { $_ }) -join ', '
    }
  }
} | Format-Table -AutoSize -Wrap

2. Two security groups

$groups = @(
  @{ Name = 'SG-ADO-<PROJECT>-Admins';  Desc = 'Azure DevOps administrators - <org>/<project>' }
  @{ Name = 'SG-ADO-<PROJECT>-Contrib'; Desc = 'Developers with write access to repo <project>' }
)
foreach ($s in $groups) {
  $g = Get-MgGroup -Filter "displayName eq '$($s.Name)'"
  if (-not $g) { New-MgGroup -DisplayName $s.Name -Description $s.Desc -MailEnabled:$false -MailNickname $s.Name -SecurityEnabled | Out-Null; "Created: $($s.Name)" }
  else         { "Exists: $($s.Name)" }
}

function Add-SgMember([string]$Group, [string]$Mail) {
  $g = Get-MgGroup -Filter "displayName eq '$Group'"
  $u = Get-MgUser -Filter "mail eq '$Mail' or userPrincipalName eq '$Mail'"
  if (-not $u) { Write-Warning "User $Mail not found"; return }
  if (Get-MgGroupMember -GroupId $g.Id -All | Where-Object Id -eq $u.Id) { "$Mail is already in $Group" }
  else { New-MgGroupMember -GroupId $g.Id -DirectoryObjectId $u.Id; "$Mail added to $Group" }
}

Add-SgMember 'SG-ADO-<PROJECT>-Admins' '<admin-mail>'

Plain security groups, not Microsoft 365 groups - nobody needs a mailbox or a Teams team for this.

3. The Azure DevOps organisation

az login --tenant $Tenant --allow-no-subscriptions
az devops configure --defaults organization=https://dev.azure.com/<org> project=<project>

# token for the REST calls in step 4
$tok = az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv
$H   = @{ Authorization = "Bearer $tok" }

In the portal, two settings:

  • Organization settings → Microsoft Entra - the organisation is connected to the client’s tenant.
  • Organization settings → Policies → External guest access = On. Without it, an invited guest still can’t get in.

Admins group into Project Collection Administrators:

$pca   = az devops security group list --scope organization --query "graphGroups[?displayName=='Project Collection Administrators'].descriptor | [0]" -o tsv
$admId = az ad group show --group 'SG-ADO-<PROJECT>-Admins' --query id -o tsv
az devops security group create --origin-id $admId --scope organization --groups $pca

az devops security group membership list --id $pca --query "*.displayName" -o tsv     # the group is listed

The project itself: az devops project create --name <project> --visibility private - or in the portal.

4. Group rules: licence and role from group membership

This is the step that makes onboarding a one-line change. Azure DevOps has no az command for group rules; the REST API does it:

$OrgName = '<org>'
$projId  = az devops project show --project <project> --query id -o tsv

function New-GroupRule([string]$Group, [string]$ProjectRole) {
  $gid  = az ad group show --group $Group --query id -o tsv
  $body = @{
    group               = @{ origin = 'aad'; originId = $gid; subjectKind = 'group' }
    licenseRule         = @{ licensingSource = 'account'; accountLicenseType = 'express' }   # 'express' = Basic
    projectEntitlements = @(@{ group = @{ groupType = $ProjectRole }; projectRef = @{ id = $projId } })
  } | ConvertTo-Json -Depth 5
  $r = Invoke-RestMethod -Method Post -Headers $H -ContentType 'application/json' -Body $body `
       -Uri "https://vsaex.dev.azure.com/$OrgName/_apis/groupentitlements?api-version=7.1"
  "{0}: succeeded = {1}" -f $Group, $r.haveResultsSucceeded
}

New-GroupRule 'SG-ADO-<PROJECT>-Contrib' 'projectContributor'
New-GroupRule 'SG-ADO-<PROJECT>-Admins'  'projectAdministrator'

Check in the portal: Organization settings → Users → Group rules, or:

$r = Invoke-RestMethod -Headers $H -Uri "https://vsaex.dev.azure.com/$OrgName/_apis/groupentitlements?api-version=7.1"
$items = if ($r.value) { $r.value } else { $r }
$items | Select-Object @{n='Group';e={$_.group.displayName}},
                       @{n='Licence';e={$_.licenseRule.licenseDisplayName}},
                       @{n='Projects';e={($_.projectEntitlements | ForEach-Object { "$($_.projectRef.name) ($($_.group.groupType))" }) -join ', '}} |
  Format-Table -AutoSize

The first five Basic users in an organisation are free; developers with a Visual Studio subscription use that instead.

5. The repo and a protected main

az repos list --project <project> --query "[].{name:name, branch:defaultBranch, url:remoteUrl}" -o table

$repoId = az repos show --repository <project> --project <project> --query id -o tsv
az repos policy merge-strategy create --repository-id $repoId --branch main `
  --blocking true --enabled true `
  --allow-squash true --allow-no-fast-forward false --allow-rebase false --allow-rebase-merge false

az repos policy list --repository-id $repoId --branch main --query "[].{policy:type.displayName, blocking:isBlocking, enabled:isEnabled}" -o table
# Require a merge strategy | True | True

Any policy on main means changes arrive only through pull requests. Squash-only keeps one commit per change.

A direct push to main is then refused with TF402455. Whether anyone can get round it depends on two repository permissions - Bypass policies when pushing and Bypass policies when completing pull requests (Project settings → Repositories → Security). Check who has them; administrators can grant them to themselves, so “nobody writes to main” is only as strong as that list.

6. Onboarding an external developer

Invite:

$Guest = '<guest-mail>'
$u = Get-MgUser -Filter "mail eq '$Guest'" -Property Id,DisplayName,UserType,ExternalUserState
if (-not $u) {
  $inv = New-MgInvitation -InvitedUserEmailAddress $Guest -InvitedUserDisplayName '<Name> (<Supplier>)' `
           -InviteRedirectUrl 'https://dev.azure.com/<org>' -SendInvitationMessage:$true
  "Invitation sent: $($inv.Status)"
} else { "Exists: $($u.DisplayName), $($u.UserType), $($u.ExternalUserState)" }

The guest accepts in their own mailbox. Then:

Get-MgUser -Filter "mail eq '$Guest'" -Property DisplayName,UserType,ExternalUserState,UserPrincipalName |
  Select-Object DisplayName, UserType, ExternalUserState, UserPrincipalName
# Guest | Accepted | name_domain#EXT#@...

Add-SgMember 'SG-ADO-<PROJECT>-Contrib' $Guest

Access isn’t instant - Azure DevOps has to pick up the group membership. In my case it took a few minutes, around five. Tell the new developer in advance, so the first “it doesn’t work” doesn’t become a support ticket.

The guest signs in at https://dev.azure.com/<org>, preferably in a private window. If offered a choice of directories, they pick the client’s. From the command line (as the guest):

az login --tenant <tenant-id> --allow-no-subscriptions
az repos list --org https://dev.azure.com/<org> --project <project> --query "[].name" -o tsv

Check the licence (as admin):

az devops user show --user $Guest --query "{user:user.mailAddress, licence:accessLevel.licenseDisplayName, source:accessLevel.assignmentSource, projects:projectEntitlements[].projectRef.name}" -o jsonc
# Basic | groupRule | <project>

A guest can be listed under the e-mail or under the #EXT# UPN. If user show doesn’t find them: az devops user list --top 200 --query "members[?contains(user.mailAddress,'<supplier-domain>')]" -o jsonc.

7. The developer’s laptop: one machine, several identities

A consultant working for several clients has several accounts in Git Credential Manager. Check which account is bound to which organisation:

git config --show-origin --get credential.helper              # manager
git credential-manager azure-repos list --show-remotes

Clone the client’s repo and give it its own identity, without --global:

git clone https://dev.azure.com/<org>/<project>/_git/<project> C:\<clients>\<project>
cd C:\<clients>\<project>
git config user.name  "<Name> (<Supplier>)"
git config user.email "<guest-mail>"

If the credential manager silently uses the wrong account, bind the account to the organisation for this clone only:

git credential-manager azure-repos bind --local <org> <guest-mail>

The other option is to put the account into the clone URL: https://<guest-mail-url-encoded>@dev.azure.com/<org>/... (with @ written as %40). Either way, check afterwards with git credential-manager azure-repos list --show-remotes that each clone uses the account you meant.

Keep repos out of OneDrive - synced .git folders get corrupted.

8. Fabric: the workspace follows main

Connect the production workspace to the repo’s main (Workspace settings → Git integration), on a subfolder such as PowerBI/<product>/ so the rest of the repo stays out of Fabric’s view.

From then on, changes reach the workspace through Source control → Update all, after a pull request has merged them into main. A commit from Fabric is just a push to the connected branch, so with a policy on main treat the production workspace as pull-only.

If people need to edit directly in Fabric, give them a separate workspace connected to its own branch (for example fabric-dev) and let their changes go to main by pull request like everyone else’s.

9. A first test with two people

Before real work starts, I run four small tests on a throwaway file in a _test/ folder outside the Fabric subfolder:

Test What happens Expected
A Guest changes line 1 locally → branch → pull request → merge Change arrives for everyone after git pull
B Change made in the web editor via PR Guest gets it with git pull
C Guest and admin change the same line, admin merges first Guest’s PR shows a conflict; resolved locally with git merge origin/main
D Guest pushes straight to main Refused with TF402455

Then remove the _test/ folder - by pull request, of course.

10. Offboarding

$g = Get-MgGroup -Filter "displayName eq 'SG-ADO-<PROJECT>-Contrib'"
$u = Get-MgUser  -Filter "mail eq '<guest-mail>'"
Remove-MgGroupMemberByRef -GroupId $g.Id -DirectoryObjectId $u.Id

Access disappears with a delay too - a few minutes in my case, not the moment you click. If the end of a contract is sensitive, remove the person from the group before the last meeting, not after.

Optionally remove the guest from the tenant altogether with Remove-MgUser -UserId $u.Id. The repository and its history stay.

[->] next step

Where do your numbers stop adding up?

Start with a short call. Bring one report nobody trusts - I'll trace it back to where the data gets captured.