From 9dc30fbc83ab1dadee92dd7223aed96a63e0c24e Mon Sep 17 00:00:00 2001 From: manvkaur <67894494+manvkaur@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:23:36 -0700 Subject: [PATCH 1/4] Add Microsoft Dataverse (commondataservice) trigger + action sample New self-contained dataverseApp/ sample: OnDataverseRowChanged trigger (GetOnNewItems_V2) plus ListDataverseRows HTTP action calling the connector's List rows operation. Auth uses system-assigned MI in Azure (ManagedIdentityCredential, no client id) and az login identity locally (AzureCliCredential), selected via IDENTITY_ENDPOINT. Includes azd/Bicep infra with Connector Namespace, connection, and access policies, post-deploy consent + trigger-config scripts, and README documenting the connector choice, known SubscribeWebhookTrigger 500, and identity guidance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 87bf03da-21a5-421b-b57a-fe347876f141 --- README.md | 7 +- dataverseApp/.func/config.json | 10 + dataverseApp/README.md | 218 +++++++++++++ dataverseApp/azure.yaml | 18 ++ dataverseApp/function_app.py | 160 ++++++++++ dataverseApp/host.json | 18 ++ dataverseApp/infra/abbreviations.json | 137 +++++++++ dataverseApp/infra/connectorNamespace.bicep | 53 ++++ dataverseApp/infra/functionAccessPolicy.bicep | 36 +++ dataverseApp/infra/main.bicep | 289 ++++++++++++++++++ dataverseApp/infra/main.parameters.json | 27 ++ dataverseApp/infra/scripts/postdeploy.ps1 | 224 ++++++++++++++ dataverseApp/infra/scripts/postdeploy.sh | 207 +++++++++++++ dataverseApp/local.settings.json | 11 + dataverseApp/requirements.txt | 4 + 15 files changed, 1418 insertions(+), 1 deletion(-) create mode 100644 dataverseApp/.func/config.json create mode 100644 dataverseApp/README.md create mode 100644 dataverseApp/azure.yaml create mode 100644 dataverseApp/function_app.py create mode 100644 dataverseApp/host.json create mode 100644 dataverseApp/infra/abbreviations.json create mode 100644 dataverseApp/infra/connectorNamespace.bicep create mode 100644 dataverseApp/infra/functionAccessPolicy.bicep create mode 100644 dataverseApp/infra/main.bicep create mode 100644 dataverseApp/infra/main.parameters.json create mode 100644 dataverseApp/infra/scripts/postdeploy.ps1 create mode 100644 dataverseApp/infra/scripts/postdeploy.sh create mode 100644 dataverseApp/local.settings.json create mode 100644 dataverseApp/requirements.txt diff --git a/README.md b/README.md index a9fbaec..2c207fd 100644 --- a/README.md +++ b/README.md @@ -37,11 +37,12 @@ The connector platform handles webhook registration, OAuth flows, token refresh, ## Getting-started samples -Seven self-contained Azure Functions apps, each deployable with `azd up`: +Eight self-contained Azure Functions apps, each deployable with `azd up`: | App | Connector | Triggers demonstrated | |---|---|---| | [`azureblobApp/`](./azureblobApp) | Azure Blob | `OnAzureBlobUpdatedFile` | +| [`dataverseApp/`](./dataverseApp) | Microsoft Dataverse (Common Data Service) | `OnDataverseRowChanged` (when a new row is added) | | [`kustoApp/`](./kustoApp) | Azure Data Explorer (Kusto) | `OnKustoQueryResult` | | [`office365App/`](./office365App) | Office 365 Outlook | `OnNewEmail`, `OnFlaggedEmail`, `OnNewMentionMeEmail`, `OnNewCalendarEvent`, `OnUpcomingEvent` | | [`onedriveApp/`](./onedriveApp) | OneDrive for Business | `OnOneDriveNewFile`, `OnOneDriveUpdatedFile` | @@ -62,6 +63,10 @@ Seven self-contained Azure Functions apps, each deployable with `azd up`: A single function `OnAzureBlobUpdatedFile` that fires when blobs in a watched Azure Blob container are updated. The payload contains `Name`, `Path`, `Size`, `LastModified` fields. +### `dataverseApp` — Microsoft Dataverse (Common Data Service) + +A single function `OnDataverseRowChanged` that fires when a new row is **added** to a Dataverse table (operation `GetOnNewItems_V2`). The **environment** and **table name** are configurable via `azd env set`. Supply the environment by friendly name (`DATAVERSE_ENVIRONMENT_NAME`, auto-resolved to the org URL via Global Discovery) or explicit URL (`DATAVERSE_ENVIRONMENT_URL`), plus `DATAVERSE_TABLE_NAME` (the entity set / plural name, e.g. `accounts`). The environment URL is passed to the trigger as the `dataset` value. Note: these row triggers are Admin Only and require Global Read on the table. + ### `kustoApp` — Azure Data Explorer (Kusto) `OnKustoQueryResult` runs whenever the configured Kusto query produces a non-empty result. Each row is accessible from the parsed JSON payload. diff --git a/dataverseApp/.func/config.json b/dataverseApp/.func/config.json new file mode 100644 index 0000000..8d9118b --- /dev/null +++ b/dataverseApp/.func/config.json @@ -0,0 +1,10 @@ +{ + "stack": { + "runtime": "python", + "language": "python" + }, + "profiles": [ + "flex" + ], + "defaultProfile": "flex" +} diff --git a/dataverseApp/README.md b/dataverseApp/README.md new file mode 100644 index 0000000..c2bae12 --- /dev/null +++ b/dataverseApp/README.md @@ -0,0 +1,218 @@ +# Microsoft Dataverse Trigger + Action (Python) + +Azure Functions sample app demonstrating the **Microsoft Dataverse** (Common Data Service) +connector using the `azurefunctions-extensions-connectors` connector trigger extension for +the trigger, plus a managed-identity HTTP function that **calls** a connector action. + +| Function | Type | Connector operation | Description | +| --- | --- | --- | --- | +| `OnDataverseRowChanged` | Trigger | [`GetOnNewItems_V2`](https://learn.microsoft.com/en-us/connectors/commondataservice/#when-a-row-is-added-(admin-only)-[deprecated]) | Fires when a **new row is added** to the configured Dataverse table | +| `ListDataverseRows` | Action (HTTP) | [`GetItems_V2` — List rows](https://learn.microsoft.com/en-us/connectors/commondataservice/#list-rows-(legacy)-[deprecated]) | On-demand endpoint that **calls** the connector to list rows from the table | + +> **Change scope:** the connector-namespace + Functions callback path is validated end-to-end for +> the **new-row** trigger (`GetOnNewItems_V2`). The classic "added, modified or deleted" webhook +> operation (`SubscribeWebhookTrigger` — *"When a row is added, modified or deleted"*) is not +> compatible with the connector namespace today, so this sample fires on **row creation**. +> +> **Known issue:** subscribing to `SubscribeWebhookTrigger` (*"When a row is added, modified or +> deleted"*) via Connector Namespace currently returns **HTTP 500** (a `Regex.Match` null-reference +> failure) when the trigger config is created. The connector team is actively working on adding +> `SubscribeWebhookTrigger` support through Connector Namespace; until that lands, use the +> `GetOnNewItems_V2` new-row trigger this sample demonstrates. + +> **Why `commondataservice` (and not `commondataserviceforapps`):** this sample deliberately targets +> the legacy **[`commondataservice`](https://learn.microsoft.com/en-us/connectors/commondataservice/)** connector. Its intended successor, `commondataserviceforapps`, +> has only been shipped to Power Automate and is **not yet available for Logic Apps / Connector +> Namespaces**. By prior consensus with the connector owners, the legacy `commondataservice` connector +> remains supported in production for Connector Namespace until (and unless) the replacement is finally released +> to that environment. Do not switch this sample to `commondataserviceforapps` until it is generally +> available in the Logic Apps / Connector Namespace environment. + +## What you configure + +Point the sample at any environment / table without editing code — all via `azd env set`: + +| Input | azd env var | Default | Notes | +| --- | --- | --- | --- | +| **Environment (name)** | `DATAVERSE_ENVIRONMENT_NAME` | _(empty)_ | Friendly name, e.g. `Contoso (default)`. The org URL is auto-resolved from this name during post-deploy. | +| **Environment (URL)** | `DATAVERSE_ENVIRONMENT_URL` | _(empty)_ | Explicit org URL, e.g. `https://org.crm.dynamics.com`. Takes precedence when set. Passed to the trigger as the `dataset` value. | +| **Table name** | `DATAVERSE_TABLE_NAME` | `accounts` | The entity set (plural logical) name, e.g. `accounts`, `contacts`. | + +Provide **either** `DATAVERSE_ENVIRONMENT_NAME` (recommended — the URL is discovered for you) +**or** `DATAVERSE_ENVIRONMENT_URL`. + +## Prerequisites + +- [Azure Developer CLI (`azd`)](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd) +- [Azure CLI (`az`)](https://learn.microsoft.com/cli/azure/install-azure-cli) ≥ 2.75.0 +- [Python 3.13](https://www.python.org/downloads/) +- A Microsoft Dataverse environment and an account with access to the target table +- [`connector-namespace` Azure CLI extension](https://github.com/Azure/Connectors/tree/main/public-preview/connector-namespace-cli) — install with: + + ```bash + # Bash + curl -fsSL https://aka.ms/connector-namespace-cli-install | sh + ``` + + or + + ```pwsh + # PowerShell + irm https://aka.ms/connector-namespace-cli-install-ps | iex + ``` + +## Deploy to Azure + +```bash +cd dataverseApp +azd auth login +az login + +# Configure the trigger (name is auto-resolved to the org URL; or set the URL directly). +azd env set DATAVERSE_ENVIRONMENT_NAME "Contoso (default)" +azd env set DATAVERSE_TABLE_NAME "accounts" + +azd up +``` + +### Resources provisioned + +| Resource | Purpose | +| --- | --- | +| **Resource Group** | Contains all resources | +| **Flex Consumption Function App** (Python 3.13) | Hosts the trigger + action functions | +| **App Service Plan** (FC1) | Flex Consumption plan | +| **User-Assigned Managed Identity** | Identity for the function app | +| **Storage Account** | Deployment artifacts and function runtime state | +| **Log Analytics Workspace** | Backing store for Application Insights | +| **Application Insights** | Telemetry and logging | +| **Connector Namespace** | Hosts the Dataverse connection and trigger config | +| **Dataverse Connection** (OAuth) | Connects to your Dataverse environment | + +The connection uses **OAuth**. After provisioning, a post-deploy hook opens a browser for +interactive consent, then creates the trigger config pointing at the function's connector webhook +URL. To re-run trigger setup (e.g. after changing the table): + +```bash +azd env set DATAVERSE_TABLE_NAME "contacts" +azd provision # re-applies app settings + access policies +azd hooks run postdeploy +``` + +## Call the connector action (List rows) + +Besides *receiving* the trigger, `ListDataverseRows` *calls* the Dataverse **List rows** action +against the connection's runtime URL: + +``` +GET {runtimeUrl}/v2/datasets/{dataset}/tables/{table}/items?$top=5 +``` + +`{dataset}` (org URL) and `{table}` (entity set plural name) are each **double URL-encoded** per the +connector's `x-ms-url-encoding: "double"` contract. The call is authenticated with a bearer token for +the API Hub scope `https://apihub.azure.com/.default`, using an **explicit credential per environment** +(not `DefaultAzureCredential`): in Azure the function app's **system-assigned managed identity** +(`ManagedIdentityCredential`, no client id), and locally your `az login` (user) identity +(`AzureCliCredential`). The environment is detected via the `IDENTITY_ENDPOINT` variable that +Azure injects when managed identity is available. + +> **Why `IDENTITY_ENDPOINT`?** On App Service, Azure Functions, and Container Apps, the platform +> injects the `IDENTITY_ENDPOINT` and `IDENTITY_HEADER` environment variables that expose the local +> managed-identity token endpoint (the `api-version=2019-08-01` MSI REST protocol). These are +> documented by Microsoft and are exactly what the Azure Identity SDK reads under the hood to select +> the App Service / Functions MI source — see +> [Managed identities for App Service and Azure Functions → *Connect to Azure services in app code* (HTTP GET tab)](https://learn.microsoft.com/azure/app-service/overview-managed-identity?tabs=portal%2Chttp#connect-to-azure-services-in-app-code). +> Because the variable is present **iff** the platform MI endpoint is available (and absent locally), +> its presence is a reliable "running in Azure with MI" signal for this host — and it survives Flex +> Consumption, where `WEBSITE_INSTANCE_ID` is *not* set (Flex is not classed as App Service by the +> Functions host). +> +> **Caveat / scope:** this signal is specific to App Service, Functions, and Container Apps. Other +> managed-identity hosts differ — VM/VMSS use the [IMDS endpoint](https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/how-to-use-vm-token) +> (`169.254.169.254`) and don't set `IDENTITY_ENDPOINT`, and AKS workload identity uses +> `AZURE_FEDERATED_TOKEN_FILE`. So treat `IDENTITY_ENDPOINT` as the auth-source signal for *this* +> host, not as a general-purpose "am I in Azure?" detector. +> +> **Production guidance:** the local-vs-cloud branch exists purely for developer convenience in this +> sample. A production app should pick **one** credential and use it unconditionally — in Azure that +> means `ManagedIdentityCredential` only (no local/CLI fallback path shipped to the cloud), so there is +> no environment-detection branch to reason about, no accidental fall-through to a developer identity, +> and one deterministic auth path to secure and audit. + +For the token exchange to succeed, that identity must have an **access policy** on the connection. +The infrastructure grants: + +| Access policy | Principal | Why | +| --- | --- | --- | +| `functionapp-msi` | Function app system-assigned MI | Lets the deployed `ListDataverseRows` action call the connector | +| `connector-namespace-msi` | Connector Namespace system MI | Required for the namespace to poll the trigger | + +Invoke it after deploying (get the function key from the portal or `az functionapp keys list`): + +```bash +curl "https://.azurewebsites.net/api/rows?table=accounts&top=5&code=" +``` + +The response is `{ "table": ..., "count": N, "value": [ ...rows... ] }`. + +## Run locally + +```bash +pip install -r requirements.txt +func start +``` + +Set `COMMONDATASERVICE_CONNECTION_RUNTIME_URL` (the connection's runtime URL), the environment URL +(**or** friendly name) and the table in `local.settings.json` before starting. Locally the action +uses your signed-in `az login` (user) identity (`AzureCliCredential`). To call the action from your +machine, grant your own identity an access policy on the connection first: + +```bash +az connector-namespace connection access-policy create -g \ + --namespace --connection-name -n dev-user \ + --principal '{"type":"ActiveDirectory","identity":{"objectId":"","tenantId":""}}' +``` + +The connector trigger needs the **Preview** Functions Extension Bundle, already configured in `host.json`. + +## Verify + +The Dataverse connector namespace isn't surfaced in the portal yet, so verify from the CLI. Capture +the names created by `azd up`: + +```bash +RG=$(azd env get-value resourceGroupName) +NS=$(azd env get-value connectorNamespaceName) +CONN=$(azd env get-value connectorNamespaceConnectionName) +FUNC=$(azd env get-value dataverseFunctionName) +TRIGGER="${CONN}-$(echo "$FUNC" | tr '[:upper:]' '[:lower:]')" +``` + +- **Connection is authenticated** (`overallStatus` should be `Connected`): + + ```bash + az connector-namespace connection show -g $RG --namespace $NS -n $CONN \ + --query "{name:name, status:properties.overallStatus}" -o jsonc + ``` + +- **Trigger config is enabled**: + + ```bash + az connector-namespace trigger show -g $RG --namespace $NS -n $TRIGGER \ + --query "{state:properties.state, operation:properties.operationName}" -o jsonc + ``` + +Now **add a new row** to the configured table (e.g. create an account), wait one polling interval +(5 minutes in this sample), then tail the function logs to see the trigger fire: + +```bash +az functionapp log tail -g $RG -n $(azd env get-value functionAppName) +``` + +> **Permissions note:** these row triggers are **Admin Only** — the OAuth-connected Dataverse +> identity needs **Global Read** on the selected table, or the poll fails with `403 Forbidden`. + +## More + +- [Operations to Functions Signature Mapping](https://github.com/Azure/azure-functions-connector-extension/blob/main/docs/operations-functions-match.md) +- [Common Data Service connector reference](https://learn.microsoft.com/en-us/connectors/commondataservice/) diff --git a/dataverseApp/azure.yaml b/dataverseApp/azure.yaml new file mode 100644 index 0000000..7830482 --- /dev/null +++ b/dataverseApp/azure.yaml @@ -0,0 +1,18 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json + +name: functions-connectors-python-dataverse +services: + function-app: + host: function + project: . + language: python +hooks: + postdeploy: + windows: + shell: pwsh + run: ./infra/scripts/postdeploy.ps1 + continueOnError: false + posix: + shell: sh + run: ./infra/scripts/postdeploy.sh + continueOnError: false diff --git a/dataverseApp/function_app.py b/dataverseApp/function_app.py new file mode 100644 index 0000000..3eb7fc9 --- /dev/null +++ b/dataverseApp/function_app.py @@ -0,0 +1,160 @@ +# Copyright (c) .NET Foundation. All rights reserved. +# Licensed under the MIT License. + +import azure.functions as func +import json +import logging +import os +import urllib.error +import urllib.parse +import urllib.request + +from azure.identity import AzureCliCredential, ManagedIdentityCredential + +app = func.FunctionApp() + +# OAuth scope every managed-connector runtime URL expects (the API Hub audience). +APIHUB_SCOPE = "https://apihub.azure.com/.default" + + +# ------------------------------------------------------------------------------ +# OnDataverseRowChanged — Microsoft Dataverse connector (generic connector API) +# +# Fires when a new row is added to the configured Dataverse table. The trigger +# config (created in the post-deploy script) targets: +# connector : commondataservice +# operation : GetOnNewItems_V2 +# parameters: +# dataset = -> DATAVERSE_ENVIRONMENT_URL (the DataSet name, +# e.g. https://org.crm.dynamics.com) +# table = -> DATAVERSE_TABLE_NAME (entity set / plural +# logical name, e.g. "accounts") +# +# The connector polls Dataverse server-side (default every few minutes) and posts +# each new row to this function's connector webhook callback. +# ------------------------------------------------------------------------------ +@app.function_name(name="OnDataverseRowChanged") +@app.connector_trigger(arg_name="payload") +def on_dataverse_row_changed(payload: str) -> None: + """Triggered when a new Dataverse row is added.""" + logging.info("OnDataverseRowChanged trigger received.") + + environment = os.environ.get("DATAVERSE_ENVIRONMENT_URL") or os.environ.get( + "DATAVERSE_ENVIRONMENT_NAME", "" + ) + table = os.environ.get("DATAVERSE_TABLE_NAME", "") + logging.info(f"Environment: '{environment}', Table: '{table}'.") + + data = json.loads(payload) + + # The connector delivers a batch under body.value; fall back to a single + # object body for connectors/versions that post one notification at a time. + body = data.get("body", data) + rows = body.get("value") if isinstance(body, dict) else None + if rows is None: + rows = [body] + + for row in rows: + if not isinstance(row, dict): + continue + + # The payload is the newly added Dataverse row. The connector tags each + # item with an "ItemInternalId"; the row's primary key is "id" + # (e.g. accountid), derived from the singular table name. + singular = table[:-1] if table.endswith("s") else table + record_id = ( + row.get("ItemInternalId") + or row.get(f"{singular}id") + or "" + ) + + logging.info(f"New '{table}' row id: '{record_id}'.") + logging.info(f"Columns in payload: {list(row.keys())}.") + + logging.info(f"Batch contains '{len(rows)}' new row(s).") + + +# ------------------------------------------------------------------------------ +# ListDataverseRows — Microsoft Dataverse connector ACTION (List rows) +# +# Demonstrates *calling* a connector action (not just receiving a trigger). The +# function authenticates to the connection's runtime URL with the function app's +# managed identity and invokes the connector's "List rows" operation: +# +# GET {runtimeUrl}/v2/datasets/{dataset}/tables/{table}/items +# +# where {dataset} (the org URL) and {table} (entity set plural name) are each +# double URL-encoded per the connector's `x-ms-url-encoding: "double"` contract. +# The call is authorized by the `functionapp-msi` access policy granted on the +# connection. Auth is explicit per environment: in Azure the function app's +# managed identity, and locally the signed-in `az login` (user) identity. +# +# HTTP: GET /api/rows?table=accounts&top=5 +# ------------------------------------------------------------------------------ +@app.function_name(name="ListDataverseRows") +@app.route(route="rows", methods=["GET"]) +def list_dataverse_rows(req: func.HttpRequest) -> func.HttpResponse: + """List rows from the configured Dataverse table via the connector action.""" + logging.info("ListDataverseRows action invoked.") + + runtime_url = os.environ.get("COMMONDATASERVICE_CONNECTION_RUNTIME_URL") + if not runtime_url: + return func.HttpResponse( + "COMMONDATASERVICE_CONNECTION_RUNTIME_URL is not configured.", + status_code=500, + ) + + dataset = os.environ.get("DATAVERSE_ENVIRONMENT_URL") or os.environ.get( + "DATAVERSE_ENVIRONMENT_NAME" + ) + if not dataset: + return func.HttpResponse( + "Set either DATAVERSE_ENVIRONMENT_URL or DATAVERSE_ENVIRONMENT_NAME.", + status_code=500, + ) + + # Table and row count are overridable per request; fall back to app settings. + table = req.params.get("table") or os.environ.get( + "DATAVERSE_TABLE_NAME", "accounts" + ) + top = req.params.get("top", "5") + + # Both path segments are double URL-encoded (x-ms-url-encoding: "double"). + enc_dataset = urllib.parse.quote(urllib.parse.quote(dataset, safe=""), safe="") + enc_table = urllib.parse.quote(urllib.parse.quote(table, safe=""), safe="") + url = ( + f"{runtime_url.rstrip('/')}/v2/datasets/{enc_dataset}" + f"/tables/{enc_table}/items?$top={urllib.parse.quote(str(top))}" + ) + + # Explicit credential per environment (DefaultAzureCredential is not used): + # - In Azure (IDENTITY_ENDPOINT is injected) -> the function app's managed identity. + # - Locally (not set) -> the signed-in `az login` (user) identity. + if os.environ.get("IDENTITY_ENDPOINT"): + credential = ManagedIdentityCredential() + else: + credential = AzureCliCredential() + + token = credential.get_token(APIHUB_SCOPE).token + request = urllib.request.Request( + url, + method="GET", + headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + ) + + try: + with urllib.request.urlopen(request, timeout=30) as response: + payload = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as error: + body = error.read().decode("utf-8", errors="replace") + logging.error(f"Connector action failed ({error.code}): {body}") + return func.HttpResponse(body, status_code=error.code, mimetype="application/json") + + rows = payload.get("value", []) if isinstance(payload, dict) else [] + logging.info(f"Retrieved '{len(rows)}' row(s) from table '{table}'.") + + return func.HttpResponse( + json.dumps({"table": table, "count": len(rows), "value": rows}), + status_code=200, + mimetype="application/json", + ) diff --git a/dataverseApp/host.json b/dataverseApp/host.json new file mode 100644 index 0000000..1bd0a60 --- /dev/null +++ b/dataverseApp/host.json @@ -0,0 +1,18 @@ +{ + "version": "2.0", + "logging": { + "logLevel": { + "Microsoft.Azure.Functions.Extensions.Connector": "Information" + }, + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle.Preview", + "version": "[4.42.*, 5.0.0)" + } +} diff --git a/dataverseApp/infra/abbreviations.json b/dataverseApp/infra/abbreviations.json new file mode 100644 index 0000000..e7bb6a8 --- /dev/null +++ b/dataverseApp/infra/abbreviations.json @@ -0,0 +1,137 @@ +{ + "connectorNamespaces": "cns-", + "connectorNamespacesConnections": "cnsc-", + "analysisServicesServers": "as", + "apiManagementService": "apim-", + "appConfigurationConfigurationStores": "appcs-", + "appManagedEnvironments": "cae-", + "appContainerApps": "ca-", + "authorizationPolicyDefinitions": "policy-", + "automationAutomationAccounts": "aa-", + "blueprintBlueprints": "bp-", + "blueprintBlueprintsArtifacts": "bpa-", + "cacheRedis": "redis-", + "cdnProfiles": "cdnp-", + "cdnProfilesEndpoints": "cdne-", + "cognitiveServicesAccounts": "cog-", + "cognitiveServicesFormRecognizer": "cog-fr-", + "cognitiveServicesTextAnalytics": "cog-ta-", + "computeAvailabilitySets": "avail-", + "computeCloudServices": "cld-", + "computeDiskEncryptionSets": "des", + "computeDisks": "disk", + "computeDisksOs": "osdisk", + "computeGalleries": "gal", + "computeSnapshots": "snap-", + "computeVirtualMachines": "vm", + "computeVirtualMachineScaleSets": "vmss-", + "containerInstanceContainerGroups": "ci", + "containerRegistryRegistries": "cr", + "containerServiceManagedClusters": "aks-", + "databricksWorkspaces": "dbw-", + "dataFactoryFactories": "adf-", + "dataLakeAnalyticsAccounts": "dla", + "dataLakeStoreAccounts": "dls", + "dataMigrationServices": "dms-", + "dBforMySQLServers": "mysql-", + "dBforPostgreSQLServers": "psql-", + "devicesIotHubs": "iot-", + "devicesProvisioningServices": "provs-", + "devicesProvisioningServicesCertificates": "pcert-", + "documentDBDatabaseAccounts": "cosmos-", + "eventGridDomains": "evgd-", + "eventGridDomainsTopics": "evgt-", + "eventGridEventSubscriptions": "evgs-", + "eventHubNamespaces": "evhns-", + "eventHubNamespacesEventHubs": "evh-", + "hdInsightClustersHadoop": "hadoop-", + "hdInsightClustersHbase": "hbase-", + "hdInsightClustersKafka": "kafka-", + "hdInsightClustersMl": "mls-", + "hdInsightClustersSpark": "spark-", + "hdInsightClustersStorm": "storm-", + "hybridComputeMachines": "arcs-", + "insightsActionGroups": "ag-", + "insightsComponents": "appi-", + "keyVaultVaults": "kv-", + "kubernetesConnectedClusters": "arck", + "kustoClusters": "dec", + "kustoClustersDatabases": "dedb", + "logicIntegrationAccounts": "ia-", + "logicWorkflows": "logic-", + "machineLearningServicesWorkspaces": "mlw-", + "managedIdentityUserAssignedIdentities": "id-", + "managementManagementGroups": "mg-", + "migrateAssessmentProjects": "migr-", + "networkApplicationGateways": "agw-", + "networkApplicationSecurityGroups": "asg-", + "networkAzureFirewalls": "afw-", + "networkBastionHosts": "bas-", + "networkConnections": "con-", + "networkDnsZones": "dnsz-", + "networkExpressRouteCircuits": "erc-", + "networkFirewallPolicies": "afwp-", + "networkFirewallPoliciesWebApplication": "waf", + "networkFirewallPoliciesRuleGroups": "wafrg", + "networkFrontDoors": "fd-", + "networkFrontdoorWebApplicationFirewallPolicies": "fdfp-", + "networkLoadBalancersExternal": "lbe-", + "networkLoadBalancersInternal": "lbi-", + "networkLoadBalancersInboundNatRules": "rule-", + "networkLocalNetworkGateways": "lgw-", + "networkNatGateways": "ng-", + "networkNetworkInterfaces": "nic-", + "networkNetworkSecurityGroups": "nsg-", + "networkNetworkSecurityGroupsSecurityRules": "nsgsr-", + "networkNetworkWatchers": "nw-", + "networkPrivateDnsZones": "pdnsz-", + "networkPrivateLinkServices": "pl-", + "networkPublicIPAddresses": "pip-", + "networkPublicIPPrefixes": "ippre-", + "networkRouteFilters": "rf-", + "networkRouteTables": "rt-", + "networkRouteTablesRoutes": "udr-", + "networkTrafficManagerProfiles": "traf-", + "networkVirtualNetworkGateways": "vgw-", + "networkVirtualNetworks": "vnet-", + "networkVirtualNetworksSubnets": "snet-", + "networkVirtualNetworksVirtualNetworkPeerings": "peer-", + "networkVirtualWans": "vwan-", + "networkVpnGateways": "vpng-", + "networkVpnGatewaysVpnConnections": "vcn-", + "networkVpnGatewaysVpnSites": "vst-", + "notificationHubsNamespaces": "ntfns-", + "notificationHubsNamespacesNotificationHubs": "ntf-", + "operationalInsightsWorkspaces": "log-", + "portalDashboards": "dash-", + "powerBIDedicatedCapacities": "pbi-", + "purviewAccounts": "pview-", + "recoveryServicesVaults": "rsv-", + "resourcesResourceGroups": "rg-", + "searchSearchServices": "srch-", + "serviceBusNamespaces": "sb-", + "serviceBusNamespacesQueues": "sbq-", + "serviceBusNamespacesTopics": "sbt-", + "serviceEndPointPolicies": "se-", + "serviceFabricClusters": "sf-", + "signalRServiceSignalR": "sigr", + "sqlManagedInstances": "sqlmi-", + "sqlServers": "sql-", + "sqlServersDataWarehouse": "sqldw-", + "sqlServersDatabases": "sqldb-", + "sqlServersDatabasesStretch": "sqlstrdb-", + "storageStorageAccounts": "st", + "storageStorageAccountsVm": "stvm", + "storSimpleManagers": "ssimp", + "streamAnalyticsCluster": "asa-", + "synapseWorkspaces": "syn", + "synapseWorkspacesAnalyticsWorkspaces": "synw", + "synapseWorkspacesSqlPoolsDedicated": "syndp", + "synapseWorkspacesSqlPoolsSpark": "synsp", + "timeSeriesInsightsEnvironments": "tsi-", + "webServerFarms": "plan-", + "webSitesAppService": "app-", + "webSitesAppServiceEnvironment": "ase-", + "webSitesFunctions": "func-", + "webStaticSites": "stapp-" +} diff --git a/dataverseApp/infra/connectorNamespace.bicep b/dataverseApp/infra/connectorNamespace.bicep new file mode 100644 index 0000000..53f2581 --- /dev/null +++ b/dataverseApp/infra/connectorNamespace.bicep @@ -0,0 +1,53 @@ +// Connector Namespace + Common Data Service / Dataverse (commondataservice) connection +// using OAuth. Requires interactive consent during post-deploy to authenticate; +// sign in with an account that has access to the target Dataverse environment. + +param name string +param location string +param tags object = {} +param connectionName string +param tenantId string = tenant().tenantId + +resource connectorNamespace 'Microsoft.Web/connectorGateways@2026-05-01-preview' = { + name: name + location: location + tags: tags + identity: { + type: 'SystemAssigned' + } +} + +resource dataverseConnection 'Microsoft.Web/connectorGateways/connections@2026-05-01-preview' = { + parent: connectorNamespace + name: connectionName + properties: { + connectorName: 'commondataservice' + } +} + +// Connector Namespace system MI -> Dataverse connection (needed to poll triggers). +resource dataverseConnectionNamespaceAccessPolicy 'Microsoft.Web/connectorGateways/connections/accessPolicies@2026-05-01-preview' = { + parent: dataverseConnection + name: 'connector-namespace-msi' + properties: { + principal: { + type: 'ActiveDirectory' + identity: { + objectId: connectorNamespace.identity.principalId + tenantId: tenantId + } + } + } +} + +@description('The resource ID of the Connector Namespace.') +output resourceId string = connectorNamespace.id + +@description('The name of the Connector Namespace.') +output name string = connectorNamespace.name + +@description('The name of the Dataverse connection on the namespace.') +output connectionName string = dataverseConnection.name + +@description('Runtime URL for the Dataverse connection.') +output dataverseConnectionRuntimeUrl string = dataverseConnection.properties.connectionRuntimeUrl diff --git a/dataverseApp/infra/functionAccessPolicy.bicep b/dataverseApp/infra/functionAccessPolicy.bicep new file mode 100644 index 0000000..22a52ae --- /dev/null +++ b/dataverseApp/infra/functionAccessPolicy.bicep @@ -0,0 +1,36 @@ +// Grants the function app's system-assigned managed identity access to the +// Dataverse (commondataservice) connection so it can call connector actions at +// runtime (e.g. the ListDataverseRows HTTP function). Kept in a separate module +// so it is applied *after* the function app is created — the function app itself +// depends on the connection's runtime URL, so the connection cannot depend on the +// function app's identity (that would be a cycle). + +param connectorNamespaceName string +param connectionName string +param tenantId string = tenant().tenantId + +@description('Object (principal) ID of the function app system-assigned managed identity.') +param functionAppPrincipalId string + +resource connectorNamespace 'Microsoft.Web/connectorGateways@2026-05-01-preview' existing = { + name: connectorNamespaceName +} + +resource dataverseConnection 'Microsoft.Web/connectorGateways/connections@2026-05-01-preview' existing = { + parent: connectorNamespace + name: connectionName +} + +resource dataverseConnectionFunctionAppAccessPolicy 'Microsoft.Web/connectorGateways/connections/accessPolicies@2026-05-01-preview' = { + parent: dataverseConnection + name: 'functionapp-msi' + properties: { + principal: { + type: 'ActiveDirectory' + identity: { + objectId: functionAppPrincipalId + tenantId: tenantId + } + } + } +} diff --git a/dataverseApp/infra/main.bicep b/dataverseApp/infra/main.bicep new file mode 100644 index 0000000..bd49d2d --- /dev/null +++ b/dataverseApp/infra/main.bicep @@ -0,0 +1,289 @@ +targetScope = 'subscription' + +@minLength(1) +@maxLength(64) +@description('Name of the the environment which is used to generate a short unique hash used in all resources.') +param environmentName string + +@metadata({ + azd: { + type: 'location' + } +}) +@description('Location for all resources except the Connector Namespace.') +param location string + +@description('Region for the Connector Namespace. Override via CONNECTOR_NAMESPACE_LOCATION if needed.') +param connectorNamespaceLocation string = 'westcentralus' + +@description('URL of the Dataverse environment to monitor, e.g. https://orgb2e33949.crm.dynamics.com. Used as the trigger "dataset" value. If empty, it is resolved from dataverseEnvironmentName during post-deploy.') +param dataverseEnvironmentUrl string = '' + +@description('Friendly name of the Dataverse environment, e.g. "Contoso (default)". When dataverseEnvironmentUrl is empty, the org URL is auto-resolved from this name via the Dataverse Global Discovery Service during post-deploy.') +param dataverseEnvironmentName string = '' + +@description('Entity set (plural logical) name of the Dataverse table whose new rows fire the trigger, e.g. accounts, contacts.') +param dataverseTableName string = 'accounts' + +metadata name = 'Azure Functions Microsoft Dataverse Connector Trigger (Python)' +metadata description = 'Connector Namespace trigger sample: fires when a new Dataverse row is added.' + +@description('Id of the user identity to be used for testing and debugging. Granted access to the Dataverse connection so the same code can be debugged locally with `az login`.') +@metadata({ + azd: { + type: 'principalId' + } +}) +param userPrincipalId string = deployer().objectId + +var abbrs = loadJsonContent('./abbreviations.json') +var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) +var tags = { 'azd-env-name': environmentName } + +var functionAppName = '${abbrs.webSitesFunctions}${resourceToken}' +var functionAppPlanName = '${abbrs.webServerFarms}${resourceToken}' +var functionAppIdentityName = '${abbrs.managedIdentityUserAssignedIdentities}${resourceToken}' +var resourceGroupName = '${abbrs.resourcesResourceGroups}${environmentName}' +var storageAccountName = '${abbrs.storageStorageAccounts}${resourceToken}' +var logAnalyticsName = '${abbrs.operationalInsightsWorkspaces}${resourceToken}' +var appInsightsName = '${abbrs.insightsComponents}${resourceToken}' +var connectorNamespaceName = '${abbrs.connectorNamespaces}${resourceToken}' +var connectorNamespaceConnectionName = '${abbrs.connectorNamespacesConnections}${resourceToken}' + +var deploymentStorageContainerName = 'app-package-${take(functionAppName, 32)}-${take(toLower(uniqueString(functionAppName, environmentName)), 7)}' +var storageBlobDataOwner = 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b' +var storageQueueDataContributor = '974c5e8b-45b9-4653-ba55-5f855dd0fb88' +var storageTableDataContributor = '0a9a7e1f-b9d0-4cc4-a60d-0319b160aaa3' +var monitoringMetricsPublisherRoleId = '3913510d-42f4-4e42-8a64-420c390055eb' + +resource rg 'Microsoft.Resources/resourceGroups@2025-04-01' = { + name: resourceGroupName + location: location + tags: tags +} + +module logAnalytics 'br/public:avm/res/operational-insights/workspace:0.15.0' = { + name: '${uniqueString(deployment().name, location)}-loganalytics' + scope: rg + params: { + name: logAnalyticsName + location: location + tags: tags + dataRetention: 30 + } +} + +module funcUserAssignedIdentity 'br/public:avm/res/managed-identity/user-assigned-identity:0.5.0' = { + name: 'funcUserAssignedIdentity' + scope: rg + params: { + location: location + tags: tags + name: functionAppIdentityName + } +} + +module monitoring 'br/public:avm/res/insights/component:0.7.1' = { + name: '${uniqueString(deployment().name, location)}-appinsights' + scope: rg + params: { + name: appInsightsName + location: location + tags: tags + workspaceResourceId: logAnalytics.outputs.resourceId + disableLocalAuth: true + roleAssignments: [ + { + roleDefinitionIdOrName: monitoringMetricsPublisherRoleId + principalId: funcUserAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: monitoringMetricsPublisherRoleId + principalId: userPrincipalId + principalType: 'User' + } + ] + } +} + +module storageAccount 'br/public:avm/res/storage/storage-account:0.32.0' = { + scope: rg + name: storageAccountName + params: { + name: storageAccountName + location: location + tags: tags + skuName: 'Standard_LRS' + kind: 'StorageV2' + allowBlobPublicAccess: false + allowSharedKeyAccess: false + publicNetworkAccess: 'Enabled' + networkAcls: { + defaultAction: 'Allow' + } + minimumTlsVersion: 'TLS1_2' + blobServices: { + containers: [{ name: deploymentStorageContainerName }] + } + roleAssignments: [ + { + roleDefinitionIdOrName: storageBlobDataOwner + principalId: funcUserAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: storageQueueDataContributor + principalId: funcUserAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: storageTableDataContributor + principalId: funcUserAssignedIdentity.outputs.principalId + principalType: 'ServicePrincipal' + } + { + roleDefinitionIdOrName: storageBlobDataOwner + principalId: userPrincipalId + principalType: 'User' + } + { + roleDefinitionIdOrName: storageQueueDataContributor + principalId: userPrincipalId + principalType: 'User' + } + { + roleDefinitionIdOrName: storageTableDataContributor + principalId: userPrincipalId + principalType: 'User' + } + ] + } +} + +module functionAppPlan 'br/public:avm/res/web/serverfarm:0.7.0' = { + scope: rg + name: functionAppPlanName + params: { + name: functionAppPlanName + location: location + tags: tags + skuName: 'FC1' + reserved: true + } +} + +// Connector Namespace + Dataverse connection (OAuth). +module connectorNamespace './connectorNamespace.bicep' = { + scope: rg + name: connectorNamespaceName + params: { + name: connectorNamespaceName + location: connectorNamespaceLocation + tags: tags + connectionName: connectorNamespaceConnectionName + } +} + +var allAppSettings = { + AzureWebJobsStorage__credential: 'managedidentity' + AzureWebJobsStorage__clientId: funcUserAssignedIdentity.outputs.clientId + AzureWebJobsStorage__accountName: storageAccount.outputs.name + APPLICATIONINSIGHTS_AUTHENTICATION_STRING: 'ClientId=${funcUserAssignedIdentity.outputs.clientId};Authorization=AAD' + APPLICATIONINSIGHTS_CONNECTION_STRING: monitoring.outputs.connectionString + AZURE_CLIENT_ID: funcUserAssignedIdentity.outputs.clientId + COMMONDATASERVICE_CONNECTION_RUNTIME_URL: connectorNamespace.outputs.dataverseConnectionRuntimeUrl + DATAVERSE_ENVIRONMENT_URL: dataverseEnvironmentUrl + DATAVERSE_ENVIRONMENT_NAME: dataverseEnvironmentName + DATAVERSE_TABLE_NAME: dataverseTableName +} + +module functionApp 'br/public:avm/res/web/site:0.22.0' = { + scope: rg + name: functionAppName + params: { + name: functionAppName + location: location + tags: union(tags, { 'azd-service-name': 'function-app' }) + kind: 'functionapp,linux' + serverFarmResourceId: functionAppPlan.outputs.resourceId + httpsOnly: true + managedIdentities: { + systemAssigned: true + userAssignedResourceIds: [ + '${funcUserAssignedIdentity.outputs.resourceId}' + ] + } + functionAppConfig: { + deployment: { + storage: { + type: 'blobContainer' + value: '${storageAccount.outputs.primaryBlobEndpoint}${deploymentStorageContainerName}' + authentication: { + type: 'UserAssignedIdentity' + userAssignedIdentityResourceId: funcUserAssignedIdentity.outputs.resourceId + } + } + } + scaleAndConcurrency: { + instanceMemoryMB: 2048 + maximumInstanceCount: 100 + } + runtime: { + name: 'python' + version: '3.13' + } + } + siteConfig: { + alwaysOn: false + } + configs: [ + { + name: 'appsettings' + properties: allAppSettings + } + ] + } +} + +// Grant the function app's system-assigned MI access to the Dataverse connection +// (applied after the app to avoid a dependency cycle with the connection). +module functionAccessPolicy './functionAccessPolicy.bicep' = { + scope: rg + name: '${connectorNamespaceName}-func-policy' + params: { + connectorNamespaceName: connectorNamespace.outputs.name + connectionName: connectorNamespace.outputs.connectionName + functionAppPrincipalId: functionApp.outputs.systemAssignedMIPrincipalId + } +} + +@description('The resource ID of the created Resource Group.') +output resourceGroupResourceId string = rg.id + +@description('The name of the created Resource Group.') +output resourceGroupName string = rg.name + +@description('The name of the created Function App.') +output functionAppName string = functionApp.outputs.name + +@description('The default hostname of the created Function App.') +output functionAppDefaultHostname string = functionApp.outputs.defaultHostname + +@description('The name of the created Connector Namespace.') +output connectorNamespaceName string = connectorNamespace.outputs.name + +@description('The name of the Dataverse connection on the Connector Namespace.') +output connectorNamespaceConnectionName string = connectorNamespace.outputs.connectionName + +@description('The name of the Azure Function that handles the Dataverse trigger.') +output dataverseFunctionName string = 'OnDataverseRowChanged' + +@description('The Dataverse environment URL monitored by the trigger.') +output dataverseEnvironmentUrl string = dataverseEnvironmentUrl + +@description('The Dataverse environment friendly name (used to resolve the org URL when the URL is not set).') +output dataverseEnvironmentName string = dataverseEnvironmentName + +@description('The Dataverse table (entity set / plural logical) name monitored by the trigger.') +output dataverseTableName string = dataverseTableName diff --git a/dataverseApp/infra/main.parameters.json b/dataverseApp/infra/main.parameters.json new file mode 100644 index 0000000..c03032b --- /dev/null +++ b/dataverseApp/infra/main.parameters.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + }, + "connectorNamespaceLocation": { + "value": "${CONNECTOR_NAMESPACE_LOCATION=westcentralus}" + }, + "userPrincipalId": { + "value": "${AZURE_PRINCIPAL_ID}" + }, + "dataverseEnvironmentUrl": { + "value": "${DATAVERSE_ENVIRONMENT_URL=}" + }, + "dataverseEnvironmentName": { + "value": "${DATAVERSE_ENVIRONMENT_NAME=}" + }, + "dataverseTableName": { + "value": "${DATAVERSE_TABLE_NAME=accounts}" + } + } +} diff --git a/dataverseApp/infra/scripts/postdeploy.ps1 b/dataverseApp/infra/scripts/postdeploy.ps1 new file mode 100644 index 0000000..ac5488f --- /dev/null +++ b/dataverseApp/infra/scripts/postdeploy.ps1 @@ -0,0 +1,224 @@ +# Post-deployment configuration for the dataverseApp (Python). +# +# The Microsoft Dataverse connection uses OAuth authentication. This script +# authorizes the connection via interactive consent (sign in with an account +# that has access to the target Dataverse environment), then creates the trigger +# config pointing at the function's connector webhook URL. +# +# Trigger: GetOnNewItems_V2 — fires when a new row is added to the table. +# dataset = (the DataSet name, e.g. https://org.crm.dynamics.com) +# table = (entity set / plural logical name, e.g. accounts) +# +# Notes: +# - Do NOT pass $top: the poll fails with 400 when Change Tracking is enabled. +# - The connected Dataverse identity needs Global Read on the table (these row +# triggers are Admin Only); otherwise the poll returns 403 Forbidden. + +Write-Host "Post-deployment configuration..." -ForegroundColor Yellow + +# --- Pre-flight: verify the connector-namespace CLI extension is installed ---- +if (-not (az extension show --name connector-namespace --query name -o tsv 2>$null)) { + Write-Host "ERROR: The 'connector-namespace' Azure CLI extension is required." -ForegroundColor Red + Write-Host "Install: irm https://aka.ms/connector-namespace-cli-install-ps | iex" -ForegroundColor Red + exit 1 +} + +# --- Read azd outputs -------------------------------------------------------- +$outputs = azd env get-values --output json | ConvertFrom-Json + +$resourceGroupName = $outputs.resourceGroupName +$connectorNamespaceName = $outputs.connectorNamespaceName +$connectorNamespaceConnectionName = $outputs.connectorNamespaceConnectionName +$functionAppName = $outputs.functionAppName +$subscriptionId = $outputs.AZURE_SUBSCRIPTION_ID +$dataverseEnvironmentUrl = $outputs.dataverseEnvironmentUrl +$dataverseEnvironmentName = $outputs.dataverseEnvironmentName +$tableName = $outputs.dataverseTableName + +if (-not $resourceGroupName -or -not $connectorNamespaceName -or -not $connectorNamespaceConnectionName -or -not $functionAppName) { + Write-Host "ERROR: required azd outputs missing. Run 'azd provision' first." -ForegroundColor Red + exit 1 +} + +# --- Resolve the org URL from the environment friendly name (Global Discovery) --- +# If DATAVERSE_ENVIRONMENT_URL is not set but a friendly name is, look up the org +# URL via the Dataverse Global Discovery Service using the signed-in az identity. +if (-not $dataverseEnvironmentUrl -and $dataverseEnvironmentName) { + Write-Host "Resolving org URL for environment '$dataverseEnvironmentName' via Global Discovery..." -ForegroundColor Cyan + $discoResource = 'https://globaldisco.crm.dynamics.com' + $token = az account get-access-token --resource $discoResource --query accessToken -o tsv 2>$null + if (-not $token) { + Write-Host "ERROR: could not obtain a Dataverse token. Ensure your 'az login' identity has access to the" -ForegroundColor Red + Write-Host " environment, or set DATAVERSE_ENVIRONMENT_URL explicitly and re-provision." -ForegroundColor Red + exit 1 + } + try { + $resp = Invoke-RestMethod -Uri "$discoResource/api/discovery/v2.0/Instances" ` + -Headers @{ Authorization = "Bearer $token" } -Method Get + } catch { + Write-Host "ERROR: Global Discovery request failed: $_" -ForegroundColor Red + exit 1 + } + $match = @($resp.value | Where-Object { $_.FriendlyName -eq $dataverseEnvironmentName }) + if ($match.Count -eq 0) { + Write-Host "ERROR: no environment named '$dataverseEnvironmentName' is visible to your identity." -ForegroundColor Red + Write-Host "Environments available to you:" -ForegroundColor Yellow + $resp.value | ForEach-Object { Write-Host " - $($_.FriendlyName) -> $($_.Url)" } + exit 1 + } + if ($match.Count -gt 1) { + Write-Host " multiple environments named '$dataverseEnvironmentName'; using the first." -ForegroundColor Yellow + } + $dataverseEnvironmentUrl = $match[0].Url.TrimEnd('/') + Write-Host " resolved: $dataverseEnvironmentUrl" -ForegroundColor Green +} + +if (-not $dataverseEnvironmentUrl) { + Write-Host "ERROR: Dataverse environment is not set. Provide the friendly name (auto-resolved) or the URL:" -ForegroundColor Red + Write-Host " azd env set DATAVERSE_ENVIRONMENT_NAME 'Contoso (default)'" -ForegroundColor Red + Write-Host " # or" -ForegroundColor Red + Write-Host " azd env set DATAVERSE_ENVIRONMENT_URL 'https://.crm.dynamics.com'" -ForegroundColor Red + Write-Host " azd provision" -ForegroundColor Red + exit 1 +} + +if (-not $tableName) { $tableName = 'accounts' } + +# GetOnNewItems_V2 uses the org URL as the "dataset" value (no trailing slash). +$dataset = $dataverseEnvironmentUrl.TrimEnd('/') + +Write-Host "Environment : $dataverseEnvironmentUrl$(if ($dataverseEnvironmentName) { " ($dataverseEnvironmentName)" })" -ForegroundColor DarkGray +Write-Host "Dataset : $dataset" -ForegroundColor DarkGray +Write-Host "Table : $tableName" -ForegroundColor DarkGray + +# Persist the resolved org URL as an app setting so the ListDataverseRows action +# (which reads DATAVERSE_ENVIRONMENT_URL directly) uses the org URL as its dataset, +# matching the trigger. Without this, a friendly-name-only config leaves the setting +# empty and the action would send the name, which the connector rejects (400). +Write-Host "Persisting resolved org URL to $functionAppName app settings..." -ForegroundColor Cyan +az functionapp config appsettings set -g $resourceGroupName -n $functionAppName ` + --settings "DATAVERSE_ENVIRONMENT_URL=$dataset" -o none +azd env set DATAVERSE_ENVIRONMENT_URL $dataset 2>$null | Out-Null + +# --- Authorize the Dataverse connection (OAuth consent) ---------------------- +Write-Host "" +Write-Host "Authorizing Microsoft Dataverse connection..." -ForegroundColor Yellow + +$currentStatus = az connector-namespace connection show ` + -g $resourceGroupName --namespace $connectorNamespaceName ` + -n $connectorNamespaceConnectionName ` + --query "properties.overallStatus" -o tsv 2>$null + +if ($currentStatus -and $currentStatus.ToLower() -eq 'connected') { + Write-Host " already Connected; skipping consent flow" -ForegroundColor Green +} else { + Write-Host "-> A browser tab will open. Sign in with an account that has access to the Dataverse environment." -ForegroundColor Cyan + + $consentParameters = "[{parameterName:token,redirectUrl:'https://portal.azure.com'}]" + $link = $null + + for ($i = 0; $i -lt 5; $i++) { + $link = az connector-namespace connection list-consent-links ` + -g $resourceGroupName --namespace $connectorNamespaceName ` + --connection-name $connectorNamespaceConnectionName ` + --parameters $consentParameters ` + --query "value[0].link" -o tsv 2>$null + if (-not $link) { + $link = az connector-namespace connection list-consent-links ` + -g $resourceGroupName --namespace $connectorNamespaceName ` + --connection-name $connectorNamespaceConnectionName ` + --parameters $consentParameters ` + --query "link" -o tsv 2>$null + } + if ($link) { break } + + Write-Host " list-consent-links attempt $($i + 1) failed; retrying in 5s..." -ForegroundColor Yellow + Start-Sleep -Seconds 5 + } + + if (-not $link) { + Write-Host "ERROR: could not obtain consent link after retries." -ForegroundColor Red + exit 1 + } + + Write-Host " opening browser for OAuth consent..." -ForegroundColor Cyan + Write-Host " (if no tab opens, paste this URL manually: $link)" -ForegroundColor Cyan + try { Start-Process $link | Out-Null } catch { Write-Host " Start-Process failed: $_" -ForegroundColor Yellow } + + $deadline = (Get-Date).AddMinutes(5) + $authorized = $false + while ((Get-Date) -lt $deadline) { + Start-Sleep -Seconds 5 + $status = az connector-namespace connection show ` + -g $resourceGroupName --namespace $connectorNamespaceName ` + -n $connectorNamespaceConnectionName ` + --query "properties.overallStatus" -o tsv 2>$null + Write-Host " status: $status" + if ($status -and $status.ToLower() -eq 'connected') { + $authorized = $true + break + } + } + + if (-not $authorized) { + Write-Host "ERROR: Connection did not reach 'Connected' status within 5 minutes." -ForegroundColor Red + exit 1 + } + Write-Host " Connection authenticated" -ForegroundColor Green +} + +# --- Create Connector Namespace trigger config ------------------------------- +Write-Host "" +Write-Host "Fetching connector extension key for $functionAppName..." -ForegroundColor Cyan +$connectorExtensionKey = (az functionapp keys list -g $resourceGroupName -n $functionAppName --query "systemKeys.connector_extension" -o tsv) +if (-not $connectorExtensionKey) { + Write-Host "ERROR: could not fetch connector_extension system key from $functionAppName." -ForegroundColor Red + exit 1 +} + +$functionName = 'OnDataverseRowChanged' +$operationName = 'GetOnNewItems_V2' +$triggerName = "$connectorNamespaceConnectionName-$($functionName.ToLower())" +# Read the app's real default host rather than assuming ".azurewebsites.net" (which differs for +# custom domains and sovereign clouds, e.g. .azurewebsites.us / .chinacloudsites.cn). +$functionHost = (az functionapp show -g $resourceGroupName -n $functionAppName --query "defaultHostName" -o tsv) +if (-not $functionHost) { + Write-Host "ERROR: could not resolve defaultHostName for $functionAppName." -ForegroundColor Red + exit 1 +} +$callbackUrl = "https://$functionHost/runtime/webhooks/connector?functionName=$functionName&code=$connectorExtensionKey" +$notifFile = Join-Path $PSScriptRoot ".notification-details-$([System.Guid]::NewGuid().ToString('N')).json" +@{ callbackUrl = $callbackUrl } | ConvertTo-Json -Compress | Set-Content -Path $notifFile -NoNewline + +Write-Host "Creating trigger '$triggerName' for $functionName ($operationName)..." -ForegroundColor Yellow + +try { + az connector-namespace trigger delete ` + -g $resourceGroupName --namespace $connectorNamespaceName ` + -n $triggerName --yes 2>$null | Out-Null + + az connector-namespace trigger create ` + -g $resourceGroupName --namespace $connectorNamespaceName ` + -n $triggerName ` + --connection-details "{connectionName:$connectorNamespaceConnectionName,connectorName:commondataservice}" ` + --operation-name $operationName ` + --parameters "[{name:dataset,value:'$dataset'},{name:table,value:'$tableName'}]" ` + --notification-details "@$notifFile" ` + --description "When a new row is added" ` + --metadata "{destinationType:functionApp,functionAppName:$functionAppName,functionAppResourceGroup:$resourceGroupName,functionAppSubscriptionId:$subscriptionId,functionName:$functionName,recurrenceFrequency:Minute,recurrenceInterval:'5'}" ` + -o none + + if ($LASTEXITCODE -ne 0) { + Write-Host "Failed to create trigger config for $functionName." -ForegroundColor Red + exit 1 + } +} +finally { + Remove-Item $notifFile -ErrorAction SilentlyContinue +} + +Write-Host "" +Write-Host "Post-deployment configuration complete." -ForegroundColor Green +Write-Host "Add a new '$tableName' row in '$dataverseEnvironmentUrl' to fire the trigger (allow one polling interval)." -ForegroundColor Green +Write-Host "Tail logs: az functionapp log tail -g $resourceGroupName -n $functionAppName" -ForegroundColor Green +Write-Host "" diff --git a/dataverseApp/infra/scripts/postdeploy.sh b/dataverseApp/infra/scripts/postdeploy.sh new file mode 100644 index 0000000..543572b --- /dev/null +++ b/dataverseApp/infra/scripts/postdeploy.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +# Post-deployment configuration for the dataverseApp (Python). +# +# The Microsoft Dataverse connection uses OAuth authentication. This script +# authorizes the connection via interactive consent (sign in with an account +# that has access to the target Dataverse environment), then creates the trigger +# config pointing at the function's connector webhook URL. +# +# Trigger: GetOnNewItems_V2 — fires when a new row is added to the table. +# dataset = (the DataSet name, e.g. https://org.crm.dynamics.com) +# table = (entity set / plural logical name, e.g. accounts) +# +# Notes: +# - Do NOT pass $top: the poll fails with 400 when Change Tracking is enabled. +# - The connected Dataverse identity needs Global Read on the table (these row +# triggers are Admin Only); otherwise the poll returns 403 Forbidden. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +_notif_files=() +cleanup_notif_files() { for f in "${_notif_files[@]}"; do rm -f "$f"; done; } +trap cleanup_notif_files EXIT + +echo "Post-deployment configuration..." + +# --- Pre-flight: verify the connector-namespace CLI extension is installed ---- +if ! az extension show --name connector-namespace --query name -o tsv 2>/dev/null; then + echo "ERROR: The 'connector-namespace' Azure CLI extension is required." + echo "Install: curl -fsSL https://aka.ms/connector-namespace-cli-install | sh" + exit 1 +fi + +# --- Read azd outputs -------------------------------------------------------- +eval "$(azd env get-values)" + +: "${resourceGroupName:?required azd output missing}" +: "${connectorNamespaceName:?required azd output missing}" +: "${connectorNamespaceConnectionName:?required azd output missing}" +: "${functionAppName:?required azd output missing}" + +tableName="${dataverseTableName:-accounts}" +environmentUrl="${dataverseEnvironmentUrl:-}" +environmentName="${dataverseEnvironmentName:-}" + +# --- Resolve the org URL from the environment friendly name (Global Discovery) --- +# If DATAVERSE_ENVIRONMENT_URL is not set but a friendly name is, look up the org +# URL via the Dataverse Global Discovery Service using the signed-in az identity. +if [ -z "$environmentUrl" ] && [ -n "$environmentName" ]; then + echo "Resolving org URL for environment '${environmentName}' via Global Discovery..." + token=$(az account get-access-token --resource https://globaldisco.crm.dynamics.com --query accessToken -o tsv 2>/dev/null || true) + if [ -z "$token" ]; then + echo "ERROR: could not obtain a Dataverse token. Ensure your 'az login' identity has access to the" + echo " environment, or set DATAVERSE_ENVIRONMENT_URL explicitly and re-provision." + exit 1 + fi + instances=$(curl -s -H "Authorization: Bearer $token" https://globaldisco.crm.dynamics.com/api/discovery/v2.0/Instances) + environmentUrl=$(echo "$instances" | jq -r --arg n "$environmentName" \ + '.value[] | select((.FriendlyName|ascii_downcase) == ($n|ascii_downcase)) | .Url' | head -n1 | sed 's#/$##') + if [ -z "$environmentUrl" ]; then + echo "ERROR: no environment named '${environmentName}' is visible to your identity. Environments available to you:" + echo "$instances" | jq -r '.value[] | " - \(.FriendlyName) -> \(.Url)"' + exit 1 + fi + echo " resolved: $environmentUrl" +fi + +if [ -z "$environmentUrl" ]; then + echo "ERROR: Dataverse environment is not set. Provide the friendly name (auto-resolved) or the URL:" + echo " azd env set DATAVERSE_ENVIRONMENT_NAME 'Contoso (default)'" + echo " # or" + echo " azd env set DATAVERSE_ENVIRONMENT_URL 'https://.crm.dynamics.com'" + echo " azd provision" + exit 1 +fi + +# GetOnNewItems_V2 uses the org URL as the "dataset" value (no trailing slash). +dataset="${environmentUrl%/}" + +echo "Environment : ${environmentUrl}${environmentName:+ (${environmentName})}" +echo "Dataset : $dataset" +echo "Table : $tableName" + +# Persist the resolved org URL as an app setting so the ListDataverseRows action +# (which reads DATAVERSE_ENVIRONMENT_URL directly) uses the org URL as its dataset, +# matching the trigger. Without this, a friendly-name-only config leaves the setting +# empty and the action would send the name, which the connector rejects (400). +echo "Persisting resolved org URL to $functionAppName app settings..." +az functionapp config appsettings set -g "$resourceGroupName" -n "$functionAppName" \ + --settings "DATAVERSE_ENVIRONMENT_URL=$dataset" -o none +azd env set DATAVERSE_ENVIRONMENT_URL "$dataset" 2>/dev/null || true + +# --- Authorize the Dataverse connection (OAuth consent) ---------------------- +echo "" +echo "Authorizing Microsoft Dataverse connection..." + +currentStatus=$(az connector-namespace connection show \ + -g "$resourceGroupName" --namespace "$connectorNamespaceName" \ + -n "$connectorNamespaceConnectionName" \ + --query "properties.overallStatus" -o tsv 2>/dev/null || true) + +if [ "$(echo "$currentStatus" | tr '[:upper:]' '[:lower:]')" = "connected" ]; then + echo " already Connected; skipping consent flow" +else + echo "-> A browser tab will open. Sign in with an account that has access to the Dataverse environment." + consentParams="[{parameterName:token,redirectUrl:'https://portal.azure.com'}]" + link="" + for attempt in 1 2 3 4 5; do + link=$(az connector-namespace connection list-consent-links \ + -g "$resourceGroupName" --namespace "$connectorNamespaceName" \ + --connection-name "$connectorNamespaceConnectionName" \ + --parameters "$consentParams" \ + --query "value[0].link" -o tsv 2>/dev/null || true) + + if [ -z "$link" ]; then + link=$(az connector-namespace connection list-consent-links \ + -g "$resourceGroupName" --namespace "$connectorNamespaceName" \ + --connection-name "$connectorNamespaceConnectionName" \ + --parameters "$consentParams" \ + --query "link" -o tsv 2>/dev/null || true) + fi + + [ -n "$link" ] && break + echo " list-consent-links attempt ${attempt} failed; retrying in 5s..." + sleep 5 + done + + if [ -z "$link" ]; then + echo "ERROR: could not obtain consent link after retries." + exit 1 + fi + + echo " opening browser for OAuth consent..." + echo " (if no tab opens, paste this URL manually: $link)" + xdg-open "$link" 2>/dev/null || open "$link" 2>/dev/null || echo " (could not open browser — please open the URL above manually)" + + deadline=$((SECONDS + 300)) + authorized=false + while [ $SECONDS -lt $deadline ]; do + sleep 5 + status=$(az connector-namespace connection show \ + -g "$resourceGroupName" --namespace "$connectorNamespaceName" \ + -n "$connectorNamespaceConnectionName" \ + --query "properties.overallStatus" -o tsv 2>/dev/null || true) + echo " status: ${status:-unknown}" + if [ "$(echo "$status" | tr '[:upper:]' '[:lower:]')" = "connected" ]; then + authorized=true + break + fi + done + + if [ "$authorized" != "true" ]; then + echo "ERROR: Connection did not reach 'Connected' status within 5 minutes." + exit 1 + fi + echo " Connection authenticated" +fi + +# --- Create Connector Namespace trigger config ------------------------------- +echo "" +echo "Fetching connector extension key for $functionAppName..." +connectorExtensionKey=$(az functionapp keys list -g "$resourceGroupName" -n "$functionAppName" --query "systemKeys.connector_extension" -o tsv) +if [ -z "$connectorExtensionKey" ]; then + echo "ERROR: could not fetch connector_extension system key." + exit 1 +fi + +functionName="OnDataverseRowChanged" +operationName="GetOnNewItems_V2" +triggerName="${connectorNamespaceConnectionName}-$(echo "$functionName" | tr '[:upper:]' '[:lower:]')" +# Read the app's real default host rather than assuming ".azurewebsites.net" (which differs for +# custom domains and sovereign clouds, e.g. .azurewebsites.us / .chinacloudsites.cn). +functionHost=$(az functionapp show -g "$resourceGroupName" -n "$functionAppName" --query "defaultHostName" -o tsv) +if [ -z "$functionHost" ]; then + echo "ERROR: could not resolve defaultHostName for $functionAppName." + exit 1 +fi +callbackUrl="https://${functionHost}/runtime/webhooks/connector?functionName=${functionName}&code=${connectorExtensionKey}" +notifFile="${SCRIPT_DIR}/.notification-details.${RANDOM}.${RANDOM}.json" +_notif_files+=("$notifFile") +printf '{"callbackUrl":"%s"}' "$callbackUrl" > "$notifFile" + +echo "" +echo "Creating trigger '${triggerName}' for ${functionName} (${operationName})..." + +az connector-namespace trigger delete \ + -g "$resourceGroupName" --namespace "$connectorNamespaceName" \ + -n "$triggerName" --yes 2>/dev/null || true + +az connector-namespace trigger create \ + -g "$resourceGroupName" --namespace "$connectorNamespaceName" \ + -n "$triggerName" \ + --connection-details "{connectionName:${connectorNamespaceConnectionName},connectorName:commondataservice}" \ + --operation-name "$operationName" \ + --parameters "[{name:dataset,value:'${dataset}'},{name:table,value:'${tableName}'}]" \ + --notification-details "@${notifFile}" \ + --description "When a new row is added" \ + --metadata "{destinationType:functionApp,functionAppName:${functionAppName},functionAppResourceGroup:${resourceGroupName},functionAppSubscriptionId:${AZURE_SUBSCRIPTION_ID},functionName:${functionName},recurrenceFrequency:Minute,recurrenceInterval:'5'}" \ + -o none + +rm -f "$notifFile" + +echo "" +echo "Post-deployment configuration complete." +echo "Add a new '${tableName}' row in '${environmentUrl}' to fire the trigger (allow one polling interval)." +echo "Tail logs: az functionapp log tail -g $resourceGroupName -n $functionAppName" +echo "" diff --git a/dataverseApp/local.settings.json b/dataverseApp/local.settings.json new file mode 100644 index 0000000..b30563c --- /dev/null +++ b/dataverseApp/local.settings.json @@ -0,0 +1,11 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "python", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "CommonDataServiceConnection": "", + "COMMONDATASERVICE_CONNECTION_RUNTIME_URL": "", + "DATAVERSE_ENVIRONMENT_URL": "https://.crm.dynamics.com", + "DATAVERSE_TABLE_NAME": "accounts" + } +} diff --git a/dataverseApp/requirements.txt b/dataverseApp/requirements.txt new file mode 100644 index 0000000..4dd59a6 --- /dev/null +++ b/dataverseApp/requirements.txt @@ -0,0 +1,4 @@ +# Azure Functions dependencies +azure-functions>=2.2.0b4 +# Managed-identity auth for calling the Dataverse connector action (List rows). +azure-identity From 94a6862f30552c37a746f415d74cbadb35172be5 Mon Sep 17 00:00:00 2001 From: manvkaur <67894494+manvkaur@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:39:21 -0700 Subject: [PATCH 2/4] Consolidate trigger-scope and SubscribeWebhookTrigger known-issue notes in README Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 87bf03da-21a5-421b-b57a-fe347876f141 --- dataverseApp/README.md | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/dataverseApp/README.md b/dataverseApp/README.md index c2bae12..1023b35 100644 --- a/dataverseApp/README.md +++ b/dataverseApp/README.md @@ -9,16 +9,13 @@ the trigger, plus a managed-identity HTTP function that **calls** a connector ac | `OnDataverseRowChanged` | Trigger | [`GetOnNewItems_V2`](https://learn.microsoft.com/en-us/connectors/commondataservice/#when-a-row-is-added-(admin-only)-[deprecated]) | Fires when a **new row is added** to the configured Dataverse table | | `ListDataverseRows` | Action (HTTP) | [`GetItems_V2` — List rows](https://learn.microsoft.com/en-us/connectors/commondataservice/#list-rows-(legacy)-[deprecated]) | On-demand endpoint that **calls** the connector to list rows from the table | -> **Change scope:** the connector-namespace + Functions callback path is validated end-to-end for -> the **new-row** trigger (`GetOnNewItems_V2`). The classic "added, modified or deleted" webhook -> operation (`SubscribeWebhookTrigger` — *"When a row is added, modified or deleted"*) is not -> compatible with the connector namespace today, so this sample fires on **row creation**. -> -> **Known issue:** subscribing to `SubscribeWebhookTrigger` (*"When a row is added, modified or -> deleted"*) via Connector Namespace currently returns **HTTP 500** (a `Regex.Match` null-reference -> failure) when the trigger config is created. The connector team is actively working on adding -> `SubscribeWebhookTrigger` support through Connector Namespace; until that lands, use the -> `GetOnNewItems_V2` new-row trigger this sample demonstrates. +> **Trigger scope & known issue:** this sample uses the **new-row** trigger `GetOnNewItems_V2` +> (*"When a row is created"*), which is validated end-to-end over the Connector Namespace + Functions +> callback path. The broader `SubscribeWebhookTrigger` (*"When a row is added, modified or deleted"*) +> is **not usable via Connector Namespace yet**: creating its trigger config currently fails with +> **HTTP 500** (a `Regex.Match` null-reference error). The Connector Namespace team is actively working on +> adding `SubscribeWebhookTrigger` support; until it ships, use the `GetOnNewItems_V2` trigger this +> sample demonstrates. > **Why `commondataservice` (and not `commondataserviceforapps`):** this sample deliberately targets > the legacy **[`commondataservice`](https://learn.microsoft.com/en-us/connectors/commondataservice/)** connector. Its intended successor, `commondataserviceforapps`, From 44708bba87cb971e7b96f4599b78d6876f74524d Mon Sep 17 00:00:00 2001 From: manvkaur <67894494+manvkaur@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:20:27 -0700 Subject: [PATCH 3/4] Use azure-connectors SDK for the ListDataverseRows action Replace the hand-crafted urllib call to the GetItems_V2 dataset path with the typed CommondataserviceClient.list_records_async from azure-connectors 0.3.0b2. The action is now async and passes explicit per-environment async credentials (ManagedIdentityCredential in Azure, AzureCliCredential locally) via AzureIdentityTokenProvider, so no DefaultAzureCredential or client id is used. The connection runtime URL identifies the environment, so the action no longer needs the org URL/dataset. Updates requirements.txt and README accordingly. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 87bf03da-21a5-421b-b57a-fe347876f141 --- dataverseApp/README.md | 31 +++++++------ dataverseApp/function_app.py | 82 +++++++++++++++-------------------- dataverseApp/requirements.txt | 2 + 3 files changed, 55 insertions(+), 60 deletions(-) diff --git a/dataverseApp/README.md b/dataverseApp/README.md index 1023b35..02dbd8a 100644 --- a/dataverseApp/README.md +++ b/dataverseApp/README.md @@ -7,7 +7,7 @@ the trigger, plus a managed-identity HTTP function that **calls** a connector ac | Function | Type | Connector operation | Description | | --- | --- | --- | --- | | `OnDataverseRowChanged` | Trigger | [`GetOnNewItems_V2`](https://learn.microsoft.com/en-us/connectors/commondataservice/#when-a-row-is-added-(admin-only)-[deprecated]) | Fires when a **new row is added** to the configured Dataverse table | -| `ListDataverseRows` | Action (HTTP) | [`GetItems_V2` — List rows](https://learn.microsoft.com/en-us/connectors/commondataservice/#list-rows-(legacy)-[deprecated]) | On-demand endpoint that **calls** the connector to list rows from the table | +| `ListDataverseRows` | Action (HTTP) | [List rows](https://learn.microsoft.com/en-us/connectors/commondataservice/#list-rows-(legacy)-[deprecated]) (via `azure-connectors` SDK) | On-demand endpoint that **calls** the connector to list rows from the table | > **Trigger scope & known issue:** this sample uses the **new-row** trigger `GetOnNewItems_V2` > (*"When a row is created"*), which is validated end-to-end over the Connector Namespace + Functions @@ -99,19 +99,23 @@ azd hooks run postdeploy ## Call the connector action (List rows) Besides *receiving* the trigger, `ListDataverseRows` *calls* the Dataverse **List rows** action -against the connection's runtime URL: +against the connection's runtime URL using the typed +[`azure-connectors`](https://pypi.org/project/azure-connectors/) SDK: -``` -GET {runtimeUrl}/v2/datasets/{dataset}/tables/{table}/items?$top=5 +```python +async with CommondataserviceClient(runtime_url, token_provider=token_provider) as client: + payload = await client.list_records_async(entity_name=table, top="5") ``` -`{dataset}` (org URL) and `{table}` (entity set plural name) are each **double URL-encoded** per the -connector's `x-ms-url-encoding: "double"` contract. The call is authenticated with a bearer token for -the API Hub scope `https://apihub.azure.com/.default`, using an **explicit credential per environment** -(not `DefaultAzureCredential`): in Azure the function app's **system-assigned managed identity** -(`ManagedIdentityCredential`, no client id), and locally your `az login` (user) identity -(`AzureCliCredential`). The environment is detected via the `IDENTITY_ENDPOINT` variable that -Azure injects when managed identity is available. +The SDK issues the connector's List rows request (`GET {runtimeUrl}/api/data/v9.1/{entity}?$top=5`) +and returns the parsed OData payload — no manual URL building or encoding. The connection's runtime +URL already identifies the Dataverse environment, so the action needs only the **table** (entity set +plural name); it does **not** take the org URL. The SDK's `token_provider` authenticates with a bearer +token for the API Hub scope `https://apihub.azure.com/.default`, using an **explicit credential per +environment** (not `DefaultAzureCredential`): in Azure the function app's **system-assigned managed +identity** (`ManagedIdentityCredential`, no client id), and locally your `az login` (user) identity +(`AzureCliCredential`). The environment is detected via the `IDENTITY_ENDPOINT` variable that Azure +injects when managed identity is available. > **Why `IDENTITY_ENDPOINT`?** On App Service, Azure Functions, and Container Apps, the platform > injects the `IDENTITY_ENDPOINT` and `IDENTITY_HEADER` environment variables that expose the local @@ -159,8 +163,9 @@ pip install -r requirements.txt func start ``` -Set `COMMONDATASERVICE_CONNECTION_RUNTIME_URL` (the connection's runtime URL), the environment URL -(**or** friendly name) and the table in `local.settings.json` before starting. Locally the action +Set `COMMONDATASERVICE_CONNECTION_RUNTIME_URL` (the connection's runtime URL) and the table in +`local.settings.json` before starting. The action itself needs only the runtime URL and table — the +environment is identified by the connection. Locally the action uses your signed-in `az login` (user) identity (`AzureCliCredential`). To call the action from your machine, grant your own identity an access policy on the connection first: diff --git a/dataverseApp/function_app.py b/dataverseApp/function_app.py index 3eb7fc9..b7ab94f 100644 --- a/dataverseApp/function_app.py +++ b/dataverseApp/function_app.py @@ -5,17 +5,16 @@ import json import logging import os -import urllib.error -import urllib.parse -import urllib.request -from azure.identity import AzureCliCredential, ManagedIdentityCredential +from azure.connectors import ( + AzureIdentityTokenProvider, + CommondataserviceClient, + ConnectorException, +) +from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential app = func.FunctionApp() -# OAuth scope every managed-connector runtime URL expects (the API Hub audience). -APIHUB_SCOPE = "https://apihub.azure.com/.default" - # ------------------------------------------------------------------------------ # OnDataverseRowChanged — Microsoft Dataverse connector (generic connector API) @@ -77,23 +76,24 @@ def on_dataverse_row_changed(payload: str) -> None: # ------------------------------------------------------------------------------ # ListDataverseRows — Microsoft Dataverse connector ACTION (List rows) # -# Demonstrates *calling* a connector action (not just receiving a trigger). The -# function authenticates to the connection's runtime URL with the function app's -# managed identity and invokes the connector's "List rows" operation: +# Demonstrates *calling* a connector action (not just receiving a trigger) with +# the typed `azure-connectors` SDK. A CommondataserviceClient targets the +# connection's runtime URL and invokes the Dataverse "List rows" operation: # -# GET {runtimeUrl}/v2/datasets/{dataset}/tables/{table}/items +# rows = await CommondataserviceClient(runtime_url, token_provider) \ +# .list_records_async(entity_name=table, top="5") # -# where {dataset} (the org URL) and {table} (entity set plural name) are each -# double URL-encoded per the connector's `x-ms-url-encoding: "double"` contract. -# The call is authorized by the `functionapp-msi` access policy granted on the -# connection. Auth is explicit per environment: in Azure the function app's +# The SDK acquires an API Hub token (https://apihub.azure.com/.default) through +# the token provider and calls the connector; the call is authorized by the +# `functionapp-msi` access policy granted on the connection. Auth is explicit per +# environment (DefaultAzureCredential is not used): in Azure the function app's # managed identity, and locally the signed-in `az login` (user) identity. # # HTTP: GET /api/rows?table=accounts&top=5 # ------------------------------------------------------------------------------ @app.function_name(name="ListDataverseRows") @app.route(route="rows", methods=["GET"]) -def list_dataverse_rows(req: func.HttpRequest) -> func.HttpResponse: +async def list_dataverse_rows(req: func.HttpRequest) -> func.HttpResponse: """List rows from the configured Dataverse table via the connector action.""" logging.info("ListDataverseRows action invoked.") @@ -104,29 +104,13 @@ def list_dataverse_rows(req: func.HttpRequest) -> func.HttpResponse: status_code=500, ) - dataset = os.environ.get("DATAVERSE_ENVIRONMENT_URL") or os.environ.get( - "DATAVERSE_ENVIRONMENT_NAME" - ) - if not dataset: - return func.HttpResponse( - "Set either DATAVERSE_ENVIRONMENT_URL or DATAVERSE_ENVIRONMENT_NAME.", - status_code=500, - ) - - # Table and row count are overridable per request; fall back to app settings. + # Table (entity set / plural name) and row count are overridable per request; + # fall back to app settings. table = req.params.get("table") or os.environ.get( "DATAVERSE_TABLE_NAME", "accounts" ) top = req.params.get("top", "5") - # Both path segments are double URL-encoded (x-ms-url-encoding: "double"). - enc_dataset = urllib.parse.quote(urllib.parse.quote(dataset, safe=""), safe="") - enc_table = urllib.parse.quote(urllib.parse.quote(table, safe=""), safe="") - url = ( - f"{runtime_url.rstrip('/')}/v2/datasets/{enc_dataset}" - f"/tables/{enc_table}/items?$top={urllib.parse.quote(str(top))}" - ) - # Explicit credential per environment (DefaultAzureCredential is not used): # - In Azure (IDENTITY_ENDPOINT is injected) -> the function app's managed identity. # - Locally (not set) -> the signed-in `az login` (user) identity. @@ -135,20 +119,24 @@ def list_dataverse_rows(req: func.HttpRequest) -> func.HttpResponse: else: credential = AzureCliCredential() - token = credential.get_token(APIHUB_SCOPE).token - request = urllib.request.Request( - url, - method="GET", - headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, - ) - try: - with urllib.request.urlopen(request, timeout=30) as response: - payload = json.loads(response.read().decode("utf-8")) - except urllib.error.HTTPError as error: - body = error.read().decode("utf-8", errors="replace") - logging.error(f"Connector action failed ({error.code}): {body}") - return func.HttpResponse(body, status_code=error.code, mimetype="application/json") + async with credential: + token_provider = AzureIdentityTokenProvider(credential) + async with CommondataserviceClient( + runtime_url, token_provider=token_provider + ) as client: + payload = await client.list_records_async( + entity_name=table, top=str(top) + ) + except ConnectorException as error: + logging.error( + f"Connector action failed ({error.status_code}): {error.response_body}" + ) + return func.HttpResponse( + error.response_body, + status_code=error.status_code, + mimetype="application/json", + ) rows = payload.get("value", []) if isinstance(payload, dict) else [] logging.info(f"Retrieved '{len(rows)}' row(s) from table '{table}'.") diff --git a/dataverseApp/requirements.txt b/dataverseApp/requirements.txt index 4dd59a6..57f37a4 100644 --- a/dataverseApp/requirements.txt +++ b/dataverseApp/requirements.txt @@ -2,3 +2,5 @@ azure-functions>=2.2.0b4 # Managed-identity auth for calling the Dataverse connector action (List rows). azure-identity +# Typed Dataverse (commondataservice) connector client used by the List rows action. +azure-connectors==0.3.0b2 From 49a9a70a53071032cd9da4d8a54b9a67a15f1581 Mon Sep 17 00:00:00 2001 From: manvkaur <67894494+manvkaur@users.noreply.github.com> Date: Fri, 17 Jul 2026 11:40:59 -0700 Subject: [PATCH 4/4] Address PR review nits and tighten Dataverse sample - Hoist startup constants (environment, table, runtime URL, IN_AZURE) to module globals; keep per-request table/top overrides on the action. - README: Python 3.13+, drop azure-functions beta pin (stable ships the connector decorator), condense the action/IDENTITY_ENDPOINT and top notes, and correct the portal-visibility caveat (runs are viewable once created via ARM/CLI; create/update still CLI-only). - Simplify inline comments and wrap all lines to satisfy flake8 (E501). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 87bf03da-21a5-421b-b57a-fe347876f141 --- dataverseApp/README.md | 78 +++++++++++------------------ dataverseApp/function_app.py | 94 ++++++++++++++++------------------- dataverseApp/requirements.txt | 6 +-- 3 files changed, 75 insertions(+), 103 deletions(-) diff --git a/dataverseApp/README.md b/dataverseApp/README.md index 02dbd8a..a9c92fb 100644 --- a/dataverseApp/README.md +++ b/dataverseApp/README.md @@ -7,23 +7,18 @@ the trigger, plus a managed-identity HTTP function that **calls** a connector ac | Function | Type | Connector operation | Description | | --- | --- | --- | --- | | `OnDataverseRowChanged` | Trigger | [`GetOnNewItems_V2`](https://learn.microsoft.com/en-us/connectors/commondataservice/#when-a-row-is-added-(admin-only)-[deprecated]) | Fires when a **new row is added** to the configured Dataverse table | -| `ListDataverseRows` | Action (HTTP) | [List rows](https://learn.microsoft.com/en-us/connectors/commondataservice/#list-rows-(legacy)-[deprecated]) (via `azure-connectors` SDK) | On-demand endpoint that **calls** the connector to list rows from the table | +| `ListDataverseRows` | Action (HTTP) | [GetItems_V2](https://learn.microsoft.com/en-us/connectors/commondataservice/#list-rows-(legacy)-[deprecated]) (via `azure-connectors` SDK) | On-demand endpoint that **calls** the connector to list rows from the table | > **Trigger scope & known issue:** this sample uses the **new-row** trigger `GetOnNewItems_V2` -> (*"When a row is created"*), which is validated end-to-end over the Connector Namespace + Functions -> callback path. The broader `SubscribeWebhookTrigger` (*"When a row is added, modified or deleted"*) -> is **not usable via Connector Namespace yet**: creating its trigger config currently fails with -> **HTTP 500** (a `Regex.Match` null-reference error). The Connector Namespace team is actively working on -> adding `SubscribeWebhookTrigger` support; until it ships, use the `GetOnNewItems_V2` trigger this -> sample demonstrates. - -> **Why `commondataservice` (and not `commondataserviceforapps`):** this sample deliberately targets -> the legacy **[`commondataservice`](https://learn.microsoft.com/en-us/connectors/commondataservice/)** connector. Its intended successor, `commondataserviceforapps`, -> has only been shipped to Power Automate and is **not yet available for Logic Apps / Connector -> Namespaces**. By prior consensus with the connector owners, the legacy `commondataservice` connector -> remains supported in production for Connector Namespace until (and unless) the replacement is finally released -> to that environment. Do not switch this sample to `commondataserviceforapps` until it is generally -> available in the Logic Apps / Connector Namespace environment. +> (*"When a row is created"*), validated end-to-end over Connector Namespace. The broader +> `SubscribeWebhookTrigger` (*"added, modified or deleted"*) is **not usable via Connector Namespace +> yet** — creating its trigger config currently fails with **HTTP 500** (`Regex.Match` null-reference). +> The team is adding support; until then, use `GetOnNewItems_V2`. + +> **Why `commondataservice` (not `commondataserviceforapps`):** the successor connector ships only to +> Power Automate and **isn't available for Logic Apps / Connector Namespaces yet**. The legacy +> [`commondataservice`](https://learn.microsoft.com/en-us/connectors/commondataservice/) stays +> supported there until it is. Don't switch until the replacement is available in that environment. ## What you configure @@ -42,7 +37,7 @@ Provide **either** `DATAVERSE_ENVIRONMENT_NAME` (recommended — the URL is disc - [Azure Developer CLI (`azd`)](https://learn.microsoft.com/azure/developer/azure-developer-cli/install-azd) - [Azure CLI (`az`)](https://learn.microsoft.com/cli/azure/install-azure-cli) ≥ 2.75.0 -- [Python 3.13](https://www.python.org/downloads/) +- [Python 3.13+](https://www.python.org/downloads/) - A Microsoft Dataverse environment and an account with access to the target table - [`connector-namespace` Azure CLI extension](https://github.com/Azure/Connectors/tree/main/public-preview/connector-namespace-cli) — install with: @@ -107,38 +102,20 @@ async with CommondataserviceClient(runtime_url, token_provider=token_provider) a payload = await client.list_records_async(entity_name=table, top="5") ``` -The SDK issues the connector's List rows request (`GET {runtimeUrl}/api/data/v9.1/{entity}?$top=5`) -and returns the parsed OData payload — no manual URL building or encoding. The connection's runtime -URL already identifies the Dataverse environment, so the action needs only the **table** (entity set -plural name); it does **not** take the org URL. The SDK's `token_provider` authenticates with a bearer -token for the API Hub scope `https://apihub.azure.com/.default`, using an **explicit credential per -environment** (not `DefaultAzureCredential`): in Azure the function app's **system-assigned managed -identity** (`ManagedIdentityCredential`, no client id), and locally your `az login` (user) identity -(`AzureCliCredential`). The environment is detected via the `IDENTITY_ENDPOINT` variable that Azure -injects when managed identity is available. - -> **Why `IDENTITY_ENDPOINT`?** On App Service, Azure Functions, and Container Apps, the platform -> injects the `IDENTITY_ENDPOINT` and `IDENTITY_HEADER` environment variables that expose the local -> managed-identity token endpoint (the `api-version=2019-08-01` MSI REST protocol). These are -> documented by Microsoft and are exactly what the Azure Identity SDK reads under the hood to select -> the App Service / Functions MI source — see -> [Managed identities for App Service and Azure Functions → *Connect to Azure services in app code* (HTTP GET tab)](https://learn.microsoft.com/azure/app-service/overview-managed-identity?tabs=portal%2Chttp#connect-to-azure-services-in-app-code). -> Because the variable is present **iff** the platform MI endpoint is available (and absent locally), -> its presence is a reliable "running in Azure with MI" signal for this host — and it survives Flex -> Consumption, where `WEBSITE_INSTANCE_ID` is *not* set (Flex is not classed as App Service by the -> Functions host). -> -> **Caveat / scope:** this signal is specific to App Service, Functions, and Container Apps. Other -> managed-identity hosts differ — VM/VMSS use the [IMDS endpoint](https://learn.microsoft.com/entra/identity/managed-identities-azure-resources/how-to-use-vm-token) -> (`169.254.169.254`) and don't set `IDENTITY_ENDPOINT`, and AKS workload identity uses -> `AZURE_FEDERATED_TOKEN_FILE`. So treat `IDENTITY_ENDPOINT` as the auth-source signal for *this* -> host, not as a general-purpose "am I in Azure?" detector. -> -> **Production guidance:** the local-vs-cloud branch exists purely for developer convenience in this -> sample. A production app should pick **one** credential and use it unconditionally — in Azure that -> means `ManagedIdentityCredential` only (no local/CLI fallback path shipped to the cloud), so there is -> no environment-detection branch to reason about, no accidental fall-through to a developer identity, -> and one deterministic auth path to secure and audit. +The SDK builds the List rows request and returns the parsed OData payload — no manual URL building or +encoding. The runtime URL already identifies the environment, so the action needs only the **table** +(entity set plural name). Its `token_provider` uses an **explicit credential per environment** (not +`DefaultAzureCredential`): in Azure the function app's **system-assigned managed identity** (no client +id), and locally your `az login` identity. The environment is detected via the `IDENTITY_ENDPOINT` +variable that Azure injects when managed identity is available. + +> **Notes on `IDENTITY_ENDPOINT`:** App Service, Functions, and Container Apps inject this variable to +> expose the local MI token endpoint, so its presence is a reliable "running in Azure with MI" signal +> (it survives Flex Consumption, unlike `WEBSITE_INSTANCE_ID`) — see +> [managed identity docs](https://learn.microsoft.com/azure/app-service/overview-managed-identity?tabs=portal%2Chttp#connect-to-azure-services-in-app-code). +> It's host-specific (VM/VMSS use IMDS, AKS uses `AZURE_FEDERATED_TOKEN_FILE`), so don't treat it as a +> general "am I in Azure?" check. The local-vs-cloud branch is a dev convenience — **production apps +> should pick one credential** and use it unconditionally. For the token exchange to succeed, that identity must have an **access policy** on the connection. The infrastructure grants: @@ -179,8 +156,9 @@ The connector trigger needs the **Preview** Functions Extension Bundle, already ## Verify -The Dataverse connector namespace isn't surfaced in the portal yet, so verify from the CLI. Capture -the names created by `azd up`: +The Dataverse connector namespace can be **viewed** in the portal once created via ARM/CLI, but +**creating or updating** connections and trigger configs isn't supported there yet — so drive those +from the CLI. Capture the names created by `azd up`: ```bash RG=$(azd env get-value resourceGroupName) diff --git a/dataverseApp/function_app.py b/dataverseApp/function_app.py index b7ab94f..f5b689b 100644 --- a/dataverseApp/function_app.py +++ b/dataverseApp/function_app.py @@ -15,22 +15,25 @@ app = func.FunctionApp() +# Dataverse target + connection read from app settings once at startup. +DATAVERSE_ENVIRONMENT = os.environ.get( + "DATAVERSE_ENVIRONMENT_URL" +) or os.environ.get("DATAVERSE_ENVIRONMENT_NAME", "") +DATAVERSE_TABLE = os.environ.get("DATAVERSE_TABLE_NAME", "accounts") +RUNTIME_URL = os.environ.get("COMMONDATASERVICE_CONNECTION_RUNTIME_URL") +# Azure injects IDENTITY_ENDPOINT when managed identity is available -> use +# it to pick the credential (MI in Azure, `az login` locally). +IN_AZURE = bool(os.environ.get("IDENTITY_ENDPOINT")) + # ------------------------------------------------------------------------------ -# OnDataverseRowChanged — Microsoft Dataverse connector (generic connector API) -# -# Fires when a new row is added to the configured Dataverse table. The trigger -# config (created in the post-deploy script) targets: -# connector : commondataservice -# operation : GetOnNewItems_V2 -# parameters: -# dataset = -> DATAVERSE_ENVIRONMENT_URL (the DataSet name, -# e.g. https://org.crm.dynamics.com) -# table =
-> DATAVERSE_TABLE_NAME (entity set / plural -# logical name, e.g. "accounts") +# OnDataverseRowChanged — Dataverse connector trigger (GetOnNewItems_V2). # -# The connector polls Dataverse server-side (default every few minutes) and posts -# each new row to this function's connector webhook callback. +# Fires when a new row is added to the configured table. The post-deploy script +# creates the trigger config (connector=commondataservice, operation= +# GetOnNewItems_V2) with dataset= and table=. +# The connector polls Dataverse server-side and posts each new row to this +# function's connector webhook callback. # ------------------------------------------------------------------------------ @app.function_name(name="OnDataverseRowChanged") @app.connector_trigger(arg_name="payload") @@ -38,11 +41,9 @@ def on_dataverse_row_changed(payload: str) -> None: """Triggered when a new Dataverse row is added.""" logging.info("OnDataverseRowChanged trigger received.") - environment = os.environ.get("DATAVERSE_ENVIRONMENT_URL") or os.environ.get( - "DATAVERSE_ENVIRONMENT_NAME", "" + logging.info( + f"Environment: '{DATAVERSE_ENVIRONMENT}', Table: '{DATAVERSE_TABLE}'." ) - table = os.environ.get("DATAVERSE_TABLE_NAME", "") - logging.info(f"Environment: '{environment}', Table: '{table}'.") data = json.loads(payload) @@ -60,77 +61,70 @@ def on_dataverse_row_changed(payload: str) -> None: # The payload is the newly added Dataverse row. The connector tags each # item with an "ItemInternalId"; the row's primary key is "id" # (e.g. accountid), derived from the singular table name. - singular = table[:-1] if table.endswith("s") else table + singular = ( + DATAVERSE_TABLE[:-1] + if DATAVERSE_TABLE.endswith("s") + else DATAVERSE_TABLE + ) record_id = ( row.get("ItemInternalId") or row.get(f"{singular}id") or "" ) - logging.info(f"New '{table}' row id: '{record_id}'.") + logging.info(f"New '{DATAVERSE_TABLE}' row id: '{record_id}'.") logging.info(f"Columns in payload: {list(row.keys())}.") logging.info(f"Batch contains '{len(rows)}' new row(s).") # ------------------------------------------------------------------------------ -# ListDataverseRows — Microsoft Dataverse connector ACTION (List rows) -# -# Demonstrates *calling* a connector action (not just receiving a trigger) with -# the typed `azure-connectors` SDK. A CommondataserviceClient targets the -# connection's runtime URL and invokes the Dataverse "List rows" operation: +# ListDataverseRows — Dataverse connector action (List rows). # -# rows = await CommondataserviceClient(runtime_url, token_provider) \ -# .list_records_async(entity_name=table, top="5") +# Demonstrates *calling* a connector action (not just receiving a trigger) +# with the typed `azure-connectors` SDK: +# CommondataserviceClient.list_records_async targets the connection's runtime +# URL and returns the parsed OData rows. The SDK gets an API Hub token via the +# token provider; the call is authorized by the `functionapp-msi` access +# policy on the connection. Auth is explicit per environment (not +# DefaultAzureCredential): managed identity in Azure, the signed-in +# `az login` (user) identity locally. # -# The SDK acquires an API Hub token (https://apihub.azure.com/.default) through -# the token provider and calls the connector; the call is authorized by the -# `functionapp-msi` access policy granted on the connection. Auth is explicit per -# environment (DefaultAzureCredential is not used): in Azure the function app's -# managed identity, and locally the signed-in `az login` (user) identity. -# -# HTTP: GET /api/rows?table=accounts&top=5 +# GET /api/rows?table=accounts&top=5 # ------------------------------------------------------------------------------ @app.function_name(name="ListDataverseRows") @app.route(route="rows", methods=["GET"]) async def list_dataverse_rows(req: func.HttpRequest) -> func.HttpResponse: - """List rows from the configured Dataverse table via the connector action.""" + """List rows from the configured Dataverse table via the connector.""" logging.info("ListDataverseRows action invoked.") - runtime_url = os.environ.get("COMMONDATASERVICE_CONNECTION_RUNTIME_URL") - if not runtime_url: + if not RUNTIME_URL: return func.HttpResponse( "COMMONDATASERVICE_CONNECTION_RUNTIME_URL is not configured.", status_code=500, ) - # Table (entity set / plural name) and row count are overridable per request; - # fall back to app settings. - table = req.params.get("table") or os.environ.get( - "DATAVERSE_TABLE_NAME", "accounts" - ) + # Table and row count are overridable per request; else the default. + table = req.params.get("table") or DATAVERSE_TABLE top = req.params.get("top", "5") - # Explicit credential per environment (DefaultAzureCredential is not used): - # - In Azure (IDENTITY_ENDPOINT is injected) -> the function app's managed identity. - # - Locally (not set) -> the signed-in `az login` (user) identity. - if os.environ.get("IDENTITY_ENDPOINT"): - credential = ManagedIdentityCredential() - else: - credential = AzureCliCredential() + credential = ( + ManagedIdentityCredential() if IN_AZURE else AzureCliCredential() + ) try: async with credential: token_provider = AzureIdentityTokenProvider(credential) async with CommondataserviceClient( - runtime_url, token_provider=token_provider + RUNTIME_URL, token_provider=token_provider ) as client: payload = await client.list_records_async( entity_name=table, top=str(top) ) except ConnectorException as error: logging.error( - f"Connector action failed ({error.status_code}): {error.response_body}" + f"Connector action failed ({error.status_code}): " + f"{error.response_body}" ) return func.HttpResponse( error.response_body, diff --git a/dataverseApp/requirements.txt b/dataverseApp/requirements.txt index 57f37a4..2a5e7b2 100644 --- a/dataverseApp/requirements.txt +++ b/dataverseApp/requirements.txt @@ -1,6 +1,6 @@ # Azure Functions dependencies -azure-functions>=2.2.0b4 -# Managed-identity auth for calling the Dataverse connector action (List rows). +azure-functions>=2.2.0 +# Managed-identity auth. azure-identity # Typed Dataverse (commondataservice) connector client used by the List rows action. -azure-connectors==0.3.0b2 +azure-connectors>=0.3.0b2