From a17059e369458f1e59aa1cd92c8a6fdd91efa7e6 Mon Sep 17 00:00:00 2001 From: Doug Date: Tue, 19 May 2026 00:46:01 -0400 Subject: [PATCH] Identity-based auth, SAS URLs, and Flex Consumption deploy docs Adapts the app for tenants that enforce StorageAccount_DisableLocalAuth and StorageAccount_BlobAnonymousAccess Azure Policies (very common in modern enterprise tenants), where the current shared-key + public-blob design fails to deploy. Code: - function_app.py: dual-mode storage auth. If CHARTS_BLOB_ACCOUNT_URL is set (or CHARTS_BLOB_CONNECTION_STRING has no AccountKey), use DefaultAzureCredential and return a User Delegation SAS URL with read-only access (TTL configurable via CHARTS_SAS_TTL_DAYS, default 30 days). Otherwise fall back to the original connection-string + public-blob behavior. Caches the user delegation key for 24 hours to avoid one round-trip per upload. - requirements.txt: add azure-identity. Docs and config: - README.md: rewrite Configuration and Deployment sections. Document both auth modes, make Flex Consumption the recommended plan, show the identity-based provisioning (--assign-identity, --deployment- storage-auth-type SystemAssignedIdentity, AzureWebJobsStorage__ accountName, three storage RBAC roles), and warn against pre- installing .python_packages locally (cross-architecture pitfall). - local.settings.json.example: show both auth modes side-by-side and add CHARTS_SAS_TTL_DAYS. - host.json, openapi.json, copilot-studio-instructions.md: refresh 'public URL' wording to reflect the SAS link default. - openapi.json: add the missing chart_type enum values (area, box, violin, heatmap) that function_app.py already supports. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 96 ++++++++++++++++++++-------- copilot-studio-instructions.md | 4 +- function_app.py | 111 ++++++++++++++++++++++++++++++--- host.json | 2 +- local.settings.json.example | 11 +++- openapi.json | 8 +-- requirements.txt | 1 + 7 files changed, 190 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 38c97c4..a2aa7c2 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # matplotlib-azurefunction -Azure Functions (Python) app that renders matplotlib charts and returns a public URL to the PNG. Exposes the same renderer through **two** surfaces: +Azure Functions (Python) app that renders matplotlib charts and returns a URL to the PNG (time-limited SAS link by default; unsigned public URL in legacy shared-key mode). Exposes the same renderer through **two** surfaces: - **HTTP**: `POST /api/chart` — usable from Copilot Studio, Power Automate, curl, anything. - **MCP**: a single tool `generate_chart` on an MCP server named `MatplotlibChartGenerator`, hosted at `/runtime/webhooks/mcp`. @@ -17,13 +17,13 @@ The MCP tool is designed to be reasoning-driven: it requires the caller to artic - **Six annotation types**: `point`, `hline`, `vline`, `hspan`, `vspan`, `text` - **Styling**: matplotlib themes (`ggplot`, `seaborn-v0_8`, `dark_background`, `fivethirtyeight`, `bmh`, `grayscale`, default), custom fonts, figsize, dpi, grid -Rendered PNGs are uploaded to Azure Blob Storage and the public URL is returned. +Rendered PNGs are uploaded to Azure Blob Storage and a URL to the image is returned. ## Architecture ``` client ──► /api/chart ─┐ - ├─► render with matplotlib ─► upload PNG to Blob ─► return public URL + ├─► render with matplotlib ─► upload PNG to Blob ─► return URL (SAS or public) MCP client ──► generate_chart ─┘ ``` @@ -47,12 +47,18 @@ The app reads these environment variables (locally via `local.settings.json`, in | Variable | Required | Default | Purpose | |---|---|---|---| -| `AzureWebJobsStorage` | yes | — | Functions runtime storage (can be `UseDevelopmentStorage=true` locally) | +| `AzureWebJobsStorage` *or* `AzureWebJobsStorage__accountName` | yes | — | Functions runtime storage. Use the `__accountName` form (plus `AzureWebJobsStorage__credential=managedidentity`) when shared keys are disabled. Locally, `UseDevelopmentStorage=true` is fine. | | `FUNCTIONS_WORKER_RUNTIME` | yes | `python` | Functions worker language | -| `CHARTS_BLOB_CONNECTION_STRING` | yes | — | Connection string for the storage account where rendered chart PNGs are uploaded | -| `CHARTS_BLOB_CONTAINER` | no | `matplotlib-charts` | Blob container name (must be publicly readable for the returned URL to work) | +| `CHARTS_BLOB_ACCOUNT_URL` *or* `CHARTS_BLOB_CONNECTION_STRING` | yes | — | Where rendered chart PNGs are uploaded. Pick **one** — see auth modes below. | +| `CHARTS_BLOB_CONTAINER` | no | `matplotlib-charts` | Blob container name | +| `CHARTS_SAS_TTL_DAYS` | no | `30` | TTL of the returned SAS URL (identity mode only) | -> ⚠️ **The blob container needs public-read access** for the returned URL to render in browsers/Copilot. Either set the container's anonymous access level to "Blob", or replace `_upload_to_blob` with a SAS-based variant if you want to keep it private. +### Storage auth modes + +The renderer auto-detects which mode to use: + +- **Identity mode (recommended).** Set `CHARTS_BLOB_ACCOUNT_URL=https://.blob.core.windows.net`. Auth uses `DefaultAzureCredential` (Managed Identity in Azure; `az login` / Visual Studio creds locally). The chart container stays private and the returned URL is a User Delegation SAS link with read-only access, expiring in `CHARTS_SAS_TTL_DAYS`. This is **required** on tenants enforcing `StorageAccount_DisableLocalAuth_Modify` or `StorageAccount_BlobAnonymousAccess_Modify` policies — which is most modern enterprise tenants. +- **Legacy shared-key mode.** Set `CHARTS_BLOB_CONNECTION_STRING="DefaultEndpointsProtocol=https;AccountName=…;AccountKey=…;EndpointSuffix=core.windows.net"`. The container is created with anonymous blob-read access and the returned URL is the unsigned blob URL. Only works when the storage account allows shared keys **and** public blob access. ## Local development @@ -91,38 +97,74 @@ curl -X POST http://localhost:7071/api/chart \ ## Deployment to Azure -Provision once: +> 💡 **Pick Flex Consumption** (not Linux Consumption). Flex is the modern Functions plan, supports identity-based runtime + deployment storage out of the box, and is the reliable path for the MCP extension preview. Linux Consumption may also work but is not exercised here. + +### Option A — Identity-based (recommended; required on tenants that disable shared keys) ```bash -# Resource group, storage account, function app (Linux, Python 3.11, Flex Consumption or Consumption) -az group create --name --location -az storage account create --name --resource-group --sku Standard_LRS +RG=; LOC=; STG=; APP= +SUB=$(az account show --query id -o tsv) + +az group create --name $RG --location $LOC +az storage account create --name $STG --resource-group $RG --sku Standard_LRS + +# Function app on Flex Consumption with system-assigned identity AND identity-based deployment storage. az functionapp create \ - --resource-group \ - --name \ - --storage-account \ + --resource-group $RG --name $APP --storage-account $STG \ --runtime python --runtime-version 3.11 \ - --functions-version 4 \ - --os-type Linux --consumption-plan-location + --flexconsumption-location $LOC \ + --assign-identity '[system]' \ + --deployment-storage-auth-type SystemAssignedIdentity + +# Grant the function app's MI the roles AzureWebJobsStorage + the chart container need. +PID=$(az functionapp identity show --resource-group $RG --name $APP --query principalId -o tsv) +SCOPE=$(az storage account show --name $STG --resource-group $RG --query id -o tsv) +for role in "Storage Blob Data Owner" "Storage Queue Data Contributor" "Storage Table Data Contributor"; do + az role assignment create --assignee-object-id $PID --assignee-principal-type ServicePrincipal \ + --role "$role" --scope $SCOPE +done + +# Replace the runtime connection string with identity-based settings, and add the app's own settings. +az functionapp config appsettings delete --resource-group $RG --name $APP \ + --setting-names AzureWebJobsStorage DEPLOYMENT_STORAGE_CONNECTION_STRING +az functionapp config appsettings set --resource-group $RG --name $APP --settings \ + AzureWebJobsStorage__accountName=$STG \ + AzureWebJobsStorage__credential=managedidentity \ + CHARTS_BLOB_ACCOUNT_URL=https://$STG.blob.core.windows.net \ + CHARTS_BLOB_CONTAINER=matplotlib-charts \ + CHARTS_SAS_TTL_DAYS=30 +``` -# Configure app settings — the storage connection string the renderer uploads to -az functionapp config appsettings set \ - --resource-group --name \ - --settings \ - CHARTS_BLOB_CONNECTION_STRING="" \ - CHARTS_BLOB_CONTAINER="matplotlib-charts" +### Option B — Legacy shared-key + +```bash +az functionapp create \ + --resource-group $RG --name $APP --storage-account $STG \ + --runtime python --runtime-version 3.11 \ + --flexconsumption-location $LOC + +az functionapp config appsettings set --resource-group $RG --name $APP --settings \ + CHARTS_BLOB_CONNECTION_STRING="" \ + CHARTS_BLOB_CONTAINER="matplotlib-charts" ``` -Deploy via zip (recommended for this repo — `func azure functionapp publish --build remote` has been observed to silently fail here): +### Deploy the code (both options) + +Flex Consumption builds Python dependencies remotely (Oryx), so the deployment package should contain just the source — **do not** pre-install `.python_packages` locally (locally-built wheels can be the wrong CPU architecture, e.g. ARM64 dev → x86_64 Linux). ```bash -# From the project root, with the venv activated -python -m pip install --target .python_packages\lib\site-packages -r requirements.txt -Compress-Archive -Path .\* -DestinationPath deploy.zip -Force +# From the project root — zip just the source, then deploy with remote build. +zip -r deploy.zip . -x ".git/*" ".venv/*" "__pycache__/*" "local.settings.json" +# PowerShell equivalent: +# Compress-Archive -Path .\* -DestinationPath deploy.zip -Force + az functionapp deployment source config-zip \ - --resource-group --name --src deploy.zip + --resource-group $RG --name $APP \ + --src deploy.zip --build-remote true ``` +> Note: `func azure functionapp publish --build remote` has been observed to silently fail against Linux Consumption for this repo. On Flex Consumption, the `az functionapp deployment source config-zip --build-remote true` command above works reliably. + ## HTTP endpoint `POST /api/chart?code=` — request body matches the MCP tool's `chart_type` / `data` / `params` shape. See [`openapi.json`](./openapi.json) for the full schema. diff --git a/copilot-studio-instructions.md b/copilot-studio-instructions.md index 52be751..9e4ca3c 100644 --- a/copilot-studio-instructions.md +++ b/copilot-studio-instructions.md @@ -11,7 +11,7 @@ POST https://.azurewebsites.net/api/chart?code=YOUR_API_KEY ## Tool Description (paste into the action description in Copilot Studio) ``` -Generates chart images using matplotlib. Returns a public URL to the PNG image. Call this tool when the user asks to create, plot, or visualize a chart or graph from data. Supported types: bar, line, pie, scatter, histogram, area, box, violin, heatmap. Supports single-series and multi-series (except pie and heatmap). After calling, display the URL as an image: ![Chart](url) +Generates chart images using matplotlib. Returns a URL to the PNG image (time-limited SAS link by default, ~30-day TTL; unsigned public URL in legacy shared-key mode). Call this tool when the user asks to create, plot, or visualize a chart or graph from data. Supported types: bar, line, pie, scatter, histogram, area, box, violin, heatmap. Supports single-series and multi-series (except pie and heatmap). After calling, display the URL as an image: ![Chart](url) ``` --- @@ -62,7 +62,7 @@ Example: {"title":"Sales","xlabel":"Quarter","ylabel":"Revenue","style":"ggplot" ``` -Public URL to the generated chart PNG image. Display as image: ![Chart](url) +Public URL to the generated chart PNG image (time-limited SAS link by default, ~30-day TTL). Display as image: ![Chart](url) ``` --- diff --git a/function_app.py b/function_app.py index a40fb58..442847d 100644 --- a/function_app.py +++ b/function_app.py @@ -4,17 +4,92 @@ import json import io import uuid -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone +from urllib.parse import urlparse import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import numpy as np -from azure.storage.blob import BlobServiceClient, ContentSettings +from azure.storage.blob import ( + BlobServiceClient, + ContentSettings, + BlobSasPermissions, + generate_blob_sas, +) app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) -BLOB_CONN_STR = os.environ["CHARTS_BLOB_CONNECTION_STRING"] + +# --------------------------------------------------------------------------- +# Blob auth configuration +# +# Two supported modes: +# 1. CHARTS_BLOB_CONNECTION_STRING with AccountKey=... +# Legacy shared-key mode. Container is created with public-blob access +# and the returned URL is the unsigned blob URL. +# 2. CHARTS_BLOB_ACCOUNT_URL (e.g. https://mystorage.blob.core.windows.net) +# Identity mode. Auth via DefaultAzureCredential (Managed Identity in +# Azure, az/dev creds locally). Container stays private; the returned +# URL is a User Delegation SAS expiring in CHARTS_SAS_TTL_DAYS (default 30). +# +# Mode 2 is required on tenants that enforce StorageAccount_DisableLocalAuth +# or StorageAccount_BlobAnonymousAccess Azure Policies. +# --------------------------------------------------------------------------- + + +def _parse_connection_string(conn: str) -> dict: + return dict(p.split("=", 1) for p in conn.split(";") if "=" in p) + + +_CONN_STR = os.environ.get("CHARTS_BLOB_CONNECTION_STRING", "") +_ACCOUNT_URL_ENV = os.environ.get("CHARTS_BLOB_ACCOUNT_URL", "").rstrip("/") +_CONN_PARTS = _parse_connection_string(_CONN_STR) if _CONN_STR else {} + +USE_IDENTITY = bool(_ACCOUNT_URL_ENV) or "AccountKey" not in _CONN_PARTS + +if USE_IDENTITY: + if _ACCOUNT_URL_ENV: + BLOB_ACCOUNT_URL = _ACCOUNT_URL_ENV + else: + name = _CONN_PARTS.get("AccountName") + if not name: + raise RuntimeError( + "Set CHARTS_BLOB_ACCOUNT_URL or a CHARTS_BLOB_CONNECTION_STRING with AccountName." + ) + suffix = _CONN_PARTS.get("EndpointSuffix", "core.windows.net") + proto = _CONN_PARTS.get("DefaultEndpointsProtocol", "https") + BLOB_ACCOUNT_URL = f"{proto}://{name}.blob.{suffix}" + BLOB_ACCOUNT_NAME = urlparse(BLOB_ACCOUNT_URL).hostname.split(".")[0] + from azure.identity import DefaultAzureCredential + _credential = DefaultAzureCredential() + _blob_service = BlobServiceClient(account_url=BLOB_ACCOUNT_URL, credential=_credential) +else: + BLOB_ACCOUNT_URL = "" + BLOB_ACCOUNT_NAME = _CONN_PARTS.get("AccountName", "") + _blob_service = BlobServiceClient.from_connection_string(_CONN_STR) + BLOB_CONTAINER = os.environ.get("CHARTS_BLOB_CONTAINER", "matplotlib-charts") +# Legacy alias retained for backward-compat with call sites that pass it. +BLOB_CONN_STR = _CONN_STR or BLOB_ACCOUNT_URL + +SAS_TTL_DAYS = int(os.environ.get("CHARTS_SAS_TTL_DAYS", "30")) + +_user_delegation_key = None +_user_delegation_key_expiry = datetime.now(timezone.utc) + + +def _get_user_delegation_key(): + """Cache a user delegation key to avoid one round-trip per upload.""" + global _user_delegation_key, _user_delegation_key_expiry + now = datetime.now(timezone.utc) + if _user_delegation_key is None or now >= _user_delegation_key_expiry - timedelta(minutes=10): + start = now - timedelta(minutes=5) + expiry = now + timedelta(hours=24) + _user_delegation_key = _blob_service.get_user_delegation_key( + key_start_time=start, key_expiry_time=expiry + ) + _user_delegation_key_expiry = expiry + return _user_delegation_key SUPPORTED_CHARTS = ["bar", "line", "pie", "scatter", "histogram", @@ -410,17 +485,37 @@ def _apply_annotations(ax, params): # --------------------------------------------------------------------------- def _get_container_client(connection_string: str, container_name: str): - blob_service = BlobServiceClient.from_connection_string(connection_string) - container_client = blob_service.get_container_client(container_name) + """Return a container client. The connection_string arg is retained for + call-site compatibility but ignored — the module-level client is used. + In legacy shared-key mode the container is created with public-blob + access; in identity mode it stays private (URLs are SAS-signed).""" + container_client = _blob_service.get_container_client(container_name) try: - container_client.create_container(public_access="blob") + if USE_IDENTITY: + container_client.create_container() + else: + container_client.create_container(public_access="blob") except Exception: pass # already exists return container_client +def _sas_url_for(blob_name: str) -> str: + udk = _get_user_delegation_key() + sas = generate_blob_sas( + account_name=BLOB_ACCOUNT_NAME, + container_name=BLOB_CONTAINER, + blob_name=blob_name, + user_delegation_key=udk, + permission=BlobSasPermissions(read=True), + expiry=datetime.now(timezone.utc) + timedelta(days=SAS_TTL_DAYS), + ) + return f"{BLOB_ACCOUNT_URL}/{BLOB_CONTAINER}/{blob_name}?{sas}" + + def _upload_to_blob(image_bytes: bytes, connection_string: str, container_name: str) -> tuple[str, str]: - """Upload image to blob. Returns (url, blob_id).""" + """Upload image to blob. Returns (url, blob_id). The URL is a public + blob URL in legacy mode, or a User Delegation SAS link in identity mode.""" container_client = _get_container_client(connection_string, container_name) blob_id = uuid.uuid4().hex @@ -431,7 +526,7 @@ def _upload_to_blob(image_bytes: bytes, connection_string: str, container_name: overwrite=True, content_settings=ContentSettings(content_type="image/png"), ) - return blob_client.url, blob_id + return (_sas_url_for(blob_name) if USE_IDENTITY else blob_client.url), blob_id def _upload_log(blob_id: str, connection_string: str, container_name: str, diff --git a/host.json b/host.json index 2d8d527..c38cc57 100644 --- a/host.json +++ b/host.json @@ -16,7 +16,7 @@ "mcp": { "serverName": "MatplotlibChartGenerator", "serverVersion": "1.0.0", - "instructions": "Generates matplotlib chart images. Call generate_chart with chart_type, data (JSON string), and optional params (JSON string for styling). Returns a public URL to the PNG image.", + "instructions": "Generates matplotlib chart images. Call generate_chart with chart_type, data (JSON string), and optional params (JSON string for styling). Returns a time-limited URL (User Delegation SAS, default 30-day TTL) to the PNG image, or an unsigned public blob URL when configured in legacy shared-key mode.", "system": { "webhookAuthorizationLevel": "Anonymous" } diff --git a/local.settings.json.example b/local.settings.json.example index bb86058..6b5aad6 100644 --- a/local.settings.json.example +++ b/local.settings.json.example @@ -4,7 +4,16 @@ "AzureWebJobsStorage": "UseDevelopmentStorage=true", "FUNCTIONS_WORKER_RUNTIME": "python", + "//": "Pick ONE of the two storage auth modes below.", + + "// MODE 1 — Identity (recommended, required on tenants with disable-local-auth/anonymous-access policies)": "", + "// CHARTS_BLOB_ACCOUNT_URL": "https://.blob.core.windows.net", + "// Auth uses DefaultAzureCredential. Locally this means `az login` with a principal that has Storage Blob Data Owner on the account.": "", + + "// MODE 2 — Legacy shared key (works only when the account allows shared keys)": "", "CHARTS_BLOB_CONNECTION_STRING": "DefaultEndpointsProtocol=https;AccountName=;AccountKey=;EndpointSuffix=core.windows.net", - "CHARTS_BLOB_CONTAINER": "matplotlib-charts" + + "CHARTS_BLOB_CONTAINER": "matplotlib-charts", + "CHARTS_SAS_TTL_DAYS": "30" } } diff --git a/openapi.json b/openapi.json index 256ac0c..cfbcd4d 100644 --- a/openapi.json +++ b/openapi.json @@ -2,7 +2,7 @@ "swagger": "2.0", "info": { "title": "Matplotlib Chart Generator", - "description": "Generates matplotlib charts and returns a public URL to the PNG image hosted in Azure Blob Storage.", + "description": "Generates matplotlib charts and returns a URL to the PNG image hosted in Azure Blob Storage. The URL is a time-limited User Delegation SAS link by default (or an unsigned public blob URL in legacy shared-key mode).", "version": "1.0.0" }, "host": ".azurewebsites.net", @@ -15,7 +15,7 @@ "post": { "operationId": "GenerateChart", "summary": "Generate a matplotlib chart", - "description": "Accepts a chart type, data, and styling parameters. Returns a public URL to the generated PNG chart image.", + "description": "Accepts a chart type, data, and styling parameters. Returns a URL to the generated PNG chart image (time-limited SAS by default).", "parameters": [ { "name": "body", @@ -201,7 +201,7 @@ "chart_type": { "type": "string", "description": "Type of chart to generate", - "enum": ["bar", "line", "pie", "scatter", "histogram"] + "enum": ["bar", "line", "pie", "scatter", "histogram", "area", "box", "violin", "heatmap"] }, "data": { "$ref": "#/definitions/ChartData" @@ -216,7 +216,7 @@ "properties": { "url": { "type": "string", - "description": "Public URL to the generated chart PNG image" + "description": "URL to the generated chart PNG image. Time-limited SAS link by default; unsigned public URL when running in legacy shared-key mode." } } }, diff --git a/requirements.txt b/requirements.txt index 4042e1f..adcb561 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ azure-functions>=1.24.0 matplotlib numpy azure-storage-blob +azure-identity