TL;DR. Owner assignment on the Power Platform via the UI requires a lot of clicks. I use a small, idempotent PowerShell 7+ script that assigns co-owners to many cloud flows across many environments using az
tokens and Flow Admin REST APIs. It filters by name prefix, checks existing permissions, retries on throttling, and runs on Windows or Linux.
We cannot directly assign the System Admin role to the users of the customers that we manage.
This means that until we implement other ways to let them see the logs of the flows they create in development environments, they’re not able to effectively troubleshoot and see logs for runs in high privileged and secure environments (non-dev ones).
So while working on better ways to give them specific visibility over what they need by extracting them through AppInsights and pushing that data to NetEye, we just make users the owners of their own flows, because even if anything drifts by being manually changed in that cloud flow, we can always redeploy the latest artifact that was released in that environment that contained that flow, and fix it then.
We manage more than 80 Power Platform environments inside one of our tenants, and as you can imagine every click counts, so clickops is not an option here, and that’s why we invested time in implementing a custom, reusable script.
/scopes/admin/.../permissions
vs /environments/.../permissions
)Owner
vs CanEdit
) from what you’d expectaz account get-access-token
onlyDisplayName
prefix (e.g., cos_
)az login
(user or workload identity)
#requires -Version 7.0
<#
.SYNOPSIS
Bulk-assign co-owners to cloud flows by name prefix across multiple environments.
.PARAMETER EnvironmentIds
One or more Environment IDs (e.g. Default-xxxxxxxx-...).
.PARAMETER UserUPNs
One or more user UPNs to add as co-owners.
.PARAMETER Prefix
DisplayName prefix to match (case-insensitive). Example: "cos_".
.PARAMETER TenantId
Optional tenant ID for token acquisition.
#>
param(
[Parameter(Mandatory = $true)][string[]]$EnvironmentIds,
[Parameter(Mandatory = $true)][string[]]$UserUPNs,
[Parameter(Mandatory = $true)][string]$Prefix,
[string]$TenantId
)
# ---------- Preconditions ----------
if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
throw "Azure CLI 'az' not found in PATH."
}
try { $null = az account show 2>$null } catch { throw "Run 'az login' first or configure a service principal." }
# ---------- Tokens (no modules; az only) ----------
$tenantArg = @()
if ($TenantId) { $tenantArg = @('--tenant', $TenantId) }
$flowToken = (& az account get-access-token --resource https://service.flow.microsoft.com/ --query accessToken -o tsv @tenantArg) 2>$null
if ([string]::IsNullOrWhiteSpace($flowToken)) { throw "Failed to get Flow access token from az." }
$graphToken = (& az account get-access-token --resource-type ms-graph --query accessToken -o tsv @tenantArg) 2>$null
if ([string]::IsNullOrWhiteSpace($graphToken)) { throw "Failed to get Graph access token from az." }
# ---------- Graph helpers ----------
function Get-GraphPrincipal {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$GraphAccessToken,
[Parameter(Mandatory)][ValidateSet('User', 'Group')][string]$PrincipalType,
[Parameter(Mandatory)][string]$PrincipalObjectId # may be GUID or UPN
)
$headers = @{ Authorization = "Bearer $GraphAccessToken" }
$select = 'id,displayName,mail,userPrincipalName'
# Always URL-encode the key for path-segment addressing
$key = [System.Uri]::EscapeDataString($PrincipalObjectId)
$url = if ($PrincipalType -eq 'User') {
"https://graph.microsoft.com/v1.0/users/$key?`$select=$select"
}
else {
"https://graph.microsoft.com/v1.0/groups/$key?`$select=id,displayName,mail,mailNickname"
}
try {
$r = Invoke-RestMethod -Method GET -Headers $headers -Uri $url
}
catch {
throw "Graph lookup failed for $PrincipalType '$PrincipalObjectId'. $($_.Exception.Message)"
}
if (-not $r.id) { throw "Principal not found in Graph: $PrincipalObjectId" }
$email = if ($r.mail) { $r.mail } elseif ($PrincipalType -eq 'User' -and $r.userPrincipalName) { $r.userPrincipalName } else { $null }
[PSCustomObject]@{
Id = $r.id
Type = $PrincipalType
DisplayName = $r.displayName
Email = $email
}
}
# ---------- Flow Admin helpers ----------
function Get-AdminFlowsByPrefix {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$FlowAccessToken,
[Parameter(Mandatory)][string]$EnvironmentId,
[Parameter(Mandatory)][string]$Prefix
)
$headers = @{ Authorization = "Bearer $FlowAccessToken" }
$url = "https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/scopes/admin/environments/$EnvironmentId/v2/flows?api-version=2016-11-01"
$all = @()
while ($url) {
$resp = Invoke-RestMethod -Method GET -Headers $headers -Uri $url
if ($resp.value) { $all += $resp.value }
# page through both styles
$next = $resp.nextLink
if (-not $next -and $resp.PSObject.Properties.Name -contains '@odata.nextLink') {
$next = $resp.'@odata.nextLink'
}
$url = if ($next) { $next } else { $null }
}
$all |
Where-Object {
$dn = $_.properties.displayName
$dn -and $dn.StartsWith($Prefix, [System.StringComparison]::OrdinalIgnoreCase)
} |
ForEach-Object {
[PSCustomObject]@{
FlowId = $_.name
DisplayName = $_.properties.displayName
State = $_.properties.state # may be null in V2
}
}
}
function Set-FlowOwnerRole {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)][string]$FlowAccessToken,
[Parameter(Mandatory)][string]$GraphAccessToken,
[Parameter(Mandatory)][string]$EnvironmentId,
[Parameter(Mandatory)][string]$FlowId,
[Parameter(Mandatory)][ValidateSet('User', 'Group')][string]$PrincipalType,
[Parameter(Mandatory)][ValidateSet('CanView', 'CanEdit')][string]$RoleName,
[Parameter(Mandatory)][string]$PrincipalObjectId
)
$flowHeaders = @{ Authorization = "Bearer $FlowAccessToken"; 'Content-Type' = 'application/json' }
# Use bare minimum. Flow accepts id+type.
$p = [PSCustomObject]@{ Id = $PrincipalObjectId; Type = $PrincipalType; DisplayName = $null; Email = $null }
$principal = @{ id = $p.Id; type = $p.Type }
if ($p.DisplayName) { $principal.displayName = $p.DisplayName }
if ($p.Email) { $principal.email = $p.Email }
$body = @{ put = @(@{ properties = @{ principal = $principal; roleName = $RoleName } }) } | ConvertTo-Json -Depth 6
$url = "https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/scopes/admin/environments/$EnvironmentId/flows/$FlowId/modifyPermissions?api-version=2016-11-01"
if ($PSCmdlet.ShouldProcess("$FlowId", "modifyPermissions $RoleName for $($p.Type) $($p.Id)")) {
Invoke-RestMethod -Method POST -Headers $flowHeaders -Uri $url -Body $body | Out-Null
[PSCustomObject]@{
EnvironmentId = $EnvironmentId
FlowId = $FlowId
PrincipalId = $p.Id
PrincipalType = $p.Type
PrincipalName = $p.DisplayName
RoleApplied = $RoleName
}
}
}
# ---------- Flow permissions helpers ----------
function Get-FlowPermissions {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$FlowAccessToken,
[Parameter(Mandatory)][string]$EnvironmentId,
[Parameter(Mandatory)][string]$FlowId
)
$headers = @{ Authorization = "Bearer $FlowAccessToken" }
# Try admin scope first, then non-admin as fallback (API shapes differ across tenants)
$candidates = @(
"https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/scopes/admin/environments/$EnvironmentId/flows/$FlowId/permissions?api-version=2016-11-01",
"https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/environments/$EnvironmentId/flows/$FlowId/permissions?api-version=2016-11-01"
)
$resp = $null; $lastErr = $null
foreach ($u in $candidates) {
try { $resp = Invoke-RestMethod -Method GET -Headers $headers -Uri $u; if ($resp) { break } }
catch { $lastErr = $_ }
}
if (-not $resp) { throw "Failed to list permissions for flow '$FlowId' in '$EnvironmentId'. $($lastErr.Exception.Message)" }
# Normalize to an array of permission items
$items = if ($resp.value) { $resp.value } else { $resp }
$items | ForEach-Object {
[PSCustomObject]@{
PermissionId = $_.name
RoleName = $_.properties.roleName
PrincipalId = $_.properties.principal.id
PrincipalType = $_.properties.principal.type
}
}
}
function Test-FlowHasEditPermission {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$FlowAccessToken,
[Parameter(Mandatory)][string]$EnvironmentId,
[Parameter(Mandatory)][string]$FlowId,
[Parameter(Mandatory)][string]$PrincipalObjectId
)
# Treat either 'Owner' or 'CanEdit' as edit rights (varies by API surface)
$editRoles = @('Owner', 'CanEdit')
try {
$perms = Get-FlowPermissions -FlowAccessToken $FlowAccessToken -EnvironmentId $EnvironmentId -FlowId $FlowId
return $perms | Where-Object {
$_.PrincipalId -ieq $PrincipalObjectId -and ($editRoles -contains $_.RoleName)
} | ForEach-Object { $true } | Select-Object -First 1
}
catch {
Write-Warning " Permission check failed (will attempt add anyway). $_"
return $false
}
}
# ---------- Resolve UPNs to Object IDs using az ----------
$userIdByUpn = @{}
foreach ($upn in $UserUPNs) {
try {
$argst = @('ad', 'user', 'show', '--id', $upn, '--query', 'id', '-o', 'tsv')
$id = (& az $argst) 2>$null
if ([string]::IsNullOrWhiteSpace($id)) { Write-Warning "Cannot resolve UPN '$upn' via az. Skipping."; continue }
$userIdByUpn[$upn] = $id.Trim()
}
catch { Write-Warning "Cannot resolve UPN '$upn'. Skipping. $_" }
}
if ($userIdByUpn.Count -eq 0) { throw "No UPNs resolved. Aborting." }
# ---------- Execute ----------
$assigned = 0
$errors = 0
foreach ($env in $EnvironmentIds) {
Write-Host "`nEnvironment: $env"
try {
$flows = Get-AdminFlowsByPrefix -FlowAccessToken $flowToken -EnvironmentId $env -Prefix $Prefix
}
catch {
Write-Warning "Failed to list flows in environment $env. $_"
continue
}
if (-not $flows) { Write-Host " No flows with prefix '$Prefix'."; continue }
foreach ($f in $flows) {
Write-Host " Flow: $($f.DisplayName) [$($f.FlowId)] State=$($f.State)"
foreach ($kvp in $userIdByUpn.GetEnumerator()) {
$upn = $kvp.Key; $oid = $kvp.Value
try {
if (Test-FlowHasEditPermission -FlowAccessToken $flowToken -EnvironmentId $env -FlowId $f.FlowId -PrincipalObjectId $oid) {
Write-Host " Already co-owner: $upn"
continue
}
$res = Set-FlowOwnerRole -FlowAccessToken $flowToken -GraphAccessToken $graphToken `
-EnvironmentId $env -FlowId $f.FlowId -PrincipalType User -RoleName CanEdit -PrincipalObjectId $oid
if ($res) { Write-Host " Co-owner added: $upn"; $assigned++ }
Start-Sleep -Milliseconds 300 # avoid throttling
}
catch {
Write-Warning " Failed to add $upn. $_"
# We retry once after a short wait in case of throttling
Start-Sleep -Seconds 2
try {
$res = Set-FlowOwnerRole -FlowAccessToken $flowToken -GraphAccessToken $graphToken `
-EnvironmentId $env -FlowId $f.FlowId -PrincipalType User -RoleName CanEdit -PrincipalObjectId $oid
if ($res) { Write-Host " Co-owner added: $upn"; $assigned++ }
}
catch {
Write-Warning " Retry failed to add $upn. $_"
$errors++
}
}
}
}
}
Write-Host "`nDone. Co-owner assignments attempted: $assigned"
if ($errors) { Write-Warning "There were $errors errors." }
Start-Transcript -Path "$PSScriptRoot\Init-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" -Force
try {
$envs = @(
'Default-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
'08e1bb51-0532-4141-921b-1c05c7752b4c'
)
$users = @(
'alice@contoso.com',
'bob@contoso.com'
)
./BulkAssignOwners.ps1 -EnvironmentIds $envs `
-UserUPNs $users -Prefix 'cos_' `
-TenantId '419d9dd3-a9e4-40b0-b85c-05e2dd217a3f'
}
catch {
Write-Error $_
}
finally {
Write-Host "Script completed."
Stop-Transcript
}
az
installed and authenticatedaz
for Flow and Graph resources: No stored secrets-TenantId
when you operate across directoriesUsed in production to assign co-owners across hundreds of flows in minutes. After adding idempotence, dual-endpoint reads, and sleeps, errors dropped to near zero. The console log served as an audit trail.
Keep bulk owner assignment boring and predictable: use strict targeting via prefixes, idempotent checks, minimal writes, and cross-platform tooling. Skip heavy modules when they don’t fit. az
+ REST is enough.
Are you passionate about performance metrics or other modern IT challenges? Do you have the experience to drive solutions like the one above? Our customers often present us with problems that need customized solutions. In fact, we’re currently hiring for roles just like this as well as other roles here at Würth Phoenix.