12. 09. 2025 Francesco Belacca Azure, Microsoft, Power Platform

Bulk-assigning Power Apps to Flow Owners

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.

Why This Route?

  • We couldn’t use the Microsoft.Graph PowerShell SDK or the PowerApps modules in our environment. They were effectively Windows-specific for us and did not fit our Linux runners and Entra ID policy.
  • To stay cross-platform and simple, we used az CLI for token minting and manual API calls to Flow and Graph.

Some Context

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.

What Breaks at Scale?

  • Permission endpoints differ (/scopes/admin/.../permissions vs /environments/.../permissions)
  • Role names differ (Owner vs CanEdit) from what you’d expect
  • Throttling hits large batches

Design goals

  • No heavy modules: az account get-access-token only
  • Idempotent: Skip when the principal already has edit rights
  • Targeted: Filter by DisplayName prefix (e.g., cos_)
  • Resilient: Pagination handling, retries, and per-flow error scopes
  • Portable: PowerShell 7 on Windows or Linux → this is because I develop in WSL

Architecture

  1. az login (user or workload identity)
  2. Get Flow token and Graph token
  3. Resolve UPNs to object IDs
  4. List flows by environment and prefix via Flow Admin API v2
  5. For each flow × user: read permissions, skip if already ok, add association if not
  6. Short sleeps and a single retry reduce rate-limit noise

Talk Is Cheap, Show Me the Code

#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." }

Usage Example

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
}

Pre-Checks

  • az installed and authenticated
  • Your identity has system admin rights for target environments
  • UPNs exist in the Entra ID tenant you are logged into and targeting
  • Prefix isolates only the intended flows

Security Notes

  • Tokens come from az for Flow and Graph resources: No stored secrets
  • For automation, prefer workload identities over client secrets
  • Pin with -TenantId when you operate across directories
  • Limit who can run the script and which environments they can target

Results

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

Wrap-up

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.

These Solutions are Engineered by Humans

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.

Francesco Belacca

Francesco Belacca

Author

Francesco Belacca

Latest posts by Francesco Belacca

18. 06. 2025 Microsoft
Dotnet Run App
25. 03. 2025 Azure, Microsoft
Azure Container App Jobs: Why I think they’re Great
See All

Leave a Reply

Your email address will not be published. Required fields are marked *

Archive