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..a9c92fb --- /dev/null +++ b/dataverseApp/README.md @@ -0,0 +1,198 @@ +# 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](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"*), 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 + +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 using the typed +[`azure-connectors`](https://pypi.org/project/azure-connectors/) SDK: + +```python +async with CommondataserviceClient(runtime_url, token_provider=token_provider) as client: + payload = await client.list_records_async(entity_name=table, top="5") +``` + +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: + +| 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) 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: + +```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 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) +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..f5b689b --- /dev/null +++ b/dataverseApp/function_app.py @@ -0,0 +1,142 @@ +# Copyright (c) .NET Foundation. All rights reserved. +# Licensed under the MIT License. + +import azure.functions as func +import json +import logging +import os + +from azure.connectors import ( + AzureIdentityTokenProvider, + CommondataserviceClient, + ConnectorException, +) +from azure.identity.aio import AzureCliCredential, ManagedIdentityCredential + +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 — Dataverse connector trigger (GetOnNewItems_V2). +# +# 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") +def on_dataverse_row_changed(payload: str) -> None: + """Triggered when a new Dataverse row is added.""" + logging.info("OnDataverseRowChanged trigger received.") + + logging.info( + f"Environment: '{DATAVERSE_ENVIRONMENT}', Table: '{DATAVERSE_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 = ( + 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 '{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 — Dataverse connector action (List rows). +# +# 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. +# +# 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.""" + logging.info("ListDataverseRows action invoked.") + + if not RUNTIME_URL: + return func.HttpResponse( + "COMMONDATASERVICE_CONNECTION_RUNTIME_URL is not configured.", + status_code=500, + ) + + # 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") + + credential = ( + ManagedIdentityCredential() if IN_AZURE else AzureCliCredential() + ) + + try: + 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}): " + f"{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}'.") + + 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..2a5e7b2 --- /dev/null +++ b/dataverseApp/requirements.txt @@ -0,0 +1,6 @@ +# Azure Functions dependencies +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