Copilot Studio Inventory
Deployment Runbook
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.
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.
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
deploy.sh script reads deploy.env and applies these for you.
Prerequisites
Before starting, ensure you have:
- An Azure account with Owner (or Contributor + User Access Administrator) on the subscription
- A Global Administrator or Application Administrator in Entra ID — required to grant Graph API app roles in Step 6a
- A Power Platform Administrator or System Administrator for the environment — required for the Dataverse Application User in Step 6b
- An existing Copilot Studio environment with published agents (you supply its Dataverse URL)
- The deployment package (
copilot-studio-inventory-deploy.tar.gz)
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:
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:
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.
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}
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.
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.
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_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.
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).
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"
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.
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}"
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.
| Permission | Mechanism | Purpose |
|---|---|---|
Storage Blob Data Contributor | Azure RBAC | Write inventory.json and activity-logs.json to Blob |
Graph / User.Read.All | Entra app role (az rest) | Resolve agent owner UPN and display name |
Graph / Group.Read.All | Entra app role (az rest) | Resolve authorized security groups for policy=2 agents |
# 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}
# 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\"}"
deploy.sh handles 409s silently.
Step 6b Dataverse Application User (Manual)
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 Settings → Users + permissions → Application 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 table | Used for |
|---|---|
bots | Published agent list and access policy |
botcomponents | Tools, topics, knowledge base components |
botcomponent_connectionreferenceset | Component-to-connection junction |
botcomponent_workflowset | Component-to-workflow junction |
connectionreferences | Connection reference metadata and auth inference |
connectioninstances | Instance-level auth inference and credential IDs |
systemusers | Agent owner resolution (AAD object ID) |
conversationtranscripts | Activity log only (if ACTIVITY_LOG_ENABLED=true) |
flowruns | Activity log only (if ACTIVITY_LOG_ENABLED=true) |
workflows | Activity 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.
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)
az functionapp deployment source config-zip -n ${FUNCTION_APP_NAME} \
-g ${FUNCTION_RESOURCE_GROUP} --src package.zip
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.
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 '{}'
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
az storage blob list --account-name ${STORAGE_ACCOUNT_NAME} --auth-mode login \
--container-name tools-inventory --query "[].name" -o tsv
Expected:
8c — Download & check the schema version
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
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
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:
# 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.
# Interactive (confirms each resource) ./teardown.sh # Non-interactive ./teardown.sh --force
Resource Summary
| Resource | Variable / 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 Identity | System-assigned (${PRINCIPAL_ID}) |
| Inventory Blob | tools-inventory/copilot-studio/inventory.json |
| Activity Log Blob | tools-inventory/copilot-studio/activity-logs.json (optional) |
| Schedule | Hourly (timer trigger, built in) |
| Runtime | Python 3.11 (Functions v4) |
| Schema | Inventory v3.0 (7 sections); Activity log v2.2.0 |
| Auth | Managed identity (no secrets); Dataverse Application User (manual) |