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.
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.