Architecture Overview

The inventory job runs as a timer-triggered Azure Function (Python, Linux Consumption) on an hourly schedule. Using its system-assigned managed identity, it queries the Dataverse OData API for published Copilot Studio agents, resolves owners and groups via Microsoft Graph, traverses component and connection junction tables, and writes a single inventory.json document (schema v3.0, seven sections) to Blob Storage. The OpenICF connector in PingOne IDM reads that blob during reconciliation to surface NHI governance data.

Copilot Studio Inventory Azure Function architecture diagram Shows the Azure Function reading from Dataverse OData API and Microsoft Graph, writing inventory.json to Blob Storage, with the OpenICF connector consuming from Blob Storage. AZURE SUBSCRIPTION / POWER PLATFORM Dataverse OData API bots · botcomponents · connrefs Microsoft Graph API users · groups · service principals Purview / App Insights activity logs (optional) copilot-inventory -func Azure Function · Python 3.11 Timer · hourly · MSI Blob Storage container: tools-inventory inventory.json schema v3.0 · 7 sections activity-logs.json optional collector overwrite each run written by managed identity SAS URL or MSI upload_blob (MSI) PINGONE IDM / OPENIDM HOST OpenICF Connector m365copilot-connector (Java) Reconciliation → IGA platform read blob LEGEND Function reads Blob write / read Optional (activity logs)

Shell Variables

All commands reference shell variables. Set these in your terminal (or Cloud Shell) before running anything. They mirror the keys in deploy.env. Later steps capture additional values as resources are created.

bash — environment setup
export SUBSCRIPTION_ID=<your-subscription-id>
export CS_TENANT_ID=<your-tenant-id>
export LOCATION=eastus

# Where the Function infrastructure is created
export FUNCTION_RESOURCE_GROUP=rg-copilot-inventory
export FUNCTION_APP_NAME=<globally-unique-name>
export STORAGE_ACCOUNT_NAME=<3-24 lowercase alphanumerics>

# The pre-existing Power Platform environment to inventory
export CS_ENVIRONMENT_URL=https://org<id>.crm.dynamics.com
Configuration via app settings
The Function receives its configuration as Function App settings (set in Step 5), not a mounted file. The deploy.sh script reads deploy.env and applies these for you.

Prerequisites

Before starting, ensure you have:

The two prerequisite steps (P1–P2) must complete before Step 1.

P1 Get the Azure CLI

The simplest path needs nothing installed locally — and crucially, no Python, because the function's dependencies are built on the server. Use Azure Cloud Shell, or install the CLI locally.

P1a — Azure Cloud Shell (recommended, zero install)

Open https://shell.azure.com and choose Bash. You are already signed in, and az, jq, and zip are pre-installed. Use the Upload button to upload the package, then extract:

bash
tar xzf copilot-studio-inventory-deploy.tar.gz
cd copilot-studio-inventory

P1b — Local CLI (alternative)

Install from https://learn.microsoft.com/cli/azure/install-azure-cli (bash 4+ recommended on macOS — brew install bash). Verify:

bash
az version

P2 Sign In & Register Providers

Sign in (skip in Cloud Shell — already signed in), select the subscription, and ensure the resource providers used by the deploy are registered.

bash
az login
az account set --subscription ${SUBSCRIPTION_ID}

# Register the providers the deploy needs (no-op if already registered)
az provider register -n Microsoft.Web --subscription ${SUBSCRIPTION_ID}
az provider register -n Microsoft.Storage --subscription ${SUBSCRIPTION_ID}
az provider register -n Microsoft.Insights --subscription ${SUBSCRIPTION_ID}
Provider propagation
Provider registration can take a few minutes to reach the Registered state. preflight.sh checks this and offers to register them for you.

Step 1 Create the Resource Group

This resource group holds the Function App, its storage account, and Application Insights. It is separate from the Power Platform environment — no access to that environment is required at the Azure level.

bash
az group create -n ${FUNCTION_RESOURCE_GROUP} -l ${LOCATION}

Step 2 Create the Storage Account

One storage account serves both the Function runtime and the inventory output blob. The container (tools-inventory) and blob path (copilot-studio/inventory.json) are created automatically by the function on its first run.

bash
az storage account create -n ${STORAGE_ACCOUNT_NAME} -g ${FUNCTION_RESOURCE_GROUP} \
  -l ${LOCATION} --sku Standard_LRS --kind StorageV2 --min-tls-version TLS1_2
Storage name rules
Storage account names are globally unique and must be 3–24 lowercase letters and digits only (no hyphens). If the name is taken, choose another STORAGE_ACCOUNT_NAME.

Step 3 Create Application Insights

Application Insights captures function logs and run telemetry. It is where you look first when a run fails. Note: this is the function's own diagnostics resource — it is distinct from any Log Analytics workspace used for agent telemetry in the activity log collector.

bash
az extension add --name application-insights --upgrade

az monitor app-insights component create --app ${FUNCTION_APP_NAME}-ai \
  -g ${FUNCTION_RESOURCE_GROUP} -l ${LOCATION} --application-type web

# Capture the connection string for Step 5
export APPINSIGHTS_CONN=$(az monitor app-insights component show \
  --app ${FUNCTION_APP_NAME}-ai -g ${FUNCTION_RESOURCE_GROUP} \
  --query connectionString -o tsv)

Step 4 Create the Function App & Identity

Create a Linux Consumption Function App on Python 3.11, enable its system-assigned managed identity, and capture both the principalId (for Azure RBAC and Graph assignments) and the applicationId (for the Dataverse Application User in Step 6b).

bash
az functionapp create -n ${FUNCTION_APP_NAME} -g ${FUNCTION_RESOURCE_GROUP} \
  --storage-account ${STORAGE_ACCOUNT_NAME} --consumption-plan-location ${LOCATION} \
  --os-type Linux --runtime python --runtime-version 3.11 --functions-version 4 \
  --disable-app-insights true

export PRINCIPAL_ID=$(az functionapp identity assign -n ${FUNCTION_APP_NAME} \
  -g ${FUNCTION_RESOURCE_GROUP} --query principalId -o tsv)

# applicationId is needed for the Dataverse Application User in Step 6b
export APP_ID=$(az ad sp show --id ${PRINCIPAL_ID} --query appId -o tsv)

echo "PRINCIPAL_ID=${PRINCIPAL_ID}"
echo "APP_ID=${APP_ID}   <-- use this in Step 6b"
Two IDs, two uses
The managed identity has both a principalId (object ID) used for Azure RBAC and Graph app role assignments, and an applicationId (app ID / client ID) used for the Dataverse Application User setup. They are different GUIDs. deploy.sh prints both.

Step 5 Apply App Settings

The function reads its configuration from app settings. The two build flags enable the server-side Oryx build that installs requirements.txt — this is why you do not need Python locally.

bash
az functionapp config appsettings set -n ${FUNCTION_APP_NAME} -g ${FUNCTION_RESOURCE_GROUP} --settings \
  CS_TENANT_ID=${CS_TENANT_ID} \
  CS_ENVIRONMENT_URL=${CS_ENVIRONMENT_URL} \
  CS_AUTH_MODE=MANAGED_IDENTITY \
  TOOLS_INVENTORY_STORAGE_ACCOUNT_URL=https://${STORAGE_ACCOUNT_NAME}.blob.core.windows.net \
  TOOLS_INVENTORY_CONTAINER=tools-inventory \
  TOOLS_INVENTORY_BLOB=copilot-studio/inventory.json \
  SCM_DO_BUILD_DURING_DEPLOYMENT=true \
  ENABLE_ORYX_BUILD=true \
  ACTIVITY_LOG_ENABLED=false \
  APPLICATIONINSIGHTS_CONNECTION_STRING="${APPINSIGHTS_CONN}"
Why no local Python
With SCM_DO_BUILD_DURING_DEPLOYMENT=true and ENABLE_ORYX_BUILD=true, the dependencies in requirements.txt are installed on Azure's build server when you push the zip in Step 7.

Step 6a Assign RBAC (Automated)

The managed identity needs three permission grants. The first is a standard Azure RBAC role; the other two are Microsoft Graph API app role assignments made via the Graph REST API.

PermissionMechanismPurpose
Storage Blob Data ContributorAzure RBACWrite inventory.json and activity-logs.json to Blob
Graph / User.Read.AllEntra app role (az rest)Resolve agent owner UPN and display name
Graph / Group.Read.AllEntra app role (az rest)Resolve authorized security groups for policy=2 agents
bash — storage RBAC
# Storage Blob Data Contributor on the storage account
az role assignment create --assignee-object-id ${PRINCIPAL_ID} \
  --assignee-principal-type ServicePrincipal \
  --role "Storage Blob Data Contributor" \
  --scope /subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${FUNCTION_RESOURCE_GROUP}/providers/Microsoft.Storage/storageAccounts/${STORAGE_ACCOUNT_NAME}
bash — Graph app role assignments
# Get the Microsoft Graph service principal ID in this tenant
export GRAPH_SP_ID=$(az ad sp show --id 00000003-0000-0000-c000-000000000000 --query id -o tsv)

# User.Read.All (role ID df021288-bdef-4463-88db-98f22de89214)
az rest --method POST \
  --uri "https://graph.microsoft.com/v1.0/servicePrincipals/${PRINCIPAL_ID}/appRoleAssignments" \
  --body "{\"principalId\":\"${PRINCIPAL_ID}\",\"resourceId\":\"${GRAPH_SP_ID}\",
           \"appRoleId\":\"df021288-bdef-4463-88db-98f22de89214\"}"

# Group.Read.All (role ID 5b567255-7703-4780-807c-7be8301ae99a)
az rest --method POST \
  --uri "https://graph.microsoft.com/v1.0/servicePrincipals/${PRINCIPAL_ID}/appRoleAssignments" \
  --body "{\"principalId\":\"${PRINCIPAL_ID}\",\"resourceId\":\"${GRAPH_SP_ID}\",
           \"appRoleId\":\"5b567255-7703-4780-807c-7be8301ae99a\"}"
Idempotent
Re-running these commands is safe. A 409 Conflict from the Graph endpoint means the assignment already exists — that is the expected result on a re-run. deploy.sh handles 409s silently.

Step 6b Dataverse Application User (Manual)

Cannot be scripted — do not skip
Power Platform's application user registry has no Azure CLI or REST equivalent that can be called from outside the tenant's Power Platform admin boundary. This step must be completed in the admin portal before the function can query Dataverse. If skipped, every run will fail with a 401 at the Dataverse query.

You need the APP_ID printed in Step 4 output (a GUID — the applicationId, not the principalId). Use it to register the Function App as a Dataverse application user with read access to the required tables.

6b-1 — Open the Power Platform admin center

Go to https://admin.powerplatform.microsoft.com and select your Copilot Studio environment.

6b-2 — Navigate to Application Users

Choose SettingsUsers + permissionsApplication users, then click + New app user.

6b-3 — Find the Function App's identity

In the Add an app panel, search for the APP_ID GUID from Step 4. Select it and click Add.

6b-4 — Assign a security role

Under Security roles, assign a role that grants read access to the tables below. A custom scoped read-only role is recommended for production; System Administrator works for initial testing but should be replaced.

Dataverse tableUsed for
botsPublished agent list and access policy
botcomponentsTools, topics, knowledge base components
botcomponent_connectionreferencesetComponent-to-connection junction
botcomponent_workflowsetComponent-to-workflow junction
connectionreferencesConnection reference metadata and auth inference
connectioninstancesInstance-level auth inference and credential IDs
systemusersAgent owner resolution (AAD object ID)
conversationtranscriptsActivity log only (if ACTIVITY_LOG_ENABLED=true)
flowrunsActivity log only (if ACTIVITY_LOG_ENABLED=true)
workflowsActivity log only (if ACTIVITY_LOG_ENABLED=true)

6b-5 — Save and verify

Click Save. The application user should now appear in the list with the assigned role. Return to the terminal to proceed with Step 7.

Confirm
The application user entry appears with Status = Enabled and the correct security role. If the function still gets 401 after Step 8, return here and verify the APP_ID used matches the one from Step 4 (not the principalId).

Step 7 Deploy the Code

7a — From the package directory

You extracted the package in P1a. The shipped package.zip already has host.json and requirements.txt at its root, which the remote build requires.

7b — Deploy via zip (remote build)

bash — ~2-5 minutes
az functionapp deployment source config-zip -n ${FUNCTION_APP_NAME} \
  -g ${FUNCTION_RESOURCE_GROUP} --src package.zip
Verify the deployment
az functionapp function list -n ${FUNCTION_APP_NAME} -g ${FUNCTION_RESOURCE_GROUP} --query "[].name" -o tsv should list copilot_inventory_job.

Step 8 Trigger Manually & Verify

8a — Trigger one run

The function is a timer trigger; invoke it on demand via the admin endpoint. Wait at least 60 seconds after Step 6a completes — RBAC assignments take time to propagate.

bash — 1-2 minutes
export MASTER_KEY=$(az functionapp keys list -n ${FUNCTION_APP_NAME} \
  -g ${FUNCTION_RESOURCE_GROUP} --query masterKey -o tsv)

curl -X POST "https://${FUNCTION_APP_NAME}.azurewebsites.net/admin/functions/copilot_inventory_job" \
  -H "x-functions-key: ${MASTER_KEY}" -H "Content-Type: application/json" -d '{}'
First run wrote nothing?
The most common cause is an incomplete Step 6b (Dataverse Application User). Stream logs to see the exact error: az webapp log tail -n ${FUNCTION_APP_NAME} -g ${FUNCTION_RESOURCE_GROUP}. Fix the permission, then re-run the curl above.

8b — Confirm the blob exists

bash
az storage blob list --account-name ${STORAGE_ACCOUNT_NAME} --auth-mode login \
  --container-name tools-inventory --query "[].name" -o tsv

Expected:

copilot-studio/inventory.json

8c — Download & check the schema version

bash
export ACCOUNT_KEY=$(az storage account keys list -n ${STORAGE_ACCOUNT_NAME} \
  -g ${FUNCTION_RESOURCE_GROUP} --query "[0].value" -o tsv)

az storage blob download --account-name ${STORAGE_ACCOUNT_NAME} --account-key ${ACCOUNT_KEY} \
  --container-name tools-inventory --name copilot-studio/inventory.json --file inv.json

# Expect "3.0"
jq -r '.schemaVersion' inv.json
Confirm
schemaVersion is 3.0, the agents array is non-empty, each agent has an ownerPrincipalId, and identityBindings are resolved to group display names.

8d — Inspect per-section counts

bash
jq '{agents:(.agents|length), identityBindings:(.identityBindings|length),
     tools:(.tools|length), knowledgeBases:(.knowledgeBases|length),
     connections:(.connections|length), toolCredentials:(.toolCredentials|length),
     serviceAccounts:(.serviceAccounts|length)}' inv.json

Zeros in agents mean either no agents are published in the environment (check that publishedon is non-null in Dataverse), or the Dataverse Application User setup in Step 6b was not completed. Zeros in identityBindings are normal if no agents have accesscontrolpolicy=2 — check agents with AllUsers or MultiTenant policies instead.

8e — Activity logs (optional)

If you enabled the collector (ACTIVITY_LOG_ENABLED=true), a second blob copilot-studio/activity-logs.json is written alongside the inventory. Read its warnings array — each entry names a missing telemetry source and how to fix it.

Updating the Function

Re-running ./deploy.sh re-deploys the current package.zip (idempotent). To ship changed source, rebuild the zip from src/host.json and requirements.txt must stay at the zip root — then deploy:

bash — rebuild & redeploy
# Rebuild the deployment zip from source
( cd src && zip -r ../package.zip host.json requirements.txt copilot_inventory_job -x '*__pycache__*' )

# Redeploy
az functionapp deployment source config-zip -n ${FUNCTION_APP_NAME} \
  -g ${FUNCTION_RESOURCE_GROUP} --src package.zip

# Trigger to verify
curl -X POST "https://${FUNCTION_APP_NAME}.azurewebsites.net/admin/functions/copilot_inventory_job" \
  -H "x-functions-key: ${MASTER_KEY}" -d '{}'

Teardown

The deployment package includes a teardown.sh script that removes all resources (Graph and O365 app role assignments, Azure RBAC, Function App, Application Insights, storage account, and optionally the resource group) with confirmation prompts.

bash
# Interactive (confirms each resource)
./teardown.sh

# Non-interactive
./teardown.sh --force
Manual step after teardown
The Dataverse Application User added in Step 6b must be removed manually — the teardown script cannot reach Power Platform: admin.powerplatform.microsoft.com → your environment → Settings → Users + permissions → Application users.

Resource Summary

ResourceVariable / Name
Resource Group${FUNCTION_RESOURCE_GROUP}rg-copilot-inventory
Storage Account${STORAGE_ACCOUNT_NAME} — runtime + inventory blob
Application Insights${FUNCTION_APP_NAME}-ai
Function App${FUNCTION_APP_NAME} — Linux Consumption
Managed IdentitySystem-assigned (${PRINCIPAL_ID})
Inventory Blobtools-inventory/copilot-studio/inventory.json
Activity Log Blobtools-inventory/copilot-studio/activity-logs.json (optional)
ScheduleHourly (timer trigger, built in)
RuntimePython 3.11 (Functions v4)
SchemaInventory v3.0 (7 sections); Activity log v2.2.0
AuthManaged identity (no secrets); Dataverse Application User (manual)