Site icon Blog.Chiffers.com

Power Platform at Scale: Auditing Every Connector Across Your Entire Tenant


TL;DR

Before you touch a single DLP policy in Power Platform, you need to know what connectors are actually in use across your tenant. I built a PowerShell script that audits every Cloud Flow, Power App, Custom Connector, Copilot Studio Agent, and Desktop Flow across all environments — resolving the internal GUIDs to friendly connector names using the Flow API, PowerApps API, version history snapshots, and Dataverse. This post is a full technical deep dive: the API architecture, the data model, the dead ends, the version history breakthrough, and everything you need to translate the output into a real DLP policy without breaking anything.

Why I Built This

I was tasked with implementing a DLP (Data Loss Prevention) policy in Power Platform to lock down the Default environment — moving approved Microsoft connectors to Business, and blocking premium and third-party ones. Simple enough on paper. Except before you do that, you really want to know what you are going to break.

The Power Platform Admin Center does have an inventory view, but you have to click into each app individually to see its connectors. With hundreds of apps and flows across a dozen environments, that was not going to work. I needed a bulk export.

What followed was a longer journey than expected. The PowerShell modules did not expose connector data the way I assumed. The APIs returned GUIDs instead of names. Connection references turned out to be per-tenant, not globally resolvable. Copilot Studio agents store their connector usage in Dataverse rather than the Flow API. And the solution to the orphaned GUID problem turned out to be sitting undocumented in the app version history endpoint the whole time. This post documents every step, every dead end, and exactly what is going on under the hood.

What Power Platform DLP Actually Covers

Before jumping into the script, it is worth being precise about scope. DLP policies in Power Platform are connector classification policies — they define which connectors can coexist in the same app or flow, and which connectors are blocked from use entirely. The three buckets are Business, Non-Business, and Blocked.

The key rule is that Business and Non-Business connectors cannot be used together in the same resource. A flow that uses SharePoint (Business) and Dropbox (Non-Business) will be suspended. A canvas app that uses Office 365 Users (Business) and Twitter (Non-Business) will fail to save. Blocked connectors cannot be used at all regardless of what else is in the resource.

DLP policies apply to the following resource types:

  • Canvas Apps — enforcement happens at save time and at runtime. If a user tries to save an app that violates the policy, the save is blocked. If a policy changes after an app is published, the app will throw an AppForbidden error at runtime with a UCI DLP violation message.
  • Cloud Flows — enforcement happens at save time. If a policy change causes a previously valid flow to become non-compliant, the flow is immediately suspended with a flowSuspensionReason of DlpViolation. Suspended flows do not auto-resume — you must fix the flow and manually turn it back on.
  • Custom Connectors — these are unclassified by default and sit in Non-Business. This is a significant gotcha. A flow that mixes any Business connector with an unclassified custom connector will be suspended, even if the custom connector calls an internal API.
  • Copilot Studio Agents — agents inherit the DLP policy of the environment they live in. Their connector usage goes through child Power Automate flows triggered from agent topics. The agent object itself does not hold connector references.
  • Desktop Flows (Power Automate Desktop / RPA) — desktop flows are explicitly excluded from connector DLP policies. They interact with desktop applications and do not use the connector framework at all.
  • Power Pages — Power Pages sites connect to Dataverse natively without a connector. Any external connector usage goes through associated cloud flows, which are covered by the flow audit.

The most dangerous consequence of applying a DLP policy to an active environment is flow suspension. Unlike canvas apps which simply fail gracefully at runtime, suspended flows stop executing entirely. Any scheduled or automated business process built on a suspended flow silently stops working until someone notices and manually fixes it. This is why you audit first.

How DLP Enforcement Actually Works Internally

Understanding this helps explain some of the API behaviour you will encounter. When you save or run a resource, the Power Platform policy engine evaluates the resource’s connectionReferences against all active DLP policies scoped to that environment. Each connection reference has an apiId which is the connector’s identifier (e.g. /providers/microsoft.powerapps/apis/shared_sharepointonline). The engine looks up each apiId in the active policy’s connector classifications and checks whether any two connectors in the same resource are in different data groups (Business vs Non-Business) or whether any connector is in the Blocked group.

This is why the connection reference GUID is not the same as the connector ID. The GUID is a per-tenant, per-environment identifier for a specific configured connection (a specific user’s SharePoint connection, for example). The connector ID (shared_sharepointonline) is the global identifier for the SharePoint connector type. The DLP engine only cares about the connector ID, not the connection GUID — but the PowerShell module and most API responses give you the GUID, not the ID. Resolving GUIDs to connector IDs is the core problem this script solves.

The Power Platform API Architecture

This is where things get complicated. Power Platform is not one API — it is at least four distinct API surfaces, each with different authentication requirements, different base URLs, different pagination implementations, and different ownership of different resource types. Understanding which API owns what is essential before you write a single line of code.

The Four APIs

The Flow API at api.flow.microsoft.com (or the region-specific variant, e.g. unitedkingdom.api.flow.microsoft.com) owns cloud flows. It requires a token scoped to https://service.flow.microsoft.com/. It returns flow objects but — critically — the summary objects returned by list operations do not include connection reference data. You have to fetch each flow individually with $expand=definition to get the full definition including connectionReferences. This makes bulk auditing slow.

The PowerApps API at unitedkingdom.api.powerapps.com owns canvas apps, custom connectors, and connections. It requires a token scoped to https://service.powerapps.com/. The PowerShell module (Microsoft.PowerApps.Administration.PowerShell) wraps this API, but the module does not expose all the properties that the raw API returns — in particular, the full connectionReferences object on app definitions is not surfaced by Get-AdminPowerApp. You need to call the API directly.

The Business Application Platform API at api.bap.microsoft.com owns environment management. It requires a token scoped to https://api.bap.microsoft.com/. Confusingly, the PowerShell module’s Get-AdminPowerAppEnvironment hits this API internally, but the raw endpoint is also callable directly. It is the right place for environment listing but not for resource-level data.

The Dataverse Web API at https://[orgname].api.crm[n].dynamics.com/api/data/v9.2/ is a completely separate API surface per environment. It requires a token scoped specifically to that environment’s Dataverse instance URL — you cannot reuse any of the other tokens. It owns Copilot Studio agents (the bots table), desktop flows (the workflows table with category eq 6), and a large amount of other Power Platform metadata stored in Dataverse.

Region-Specific Endpoints

This is an important detail that burned me early on. The global endpoint api.flow.microsoft.com works for the default environment but not reliably for region-specific environments. Each environment object returned by the environments API includes a runtimeEndpoints property with region-specific URLs:

{
"runtimeEndpoints": {
"microsoft.BusinessAppPlatform": "https://unitedkingdom.api.bap.microsoft.com",
"microsoft.CommonDataModel": "https://unitedkingdom.api.cds.microsoft.com",
"microsoft.PowerApps": "https://unitedkingdom.api.powerapps.com",
"microsoft.Flow": "https://unitedkingdom.api.flow.microsoft.com"
}
}

Always use $env.properties.runtimeEndpoints."microsoft.Flow" when constructing flow API calls rather than hardcoding the global endpoint. On a UK tenant every environment routes to unitedkingdom.api.flow.microsoft.com. On a US tenant it will be different. The script uses this dynamically so it works regardless of region.

OAuth Audiences and Token Scoping

Each API has a specific OAuth resource audience. Getting the wrong audience gives you an InvalidAuthenticationAudience error that tells you exactly which audiences are accepted — which is actually quite useful for debugging. The valid audiences for Power Platform work are:

https://service.flow.microsoft.com/ # Flow API
https://service.powerapps.com/ # PowerApps API
https://api.bap.microsoft.com/ # BAP API
https://[orgname].api.crm11.dynamics.com/ # Dataverse (per-instance)

Using the Azure CLI to get tokens avoids having to register an app in Entra or manage client secrets. The CLI handles the OAuth flow against the Microsoft Azure PowerShell first-party app registration (1950a258-227b-4e31-a9cf-717495945fc2), which has the delegated permissions needed for all of these APIs as long as the user running it has the right roles.

One critical detail: Dataverse tokens must be scoped to the specific instance URL with a trailing slash. https://org53fa3f56.api.crm11.dynamics.com/ works. https://org53fa3f56.api.crm11.dynamics.com without the slash fails with a 401. This is inconsistent with how every other Microsoft API handles token audiences and is not documented anywhere I could find.

Connection References vs Connections: The Data Model

This distinction is at the heart of the GUID problem and it took me a while to fully understand it. There are two separate concepts that are easily confused.

Connections

A connection is a configured, authenticated link between a specific user and a specific connector. When a user goes to Power Automate and clicks “New connection” for SharePoint, they create a connection object. That connection has a GUID like shared-sharepointonl-434ee46b-17d5-4f76-bac6-7ba2107df2c3 and is owned by that specific user. The connection stores the OAuth tokens or credentials needed to call the connector’s backend service. The connection name (the GUID) is what Get-AdminPowerAppConnection returns as ConnectionName.

The raw connection object looks like this:

{
"ConnectionName": "shared-sharepointonl-434ee46b-17d5-4f76-bac6-7ba2107df2c3",
"ConnectorName": "shared_sharepointonline",
"DisplayName": "user@domain.com",
"Statuses": [{ "status": "Connected" }],
"CreatedBy": { "userPrincipalName": "user@domain.com" }
}

The ConnectorName field (e.g. shared_sharepointonline) is the global connector type identifier — strip the shared_ prefix and you have a human-readable name. This is what the first enrichment pass in the script extracts.

Connection References

A connection reference is a different concept. It is an abstraction layer used in solutions and apps that decouples the app/flow definition from the specific connection being used. Instead of hardcoding a specific user’s SharePoint connection into a flow, a solution-aware flow references a connection reference logical name. The connection reference is then mapped to an actual connection at deployment time.

In a canvas app’s definition, connection references look like this in the connectionReferences property:

{
"5f6f8361-9b57-4ade-9281-f2c09cc5adcc": {
"id": "/providers/microsoft.powerapps/apis/shared_excelonlinebusiness",
"displayName": "Excel Online (Business)",
"apiTier": "Standard",
"isCustomApiConnection": false
}
}

The key (5f6f8361...) is the connection reference GUID. It is generated when the connection reference is created and is unique to that app within that tenant. The id field contains the global connector identifier. The displayName is the friendly name. This is what we need for DLP classification purposes — and it is what the second and third enrichment passes extract.

The fundamental reason GUIDs are hard to resolve is that they are generated per-app, per-tenant. There is no global registry that maps GUID to connector type. The only source of truth is the app definition itself, which is why the version history approach works even when the connection has been deleted — the GUID and its associated connector metadata are baked into every saved version of the app.

Why Non-Solution Apps Return Different Data

Canvas apps that are not solution-aware (older apps, apps created outside solutions) store their connection references differently. The Get-AdminPowerApp cmdlet returns a summary object where Internal.properties.connectionReferences contains the connection reference GUIDs — but without the connector metadata. To get the full object with displayName and id fields, you have to call the raw PowerApps API:

GET https://unitedkingdom.api.powerapps.com/providers/Microsoft.PowerApps/apps/{appId}?api-version=2021-02-01

This returns the full app definition including the enriched connectionReferences object with display names. The PowerShell module does not surface this data and there is no parameter to request it — you have to go directly to the API.

The Dead Ends: What Did Not Work and Why

I want to document these properly because every one of them points to something real about how the API is designed, and knowing why they fail saves you time.

Get-AdminPowerAppConnector

This cmdlet exists in the Microsoft.PowerApps.Administration.PowerShell module and its name strongly implies it will return a list of connectors with their IDs. It returns 0 results. After digging through the module source, it turns out this cmdlet calls an endpoint that requires a specific environment context and returns connectors available in that environment — but the underlying API it calls appears to have been deprecated or requires permissions that the admin role does not grant by default. Do not use it. Use Get-AdminPowerAppConnection instead.

Microsoft’s Public Connector Registry on GitHub

Microsoft publishes connector definitions at github.com/microsoft/PowerPlatformConnectors. Each connector has a JSON definition file. Logical conclusion: map the GUIDs against this registry. Does not work. The GUIDs in connection references are not the same as any identifier in the GitHub registry. The GitHub repo uses connector names (sharepointonline, excelonlinebusiness) not GUIDs. The GUIDs are generated per-tenant per-app and have no relationship to the global connector identifiers in the public registry.

Get-AdminFlow Without Expand

The Get-AdminFlow cmdlet returns flow summary objects. These objects have an Internal.properties.connectionReferences field but it is null in the summary response. To get connection reference data you must fetch each flow individually via the raw API with $expand=definition. The module does not support this parameter. This means for a thorough audit you are making one API call per flow, which is why the script is slow on large environments.

# This is the only way to get connection reference data from a flow
GET {flowEndpoint}/providers/Microsoft.ProcessSimple/environments/{envId}/flows/{flowId}?$expand=definition&api-version=2016-11-01

The Admin Scoped Flow Endpoint

There is an admin-scoped Flow endpoint at /providers/Microsoft.ProcessSimple/scopes/admin/environments/{envId}/flows that theoretically allows an admin to list all flows in any environment. In practice, calling it returns:

{
"error": {
"code": "CannotListFlowsAsAdminWithDefinition",
"message": "The List Flows as Admin API is no longer supported. Please use the List Flows as Admin (V2) API."
}
}

The V2 admin API endpoint at /scopes/admin/environments/{envId}/v2/flows also returns 0 results in practice. What this reveals is that admin-scoped flow listing without environment membership is genuinely not supported in the current API. You can only list flows in environments where you have been explicitly added as an Environment Admin or where you are the owner of the flows. Power Platform Administrator at the tenant level does not grant implicit access to all environment data — only to environment management operations.

The $top=250 Pagination Error

Early versions of the script used $top=250 for pagination. This returns:

{
"error": {
"code": "InvalidTopInQueryString",
"message": "Invalid top value - '250'. Top value must be positive integer less than or equal to 50."
}
}

The Flow API max page size is 50, not 100 or 250. Use $top=50 and follow the nextLink in each response for pagination. The nextLink field is present in the response when there are more pages and absent when you are on the last page.

Solution-Aware Flows Invisible in Standard Listing

Flows that are part of a Dataverse solution do not appear in the standard flow listing endpoint. They require a separate call with the include=includeSolutionCloudFlows parameter. This is not documented in the main API reference. If your environment uses solutions heavily (which Dynamics 365 and Copilot Studio environments do by default), you will miss the majority of flows without this parameter. The script makes two passes for every environment — one without the parameter and one with it — and deduplicates by flow name.

The $input Reserved Variable

If you import the audit CSV into a variable called $input in PowerShell and then try to iterate it, you will get an empty array. $input is an automatic variable in PowerShell representing the pipeline input object. Assigning to it does not work as expected and silently produces no results. Use any other variable name. I used $csvData. This one cost me more time than I want to admit.

The Version History Breakthrough

After exhausting every other approach, around 23 GUIDs remained unresolved. These belonged to apps whose active connection objects had been deleted — either because the user left, the connection expired and was cleaned up, or the app was imported as a template and the connections were never configured. No active connection in any environment, no match in the global connector registry, no result from the API connector listing endpoint.

The breakthrough came from inspecting the raw response from the /versions endpoint on the PowerApps API:

GET https://unitedkingdom.api.powerapps.com/providers/Microsoft.PowerApps/apps/{appId}/versions?api-version=2021-02-01

This returns a list of every saved version of the app, going back to creation. Each version object includes a complete appDefinition snapshot with the full connectionReferences object as it existed at the time of that save:

{
"name": "20250826T095228Z",
"properties": {
"appDefinition": {
"properties": {
"connectionReferences": {
"5f6f8361-9b57-4ade-9281-f2c09cc5adcc": {
"id": "/providers/microsoft.powerapps/apis/shared_excelonlinebusiness",
"displayName": "Excel Online (Business)",
"apiTier": "Standard"
}
}
}
}
}
}

Power Platform stores a full snapshot of the app definition at every save point, and that snapshot includes the connector metadata — the displayName, the id, the apiTier — regardless of whether the underlying connection still exists. The connection reference GUID and its associated connector type are part of the app definition, not part of the connection object. They survive connection deletion.

This means that even for apps with no active connections, no configured connection references, and connections deleted years ago, the version history will still tell you what connector type the GUID represents. Iterating all versions for all apps across all environments and building the lookup from the results resolved all but a handful of the remaining GUIDs — and the few that remained were genuinely unresolvable because the apps themselves had been deleted at the Dataverse level, leaving orphaned references in the audit data.

I did not find this approach documented anywhere in Microsoft’s official documentation. It came from systematically inspecting raw API responses until I found a property path that contained the data I needed.

The Permissions Model: Why You Cannot See Everything

This is worth understanding in detail because the access model is genuinely complex and the failure modes are silent — you just get 0 results rather than an error.

Power Platform Administrator

The Power Platform Administrator role in Entra (formerly Azure AD) grants administrative control over the Power Platform service — creating and deleting environments, managing DLP policies, viewing billing and capacity, managing connectors and connections at the tenant level. It does NOT automatically grant data access to all environments.

Specifically, Power Platform Administrator does not make you an Environment Admin or System Administrator in each environment’s Dataverse instance. This means you can list flows via the environment’s management API, but you cannot read data from Dataverse tables in environments where you have not been explicitly added as a System Administrator or System Customizer.

Environment Admin

Environment Admin is a role assigned within a specific environment (in Power Platform Admin Center, under the environment’s Settings). It gives you administrative access to that environment’s resources — flows, apps, connections. An Environment Admin can list all flows and apps in their environment regardless of who owns them. Without this role, the Flow API only returns resources owned by the calling user.

If you are getting 0 flows for an environment where you know flows exist, the most likely cause is that you are not an Environment Admin in that environment. Adding yourself via Power Platform Admin Center resolves this, but it requires awareness of the environment and explicit action per environment.

System Administrator in Dataverse

To query Dataverse tables (for Copilot Studio agents, desktop flows, and other Dataverse-stored resources), you need the System Administrator or System Customizer security role in that specific Dataverse environment. This is assigned in the environment’s Dataverse settings, separate from the Power Platform Admin Center. Power Platform Administrator at the tenant level does not grant this.

The error you get when missing this role is instructive:

{
"error": {
"code": "0x80042f09",
"message": "The user with id {guid} has not been assigned any roles. They need a role with the prvReadbotcomponent privilege."
}
}

The privilege name in the error message tells you exactly which Dataverse table you are trying to read and what security role privilege you need. prvReadbotcomponent means you are trying to read the botcomponent table and need a role that includes that privilege — System Administrator or a custom role with that specific privilege.

PIM and Role Expiry

If your organisation uses Privileged Identity Management (PIM) for just-in-time access, both your Power Platform Administrator role and any Dataverse security roles may have time-limited activations. The script can take 20-40 minutes to run on a large tenant. If your PIM activation expires mid-run, the Azure CLI token acquisition will fail silently (returning an empty string rather than an error) and subsequent API calls will return 401 or empty results.

If you are seeing inconsistent results between runs, check your PIM activation status. The symptom is that early environments in the run have full data and later ones show 0 results.

The Full Script

Prerequisites:

  • Power Platform Administrator role active in Entra (activate via PIM if required)
  • Environment Admin in each environment you want to audit
  • System Administrator in each Dataverse environment (for agents and desktop flows)
  • Azure CLI installed and authenticated (az login --tenant YOUR-TENANT-ID)
  • Microsoft.PowerApps.Administration.PowerShell module: Install-Module -Name Microsoft.PowerApps.Administration.PowerShell -Force
  • Replace YOUR-TENANT-ID-HERE with your tenant ID (find it in Entra admin center or Azure Portal)
  • If you are not on a UK tenant, replace the unitedkingdom.api.powerapps.com endpoint with your region’s equivalent

# ============================================================
# Power Platform Full Connector Audit
# Craig Chiffers - blog.chiffers.com
# ============================================================

Add-PowerAppsAccount

$tenantId = "YOUR-TENANT-ID-HERE"
$ukPowerApps = "https://unitedkingdom.api.powerapps.com"

# ============================================================
# STEP 1 - Get tokens
# Three separate OAuth audiences required:
# - service.flow.microsoft.com : Flow API
# - service.powerapps.com : PowerApps API
# - [dataverse-url]/ : per-environment Dataverse (fetched in Step 8)
# ============================================================
Write-Host "`n[1/8] Getting tokens..." -ForegroundColor Cyan
$tokenFlow = az account get-access-token --resource "https://service.flow.microsoft.com/" --tenant $tenantId --query accessToken -o tsv
$tokenPA = az account get-access-token --resource "https://service.powerapps.com/" --tenant $tenantId --query accessToken -o tsv
Write-Host "Tokens acquired" -ForegroundColor Green

# ============================================================
# STEP 2 - Build connection lookup from active connections
# Get-AdminPowerAppConnection returns connection objects with:
# ConnectionName = per-tenant GUID
# ConnectorName = global connector ID (e.g. shared_sharepointonline)
# Stripping the shared_ prefix gives a readable name.
# ============================================================
Write-Host "`n[2/8] Building connector lookup from connections..." -ForegroundColor Cyan
$lookup = @{}
$environments = Get-AdminPowerAppEnvironment

foreach ($env in $environments) {
try {
Get-AdminPowerAppConnection -EnvironmentName $env.EnvironmentName | ForEach-Object {
if (-not $lookup.ContainsKey($_.ConnectionName)) {
$lookup[$_.ConnectionName] = $_.ConnectorName -replace "^shared_", ""
}
}
} catch { }
}
Write-Host " Lookup from connections: $($lookup.Count)" -ForegroundColor Gray

# ============================================================
# STEP 3 - Enrich lookup from current app definitions
# The PowerApps API returns full connectionReferences objects
# with displayName and connector id -- the module does not.
# This resolves GUIDs that have active connections configured.
# ============================================================
Write-Host "`n[3/8] Enriching lookup from app connection references..." -ForegroundColor Cyan

foreach ($env in $environments) {
try {
Get-AdminPowerApp -EnvironmentName $env.EnvironmentName | ForEach-Object {
try {
$uri = "$ukPowerApps/providers/Microsoft.PowerApps/apps/$($_.AppName)?api-version=2021-02-01"
$appDetail = Invoke-RestMethod -Uri $uri -Method Get -Headers @{ Authorization = "Bearer $tokenPA" }
if ($appDetail.properties.connectionReferences) {
$appDetail.properties.connectionReferences.PSObject.Properties | ForEach-Object {
if (-not $lookup.ContainsKey($_.Name) -and $_.Value.displayName) {
$lookup[$_.Name] = $_.Value.displayName
}
}
}
} catch { }
}
} catch { }
}
Write-Host " Lookup after app enrichment: $($lookup.Count)" -ForegroundColor Gray

# ============================================================
# STEP 4 - Enrich lookup from app version history
# Power Platform stores a full app definition snapshot at
# every save. The connectionReferences object in each snapshot
# includes displayName and connector id even if the underlying
# connection has since been deleted. This resolves orphaned
# GUIDs that are invisible via every other API surface.
# Not documented by Microsoft -- discovered via API inspection.
# ============================================================
Write-Host "`n[4/8] Enriching lookup from app version history..." -ForegroundColor Cyan

foreach ($env in $environments) {
try {
Get-AdminPowerApp -EnvironmentName $env.EnvironmentName | ForEach-Object {
try {
$uri = "$ukPowerApps/providers/Microsoft.PowerApps/apps/$($_.AppName)/versions?api-version=2021-02-01"
$versions = Invoke-RestMethod -Uri $uri -Method Get -Headers @{ Authorization = "Bearer $tokenPA" }
foreach ($version in $versions.value) {
if ($version.properties.appDefinition.properties.connectionReferences) {
$version.properties.appDefinition.properties.connectionReferences.PSObject.Properties | ForEach-Object {
if (-not $lookup.ContainsKey($_.Name) -and $_.Value.displayName) {
$lookup[$_.Name] = $_.Value.displayName
}
}
}
}
} catch { }
}
} catch { }
}
Write-Host " Lookup after version history enrichment: $($lookup.Count)" -ForegroundColor Gray

function Resolve-Connector {
param($id)
if (-not $id) { return $null }
$id = $id.Trim()
if ($lookup.ContainsKey($id)) { return $lookup[$id] }
return $id
}

# ============================================================
# STEP 5 - Audit Cloud Flows
# Two passes per environment:
# Pass 1: standard flows (owned by calling user or accessible)
# Pass 2: includeSolutionCloudFlows (solution-aware flows,
# used by Dynamics 365 and Copilot Studio environments)
# Max page size is 50 -- follow nextLink for pagination.
# Each flow requires a separate API call with $expand=definition
# to get connectionReferences (not included in list responses).
# Use environment-specific runtimeEndpoints.microsoft.Flow
# rather than the global endpoint for correct routing.
# ============================================================
Write-Host "`n[5/8] Auditing cloud flows..." -ForegroundColor Cyan
$results = @()

$envsUri = "https://api.flow.microsoft.com/providers/Microsoft.ProcessSimple/environments?api-version=2016-11-01"
$envsResponse = Invoke-RestMethod -Uri $envsUri -Method Get -Headers @{ Authorization = "Bearer $tokenFlow" }

foreach ($env in $envsResponse.value) {
$envName = $env.name
$envDisplay = $env.properties.displayName
$flowEndpoint = $env.properties.runtimeEndpoints."microsoft.Flow"
Write-Host " Scanning flows: $envDisplay" -ForegroundColor Yellow

try {
$flows = @()
foreach ($include in @("", "&include=includeSolutionCloudFlows")) {
$nextUri = "$flowEndpoint/providers/Microsoft.ProcessSimple/environments/$envName/flows?api-version=2016-11-01&`$top=50$include"
while ($nextUri) {
$response = Invoke-RestMethod -Uri $nextUri -Method Get -Headers @{ Authorization = "Bearer $tokenFlow" }
$existing = $flows | Select-Object -ExpandProperty name
$flows += $response.value | Where-Object { $_.name -notin $existing }
$nextUri = $response.nextLink
}
}
Write-Host " Flows: $($flows.Count)" -ForegroundColor Gray

$counter = 0
foreach ($flow in $flows) {
$counter++
Write-Progress -Activity "Flows: $envDisplay" -Status "$counter of $($flows.Count)" -PercentComplete (($counter / [Math]::Max($flows.Count,1)) * 100)
try {
$flowUri = "$flowEndpoint/providers/Microsoft.ProcessSimple/environments/$envName/flows/$($flow.name)?`$expand=definition&api-version=2016-11-01"
$fullFlow = Invoke-RestMethod -Uri $flowUri -Method Get -Headers @{ Authorization = "Bearer $tokenFlow" }
$connectors = @()
if ($fullFlow.properties.connectionReferences) {
$connectors = $fullFlow.properties.connectionReferences.PSObject.Properties | ForEach-Object {
$name = Resolve-Connector $_.Name
if ($name -eq $_.Name -and $_.Value.displayName) { $_.Value.displayName } else { $name }
} | Where-Object { $_ } | Sort-Object -Unique
}
$results += [PSCustomObject]@{ Type="Cloud Flow"; Environment=$envDisplay; Name=$flow.properties.displayName; Owner=$flow.properties.creator.userId; Connectors=($connectors -join ", "); Status=$flow.properties.state }
} catch {
$results += [PSCustomObject]@{ Type="Cloud Flow"; Environment=$envDisplay; Name=$flow.properties.displayName; Owner=$flow.properties.creator.userId; Connectors="Error"; Status=$flow.properties.state }
}
}
} catch {
Write-Host " Could not scan: $_" -ForegroundColor Red
}
}

# ============================================================
# STEP 6 - Audit Power Apps
# Get-AdminPowerApp returns connectionReferences GUIDs in
# Internal.properties.connectionReferences but without
# connector metadata. The lookup built in Steps 2-4 resolves
# these to friendly names.
# ============================================================
Write-Host "`n[6/8] Auditing Power Apps..." -ForegroundColor Cyan
foreach ($env in $environments) {
Write-Host " Scanning apps: $($env.DisplayName)" -ForegroundColor Yellow
try {
Get-AdminPowerApp -EnvironmentName $env.EnvironmentName | ForEach-Object {
$connRefs = $_.Internal.properties.connectionReferences
$connectors = @()
if ($connRefs) {
$connectors = $connRefs.PSObject.Properties.Name | ForEach-Object {
Resolve-Connector $_
} | Where-Object { $_ } | Sort-Object -Unique
}
$results += [PSCustomObject]@{ Type="Power App"; Environment=$env.DisplayName; Name=$_.DisplayName; Owner=$_.Owner.userPrincipalName; Connectors=($connectors -join ", "); Status="Active" }
}
} catch {
Write-Host " Skipped: $_" -ForegroundColor Gray
}
}

# ============================================================
# STEP 7 - Audit Custom Connectors
# Custom connectors are per-environment and require the
# isCustomApi filter on the apis endpoint. They sit in
# Non-Business by default and must be explicitly classified
# in the DLP policy to avoid breaking flows that mix them
# with Business connectors.
# ============================================================
Write-Host "`n[7/8] Auditing custom connectors..." -ForegroundColor Cyan
foreach ($env in $environments) {
try {
$uri = "$ukPowerApps/providers/Microsoft.PowerApps/scopes/admin/environments/$($env.EnvironmentName)/apis?`$filter=properties/isCustomApi+eq+true&api-version=2016-11-01"
$response = Invoke-RestMethod -Uri $uri -Method Get -Headers @{ Authorization = "Bearer $tokenPA" }
$response.value | ForEach-Object {
Write-Host " Found: $($_.properties.displayName) in $($env.DisplayName)" -ForegroundColor Yellow
$results += [PSCustomObject]@{ Type="Custom Connector"; Environment=$env.DisplayName; Name=$_.properties.displayName; Owner=$_.properties.createdBy.email; Connectors=$_.properties.displayName; Status="Active" }
}
} catch { }
}

# ============================================================
# STEP 8 - Audit Copilot Studio Agents + Desktop Flows
# Both live in Dataverse, not the PowerApps or Flow APIs.
# Each Dataverse instance requires its own scoped token.
# The token audience must match the instance URL exactly,
# including the trailing slash -- Dataverse is strict about this.
#
# Agents: bots table
# Desktop flows: workflows table where category eq 6
# (category 5 = cloud flows, 6 = desktop flows/RPA)
#
# Agents do not hold connector references directly -- their
# connector usage is in child cloud flows triggered from topics.
# Desktop flows do not use connectors -- they use the PAD
# runtime to interact with desktop applications.
# ============================================================
Write-Host "`n[8/8] Auditing Copilot Studio agents and desktop flows..." -ForegroundColor Cyan
foreach ($env in $environments) {
$dataverseUrl = $env.Internal.properties.linkedEnvironmentMetadata.instanceApiUrl
if (-not $dataverseUrl) { continue }
try {
$tokenDV = az account get-access-token --resource "$dataverseUrl/" --tenant $tenantId --query accessToken -o tsv

# Copilot Studio agents
$nextUri = "$dataverseUrl/api/data/v9.2/bots?`$select=name,botid,publishedon,_ownerid_value&`$top=50"
while ($nextUri) {
$response = Invoke-RestMethod -Uri $nextUri -Method Get -Headers @{ Authorization = "Bearer $tokenDV" }
$response.value | ForEach-Object {
$results += [PSCustomObject]@{ Type="Copilot Agent"; Environment=$env.DisplayName; Name=$_.name; Owner=$_._ownerid_value; Connectors="N/A - connectors via child cloud flows"; Status=if($_.publishedon){"Published"}else{"Draft"} }
}
$nextUri = $response.'@odata.nextLink'
}

# Desktop flows (Dataverse category 6)
$nextUri2 = "$dataverseUrl/api/data/v9.2/workflows?`$filter=category eq 6&`$select=name,statecode,_ownerid_value&`$top=50"
while ($nextUri2) {
$response2 = Invoke-RestMethod -Uri $nextUri2 -Method Get -Headers @{ Authorization = "Bearer $tokenDV" }
$response2.value | ForEach-Object {
$results += [PSCustomObject]@{ Type="Desktop Flow"; Environment=$env.DisplayName; Name=$_.name; Owner=$_._ownerid_value; Connectors="N/A - RPA, not subject to DLP connector policies"; Status=if($_.statecode -eq 1){"Active"}else{"Inactive"} }
}
$nextUri2 = $response2.'@odata.nextLink'
}

Write-Host " $($env.DisplayName): done" -ForegroundColor Gray
} catch { }
}

# ============================================================
# EXPORT
# ============================================================
New-Item -ItemType Directory -Path "C:\temp" -Force | Out-Null
$results | Export-Csv "C:\temp\connector-audit-final.csv" -NoTypeInformation
Write-Host "`nDone! Saved to C:\temp\connector-audit-final.csv" -ForegroundColor Green

Write-Host "`n--- Summary ---" -ForegroundColor Cyan
$results | Group-Object Type | Sort-Object Name | ForEach-Object {
Write-Host " $($_.Name): $($_.Count)" -ForegroundColor Gray
}

Write-Host "`n--- Unique Connectors (DLP relevant) ---" -ForegroundColor Cyan
$results | Where-Object { $_.Type -in @("Cloud Flow","Power App","Custom Connector") } |
ForEach-Object { $_.Connectors -split ", " } |
Where-Object { $_ -and $_ -notmatch "^Error" -and $_ -notmatch "^N/A" } |
Sort-Object -Unique

Translating the Audit Data Into a DLP Policy

Once the CSV is produced, the decision-making process for building the DLP policy is straightforward but worth walking through in detail.

Understanding the Three Buckets

Every connector in your tenant must sit in exactly one of three groups within a DLP policy:

Business — connectors approved for business data. Flows and apps can freely mix Business connectors with each other. The standard Microsoft productivity connectors (SharePoint, Teams, Outlook, Dataverse, Users, OneDrive for Business) belong here.

Non-Business — connectors not approved for business data. Non-Business connectors can be used but cannot be mixed with Business connectors in the same resource. Any unclassified connector defaults to Non-Business. This is the default bucket.

Blocked — connectors that cannot be used at all in the scope of the policy. Any resource using a Blocked connector will be immediately suspended (flows) or fail to run (apps).

The key operational principle is that Business and Non-Business cannot mix. A flow using SharePoint (Business) and Dropbox (Non-Business) violates the policy. A flow using SharePoint (Business) and a custom connector in Non-Business (because it was never classified) also violates the policy — which is why classifying custom connectors explicitly is essential.

Reading the CSV for DLP Classification

With the audit CSV open, the process is:

First, pull the unique connector list from the DLP-relevant resource types (Cloud Flow, Power App, Custom Connector). These are the connectors you need to classify. Connectors that only appear in Desktop Flow or Copilot Agent rows do not need DLP classification.

Second, categorise each connector. Standard Microsoft connectors used for internal business operations go to Business. Third-party connectors (Salesforce, ServiceNow, Slack, Dropbox, Google services) go to Blocked if you do not want them used, or Non-Business if you want to allow them in isolated flows that do not also use business data. Premium Microsoft connectors that are not standard productivity tools (Azure Blob Storage, SQL Server, HTTP) require a judgment call based on your governance requirements.

Third, pay special attention to custom connectors. Each custom connector in the audit needs an explicit classification decision. If a custom connector calls an internal API and is used alongside SharePoint in the same flow, it must be in Business — not Non-Business. If it sits in Non-Business by default and that flow exists, the flow will be suspended the moment the DLP policy goes live.

Checking the Impact Before Applying

The Power Platform Admin Center DLP editor has a built-in impact analysis tool. When creating or editing a policy, before saving it you can click “Check impact” or “View impacted resources” to see which flows and apps would be suspended or blocked by the policy as configured. Use this. It cross-references your proposed connector classifications against every resource in scope and lists any violations.

You can also use the PowerShell module to check impact programmatically:

# List all DLP policies in the tenant
Get-DlpPolicy

# Get details of a specific policy
Get-DlpPolicy -PolicyName "your-policy-name"

Scoping the Policy

DLP policies can be scoped in three ways: All environments, All environments except specific ones, or specific environments only. For an initial Default environment lockdown, scope the policy to the Default environment explicitly rather than All environments. This limits blast radius while you validate the policy does not break anything unexpected. You can expand the scope later once you have confidence in the connector classifications.

The Suspended Flow Recovery Process

If you apply a policy and flows get suspended, the recovery process is: identify the suspended flows (they will show flowSuspensionReason: DlpViolation in their properties), determine whether the violation is because of a misconfiguration in the policy or because the flow genuinely uses a connector that should not be used, fix either the policy or the flow, then manually turn each suspended flow back on. There is no bulk re-enable — you have to turn each one on individually.

Things I Learned the Hard Way

Connection reference GUIDs are per-tenant, not global

I spent a long time trying to resolve connector GUIDs against Microsoft’s public connector registry on GitHub. It does not work. The GUIDs in connection references are per-tenant identifiers generated at app creation time. There is no global registry that maps them. The only source of truth is your own tenant’s API — specifically the app definition and version history.

Get-AdminPowerAppConnector returns nothing

The PowerShell module has a Get-AdminPowerAppConnector cmdlet that sounds like exactly what you need. It returns 0 results. The underlying API endpoint it calls appears to be deprecated or requires permissions beyond what Power Platform Administrator grants. Use Get-AdminPowerAppConnection instead — that returns actual connection objects with connector name mappings.

Canvas apps store connection references differently to flows

Flow connection references come back in the flow definition when you fetch it with $expand=definition. Canvas app connection references are on the app’s properties.connectionReferences object via the PowerApps API, not the PowerShell module. The PowerShell module’s Get-AdminPowerApp returns the GUID keys but without the connector metadata. You need a separate API call per app to get the full object. This is slow but necessary.

App version history resolves orphaned GUIDs

The /versions endpoint stores a full app definition snapshot at every save, including complete connection reference metadata. This survives connection deletion. It is not documented as a mechanism for GUID resolution but it is the most complete source of connector data in the tenant. Run it as the last enrichment pass after exhausting active connections and current app definitions.

$input is a reserved variable in PowerShell

Classic one. $input is an automatic pipeline variable. Assigning to it does not behave as expected and silently produces empty results when iterated. Name your CSV import variable anything else.

The Flow API max page size is 50

Not 250. Not 100. 50. Using a higher value returns an InvalidTopInQueryString error. Always follow nextLink for pagination. Always make two passes per environment — one without the solution flows include and one with include=includeSolutionCloudFlows.

Desktop flows are category 6 in Dataverse

RPA desktop flows are stored in the Dataverse workflows table with category eq 6 (cloud flows are category 5). They do not appear via the Flow API. Their connector data is empty because they use the Power Automate Desktop runtime rather than the connector framework. They are not subject to DLP connector policies.

Copilot Studio agents live in Dataverse, not the PowerApps API

Agents are in the bots Dataverse table. Their connector usage is in child cloud flows triggered from topics — those flows appear in your regular flow audit as standard cloud flows. The agent object itself does not hold connector references.

Each Dataverse environment needs a scoped token with a trailing slash

You cannot reuse the PowerApps token for Dataverse API calls. Each Dataverse instance needs a token scoped to its own URL. The trailing slash on the resource URL is mandatory — https://yourorg.api.crm11.dynamics.com/ works, without the slash it fails. This is inconsistent with every other Microsoft API and is not documented.

Power Platform Administrator does not give you Dataverse access

The tenant-level Power Platform Administrator role does not automatically make you a System Administrator in every Dataverse environment. You need to be explicitly assigned the System Administrator or System Customizer role in each Dataverse environment you want to query. The error is clear about this — it tells you which privilege you are missing — but the fact that the tenant admin role does not cascade to Dataverse is counterintuitive and worth knowing upfront.

The $expand=definition parameter is the only way to get flow connector data

The list flows API returns summary objects where connectionReferences is null. To get connector data you must fetch each flow individually with $expand=definition. There is no batch endpoint. On a large environment with hundreds of flows this means hundreds of API calls, which is why the script is slow. There is no workaround for this at the API level.

What the Output Looks Like

The script produces a CSV with these columns:

  • Type — Cloud Flow, Power App, Custom Connector, Copilot Agent, Desktop Flow
  • Environment — friendly name of the Power Platform environment
  • Name — name of the resource
  • Owner — UPN or object ID of the owner
  • Connectors — comma-separated list of resolved connector names for DLP-relevant types; “N/A” notes for types not subject to DLP
  • Status — Started/Stopped for flows, Published/Draft for agents, Active/Inactive for desktop flows

At the end of the run the console also prints a de-duplicated list of every unique connector in use across the tenant filtered to DLP-relevant types (Cloud Flow, Power App, Custom Connector). This is the list you work from when building the DLP policy.

Known Limitations

  • Environment access — you can only audit environments where you are an Environment Admin. Power Platform Administrator at the tenant level does not grant this automatically. Environments you are not an admin in will return 0 flows and 0 apps silently.
  • Dataverse access — Copilot Studio agents and desktop flows require System Administrator in each Dataverse environment. Missing this role produces a 0x80042f09 privilege error and the audit silently skips those resource types for that environment.
  • Token expiry — for large tenants the script can run for 30-60 minutes. Azure CLI tokens typically expire after 60-90 minutes. If a token expires mid-run, subsequent API calls fail silently and return empty results rather than errors. Re-authenticate and re-run if you suspect this has happened.
  • Solution-aware flows in restricted environments — environments where all flows are solution-aware and you are not an Environment Admin will return 0 flows from both the standard and includeSolutionCloudFlows queries. No workaround without access.
  • Region-specific endpoints — the script uses UK region endpoints. Adjust unitedkingdom.api.powerapps.com for your region. The environment’s runtimeEndpoints property always contains the correct region-specific Flow endpoint regardless.
  • Version history enrichment is slow — fetching all versions for every app across every environment is the most time-consuming step. On a tenant with hundreds of apps expect 15-30 minutes for this step alone. It can be skipped if you do not have orphaned GUIDs to resolve.
  • Deleted apps — apps that have been deleted from Power Platform leave orphaned connection reference GUIDs in any audit data that was previously collected. Even version history cannot resolve GUIDs for apps that have been fully deleted. These will remain as unresolved GUIDs in the output.

Closing Thoughts

This took a lot longer to build than I expected, mostly because the Power Platform API surface is fragmented across four different endpoints with different auth requirements, different pagination implementations, different data structures, and different levels of documentation quality. The Flow API max page size issue is not in the main documentation. The solution flows parameter is buried in a release note. The Dataverse trailing slash token scoping is not documented at all. The version history approach for resolving orphaned GUIDs is something I found by systematically inspecting raw API responses.

The most important insight from this whole exercise is about the connection reference data model. Understanding that a connection reference GUID is baked into the app definition at creation time and survives independently of the underlying connection object is what unlocked the version history solution. Once you understand that the GUID and its connector metadata are part of the app, not part of the connection, the approach is obvious in hindsight.

The second most important insight is about the permissions model. Power Platform Administrator is a management role, not a data access role. It gives you control over the platform but not implicit access to every environment’s data. Planning your access strategy before running this kind of audit — getting Environment Admin in all environments, getting System Administrator in all Dataverse instances — saves a lot of confusing “0 results” debugging.

Once you have the data, DLP governance is the easy part. You are not guessing what you might break. You have the full picture.

If you are working on Power Platform governance and want to discuss any of this, find me on LinkedIn or drop a comment below.


Tags: Power Platform, DLP, PowerShell, Governance, Power Automate, Power Apps, Copilot Studio, Dataverse, Azure, Power Platform Administration

0 0 votes
Article Rating
Exit mobile version