From 6957f78fffd41c4adfcff2d0743c20a1c2666348 Mon Sep 17 00:00:00 2001 From: Mukesh Agarwal <38921815+mukeshagmsft@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:17:07 +0000 Subject: [PATCH 1/5] feat(infrastructure): add direct Foundry model connection with PMI Add a project-scoped ApiManagement connection to an existing Foundry account, optional project-identity target RBAC, generated ARM, parameters, and prompt-agent instructions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 40baedfa-7ef3-4419-9273-0bc015109527 (cherry picked from commit 0c4cbee50bc0d6a4f9851954c5e986ae6c6e08e3) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../01-connections/README.md | 3 +- .../foundry-model-connection/README.md | 150 +++++++++++++ .../foundry-model-connection/azuredeploy.json | 202 ++++++++++++++++++ .../foundry-model-connection/main.bicep | 93 ++++++++ .../foundry-model-connection/metadata.json | 12 ++ .../modules/project-model-role.bicep | 26 +++ .../samples/parameters.json | 17 ++ .../golden-path/README.md | 16 +- 8 files changed, 512 insertions(+), 7 deletions(-) create mode 100644 infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/README.md create mode 100644 infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/azuredeploy.json create mode 100644 infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/main.bicep create mode 100644 infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/metadata.json create mode 100644 infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/modules/project-model-role.bicep create mode 100644 infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/samples/parameters.json diff --git a/infrastructure/infrastructure-setup-bicep/01-connections/README.md b/infrastructure/infrastructure-setup-bicep/01-connections/README.md index 875779ce5..d3d2c7214 100644 --- a/infrastructure/infrastructure-setup-bicep/01-connections/README.md +++ b/infrastructure/infrastructure-setup-bicep/01-connections/README.md @@ -15,10 +15,11 @@ variants in [`../golden-path`](../golden-path/README.md): | 2. No separate gateway — model in another Foundry / Azure OpenAI account | [`model-gateway`](./model-gateway/) (`samples/parameters-foundryazureai.json`, `parameters-foundryopenai.json`, or `parameters-foundryanthropic.json`) — a **ModelGateway** connection pointed at the other account's inference endpoint | | 3. Third-party model provider | [`model-gateway`](./model-gateway/) (`samples/parameters-openai.json`) | | 4. BYOM + BYOG (your own 3P gateway) | [`model-gateway`](./model-gateway/) (custom `targetUrl`) | +| Direct Foundry models with project managed identity | [`foundry-model-connection`](./foundry-model-connection/) (`ApiManagement` category, no separate gateway; optional target-account RBAC) | > [!IMPORTANT] > BYOM `/` resolution works **only** for *gateway-category* connections — -> `ApiManagement` (variant 1) or `ModelGateway` (variants 2–4). A plain +> `ApiManagement` (variant 1 or the direct Foundry PMI sample) or `ModelGateway` (variants 2–4). A plain > [`connection-azure-openai.bicep`](./connection-azure-openai.bicep) (`AzureOpenAI`) or > [`connection-foundry.bicep`](./connection-foundry.bicep) (`CognitiveService`) connection is > **not** resolvable by a prompt agent as `/` — it fails with diff --git a/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/README.md b/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/README.md new file mode 100644 index 000000000..c8b69e918 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/README.md @@ -0,0 +1,150 @@ +# Foundry model connection with project managed identity + +Connect an existing Foundry project directly to models on another existing +Foundry account, without deploying an API Management service or using API keys. +This sample uses the same `ApiManagement` connection category, project managed +identity (PMI), audience, Responses surface, and dynamic discovery metadata as +the Foundry model-resource connection UX. + +```text +Prompt agent in source project + -> foundry-models/ + -> target Foundry account /openai/v1 + authenticated as the SOURCE PROJECT managed identity +``` + +`ApiManagement` is the connection category, not infrastructure provisioned by +this sample. For an actual gateway with policies, use +[`public-byom-apim`](../public-byom-apim/) or +[`ai-gateway-tier`](../ai-gateway-tier/) instead. The classic +[`connection-foundry.bicep`](../connection-foundry.bicep) creates a different +connection type and is not interchangeable for agent model references. + +## Prerequisites + +- An existing Foundry account and project, for example from + [`40-basic-agent-setup`](../../40-basic-agent-setup/) or + [`41-standard-agent-setup`](../../41-standard-agent-setup/). + The source project must have a **system-assigned managed identity**. +- An existing target Foundry account (`AIServices`) with a model deployment + that supports the Responses API. Use its **deployment name**, which can differ + from the model name. This is not an Azure OpenAI account sample. +- Source and target must be in the same Entra tenant. The target can be in a + different resource group or subscription. +- Azure CLI with Bicep. Deploy into the **source account's resource group**. + The deploying identity needs connection-write permissions on the source + project. With `assignBackendRole=true`, it also needs + `Microsoft.Authorization/roleAssignments/write` at the target account and + deployment permissions in the target resource group. +- The target endpoint must be reachable by the managed inference service. + This sample does not provision private endpoints, networking, accounts, + projects, model deployments, or agent infrastructure. +- The user running the agent script needs project data-plane permissions, + independently of the project identity's target-account role. + +The parameter example targets AzureCloud. Use the actual target endpoint and +appropriate `audience` and `foundryDataPlaneSuffix` for another cloud; this +sample does not establish feature availability in sovereign clouds. + +## Deploy the connection and RBAC + +From this directory, copy `samples/parameters.json` to a local parameter file +and replace all placeholders. Do not commit populated local files. +`targetOpenAIBaseUrl` must be the target's actual Foundry endpoint ending in +`/openai/v1`, with **no trailing slash or query string**. The target resource ID +must identify that same account. + +```bash +az login +az account set --subscription "" + +az bicep build --file main.bicep --outfile azuredeploy.json + +az deployment group create \ + --resource-group "" \ + --name foundry-model-connection \ + --template-file main.bicep \ + --parameters @parameters.local.json +``` + +The deployment: + +1. Reads the existing source project's managed-identity principal ID. +2. Grants that identity **Foundry User (Azure AI User)**, + `53ca6127-db72-4b80-b1b0-d745d6d5456d`, scoped only to the target account. +3. Creates a project-scoped, non-shared `ApiManagement` connection with + `ProjectManagedIdentity` authentication and an explicit Cognitive Services + audience. No keys or API-key `authConfig` are stored. + +Set `assignBackendRole=false` if an administrator already provisioned the role. +That option does **not** make the target usable without equivalent permission. +The role assignment uses a deterministic name so repeat deployments reuse it. +If an identical assignment already exists under a different name, have an +administrator verify it and disable assignment rather than creating a duplicate. + +Metadata matches the UX contract: `upstreamSurface: responses`, +`deploymentInPath: 'false'`, an empty static model list, and JSON-serialized +dynamic discovery for `/openai/deployments` and +`/openai/deployments/{deploymentName}` with `deploymentAPIVersion: '2022-12-01'`. +This sample does not change discovery endpoints or API versions. + +Outputs include `connectionId`, `connectionName`, `projectEndpoint`, +`modelReference`, `targetEndpoint`, and `roleAssignmentId` (empty when skipped). +Creating the ARM resources does not prove that inference or discovery succeeds; +complete the agent invocation below. RBAC propagation can outlast deployment. + +## Create and invoke an agent + +Reuse the connection-agnostic +[`create-agent.py`](../public-byom-apim/samples/create-agent.py); no agent ARM +resource or duplicate script is needed. From this directory: + +```bash +python -m pip install "azure-ai-projects>=2.0.0" azure-identity + +PROJECT_ENDPOINT=$(az deployment group show \ + --resource-group "" \ + --name foundry-model-connection --query properties.outputs.projectEndpoint.value -o tsv) +MODEL_REFERENCE=$(az deployment group show \ + --resource-group "" \ + --name foundry-model-connection --query properties.outputs.modelReference.value -o tsv) + +python ../public-byom-apim/samples/create-agent.py \ + --endpoint "$PROJECT_ENDPOINT" \ + --model "$MODEL_REFERENCE" \ + --agent-name foundry-pmi-sample-agent \ + --prompt "Say hello in five words." +``` + +The reference is `foundry-models/`. Use a **prompt +agent + Responses API**, not the classic Assistants threads/runs API. +Success means the script prints the created agent version and response text. +Use a unique agent name if an existing agent must not gain another version. +Agent calls consume model quota and can incur charges. + +## Verification and troubleshooting + +- Check the connection on the **source project**: category `ApiManagement`, + authentication `ProjectManagedIdentity`, explicit audience, and the correct + target endpoint and `metadata.ResourceId`. +- Check target-account IAM for Foundry User on the **source project's principal + ID**, not the source account identity, application/client ID, or your user. + Deployment ordering does not eliminate RBAC propagation delay; wait and retry. +- For upstream `Unauthorized`, check that role, audience, tenant, and target + account match. Having Owner yourself does not grant the project identity access. +- For model-resolution errors, check the deployment name and connection name, + model Responses support, and use of the prompt-agent Responses flow. +- A discovery timeout is separate from authentication. This template does not + fix or extend the model gateway's discovery timeout. + +## Cleanup + +Delete the sample agent versions, agent, and conversation created by the script +through the Foundry portal or SDK. Delete only the connection identified by the +`connectionId` output. If this deployment created the role assignment, remove +that assignment only after confirming no other connection or application needs +the same project identity's access to the target account. + +Deleting the deployment record does **not** delete its resources. Do not delete +the existing source/target accounts, projects, resource groups, or model +deployments when cleaning up this layer-on-existing sample. diff --git a/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/azuredeploy.json b/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/azuredeploy.json new file mode 100644 index 000000000..e263f51f5 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/azuredeploy.json @@ -0,0 +1,202 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.41.2.15936", + "templateHash": "9915713739655857723" + } + }, + "parameters": { + "accountName": { + "type": "string", + "metadata": { + "description": "Name of the existing Foundry account in THIS deployment resource group." + } + }, + "projectName": { + "type": "string", + "metadata": { + "description": "Name of the existing project under accountName. The project must have a system-assigned managed identity." + } + }, + "targetAccountResourceId": { + "type": "string", + "metadata": { + "description": "Resource ID of the existing target Foundry account hosting the model. May be in another resource group or subscription in the same tenant." + } + }, + "targetOpenAIBaseUrl": { + "type": "string", + "metadata": { + "description": "Target Foundry OpenAI base URL, including /openai/v1 and no trailing slash. Use the actual endpoint, not the target ARM resource ID." + } + }, + "modelDeploymentName": { + "type": "string", + "metadata": { + "description": "Existing model deployment name on the target account, not the underlying catalog model name." + } + }, + "connectionName": { + "type": "string", + "defaultValue": "foundry-models", + "metadata": { + "description": "Project connection name. Agents reference /." + } + }, + "assignBackendRole": { + "type": "bool", + "defaultValue": true, + "metadata": { + "description": "Grant Foundry User on the target account to the source PROJECT managed identity. Requires roleAssignments/write at the target account and deployment permissions in its resource group. Set false only if access is already provisioned." + } + }, + "audience": { + "type": "string", + "defaultValue": "https://cognitiveservices.azure.com", + "metadata": { + "description": "Cognitive Services token audience for the target cloud. The default is for AzureCloud." + } + }, + "foundryDataPlaneSuffix": { + "type": "string", + "defaultValue": "services.ai.azure.com", + "metadata": { + "description": "Foundry data-plane hostname suffix for the source cloud. Used only for the project endpoint output; the default is for AzureCloud." + } + } + }, + "variables": { + "targetIdParts": "[split(parameters('targetAccountResourceId'), '/')]", + "targetAccountName": "[variables('targetIdParts')[8]]", + "targetSubscriptionId": "[variables('targetIdParts')[2]]", + "targetResourceGroupName": "[variables('targetIdParts')[4]]", + "discoveryBaseUrl": "[replace(parameters('targetOpenAIBaseUrl'), '/openai/v1', '/openai')]" + }, + "resources": [ + { + "type": "Microsoft.CognitiveServices/accounts/projects/connections", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), parameters('connectionName'))]", + "properties": { + "category": "ApiManagement", + "target": "[parameters('targetOpenAIBaseUrl')]", + "authType": "ProjectManagedIdentity", + "audience": "[parameters('audience')]", + "credentials": {}, + "isSharedToAll": false, + "metadata": { + "ApiType": "Azure", + "ResourceId": "[parameters('targetAccountResourceId')]", + "displayName": "[parameters('connectionName')]", + "upstreamSurface": "responses", + "deploymentInPath": "false", + "deploymentAPIVersion": "2022-12-01", + "customHeaders": "{}", + "models": "[[]", + "modelDiscovery": "[string(createObject('listModelsEndpoint', format('{0}/deployments', variables('discoveryBaseUrl')), 'getModelEndpoint', format('{0}/deployments/{{deploymentName}}', variables('discoveryBaseUrl')), 'deploymentProvider', 'OpenAI'))]" + } + }, + "dependsOn": [ + "[extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('targetSubscriptionId'), variables('targetResourceGroupName')), 'Microsoft.Resources/deployments', format('foundry-model-role-{0}', uniqueString(resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('accountName'), parameters('projectName')), parameters('connectionName'), parameters('targetAccountResourceId'))))]" + ] + }, + { + "condition": "[parameters('assignBackendRole')]", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "[format('foundry-model-role-{0}', uniqueString(resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('accountName'), parameters('projectName')), parameters('connectionName'), parameters('targetAccountResourceId')))]", + "subscriptionId": "[variables('targetSubscriptionId')]", + "resourceGroup": "[variables('targetResourceGroupName')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "targetAccountName": { + "value": "[variables('targetAccountName')]" + }, + "projectPrincipalId": { + "value": "[reference(resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('accountName'), parameters('projectName')), '2025-04-01-preview', 'full').identity.principalId]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.41.2.15936", + "templateHash": "2605395722310853417" + } + }, + "parameters": { + "targetAccountName": { + "type": "string", + "metadata": { + "description": "Existing target Foundry account in this module deployment resource group." + } + }, + "projectPrincipalId": { + "type": "string", + "metadata": { + "description": "Principal/object ID of the source project managed identity, not the account identity or the deploying user." + } + } + }, + "variables": { + "foundryUserRoleId": "53ca6127-db72-4b80-b1b0-d745d6d5456d" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('targetAccountName'))]", + "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('targetAccountName')), parameters('projectPrincipalId'), variables('foundryUserRoleId'))]", + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('foundryUserRoleId'))]", + "principalId": "[parameters('projectPrincipalId')]", + "principalType": "ServicePrincipal" + } + } + ], + "outputs": { + "roleAssignmentId": { + "type": "string", + "value": "[extensionResourceId(resourceId('Microsoft.CognitiveServices/accounts', parameters('targetAccountName')), 'Microsoft.Authorization/roleAssignments', guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('targetAccountName')), parameters('projectPrincipalId'), variables('foundryUserRoleId')))]" + } + } + } + } + } + ], + "outputs": { + "connectionId": { + "type": "string", + "value": "[resourceId('Microsoft.CognitiveServices/accounts/projects/connections', parameters('accountName'), parameters('projectName'), parameters('connectionName'))]" + }, + "connectionName": { + "type": "string", + "value": "[parameters('connectionName')]" + }, + "modelReference": { + "type": "string", + "value": "[format('{0}/{1}', parameters('connectionName'), parameters('modelDeploymentName'))]" + }, + "projectEndpoint": { + "type": "string", + "value": "[format('https://{0}.{1}/api/projects/{2}', reference(resourceId('Microsoft.CognitiveServices/accounts', parameters('accountName')), '2025-04-01-preview').customSubDomainName, parameters('foundryDataPlaneSuffix'), parameters('projectName'))]" + }, + "targetEndpoint": { + "type": "string", + "value": "[parameters('targetOpenAIBaseUrl')]" + }, + "roleAssignmentId": { + "type": "string", + "value": "[if(parameters('assignBackendRole'), reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('targetSubscriptionId'), variables('targetResourceGroupName')), 'Microsoft.Resources/deployments', format('foundry-model-role-{0}', uniqueString(resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('accountName'), parameters('projectName')), parameters('connectionName'), parameters('targetAccountResourceId')))), '2025-04-01').outputs.roleAssignmentId.value, '')]" + } + } +} \ No newline at end of file diff --git a/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/main.bicep b/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/main.bicep new file mode 100644 index 000000000..63181c284 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/main.bicep @@ -0,0 +1,93 @@ +targetScope = 'resourceGroup' + +@description('Name of the existing Foundry account in THIS deployment resource group.') +param accountName string + +@description('Name of the existing project under accountName. The project must have a system-assigned managed identity.') +param projectName string + +@description('Resource ID of the existing target Foundry account hosting the model. May be in another resource group or subscription in the same tenant.') +param targetAccountResourceId string + +@description('Target Foundry OpenAI base URL, including /openai/v1 and no trailing slash. Use the actual endpoint, not the target ARM resource ID.') +param targetOpenAIBaseUrl string + +@description('Existing model deployment name on the target account, not the underlying catalog model name.') +param modelDeploymentName string + +@description('Project connection name. Agents reference /.') +param connectionName string = 'foundry-models' + +@description('Grant Foundry User on the target account to the source PROJECT managed identity. Requires roleAssignments/write at the target account and deployment permissions in its resource group. Set false only if access is already provisioned.') +param assignBackendRole bool = true + +@description('Cognitive Services token audience for the target cloud. The default is for AzureCloud.') +param audience string = 'https://cognitiveservices.azure.com' + +@description('Foundry data-plane hostname suffix for the source cloud. Used only for the project endpoint output; the default is for AzureCloud.') +param foundryDataPlaneSuffix string = 'services.ai.azure.com' + +var targetIdParts = split(targetAccountResourceId, '/') +var targetAccountName = targetIdParts[8] +var targetSubscriptionId = targetIdParts[2] +var targetResourceGroupName = targetIdParts[4] +var discoveryBaseUrl = replace(targetOpenAIBaseUrl, '/openai/v1', '/openai') + +resource account 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: accountName +} + +resource project 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' existing = { + parent: account + name: projectName +} + +module projectModelRole 'modules/project-model-role.bicep' = if (assignBackendRole) { + name: 'foundry-model-role-${uniqueString(project.id, connectionName, targetAccountResourceId)}' + scope: resourceGroup(targetSubscriptionId, targetResourceGroupName) + params: { + targetAccountName: targetAccountName + projectPrincipalId: project.identity.principalId + } +} + +// ApiManagement is the connection category; no API Management service is deployed. +resource connection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = { + parent: project + name: connectionName + properties: { + category: 'ApiManagement' + target: targetOpenAIBaseUrl + // The service supports PMI; the published Bicep authType enum does not yet include it. + #disable-next-line BCP036 + authType: 'ProjectManagedIdentity' + audience: audience + credentials: {} + isSharedToAll: false + metadata: { + ApiType: 'Azure' + ResourceId: targetAccountResourceId + displayName: connectionName + upstreamSurface: 'responses' + deploymentInPath: 'false' + deploymentAPIVersion: '2022-12-01' + customHeaders: '{}' + models: '[]' + modelDiscovery: string({ + listModelsEndpoint: '${discoveryBaseUrl}/deployments' + getModelEndpoint: '${discoveryBaseUrl}/deployments/{deploymentName}' + deploymentProvider: 'OpenAI' + }) + } + } + dependsOn: [ + projectModelRole + ] +} + +output connectionId string = connection.id +output connectionName string = connection.name +output modelReference string = '${connectionName}/${modelDeploymentName}' +output projectEndpoint string = 'https://${account.properties.customSubDomainName}.${foundryDataPlaneSuffix}/api/projects/${projectName}' +output targetEndpoint string = targetOpenAIBaseUrl +output roleAssignmentId string = assignBackendRole ? projectModelRole!.outputs.roleAssignmentId : '' diff --git a/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/metadata.json b/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/metadata.json new file mode 100644 index 000000000..92a395277 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/metadata.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://aka.ms/azure-quickstart-templates-metadata-schema#", + "type": "QuickStart", + "itemDisplayName": "Connect a Foundry project to Foundry models with project managed identity", + "description": "Creates an ApiManagement-category connection directly to an existing Foundry account and optionally grants the source project's managed identity Foundry User on that account. A prompt agent invokes an existing model deployment as connection/deployment through the Responses API. No API Management service or model deployment is created.", + "summary": "Keyless project-to-Foundry model connection with project-managed-identity authentication and target-account RBAC.", + "githubUsername": "mukeshagmsft", + "dateUpdated": "2026-11-03", + "environments": [ + "AzureCloud" + ] +} diff --git a/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/modules/project-model-role.bicep b/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/modules/project-model-role.bicep new file mode 100644 index 000000000..80d2f58d1 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/modules/project-model-role.bicep @@ -0,0 +1,26 @@ +targetScope = 'resourceGroup' + +@description('Existing target Foundry account in this module deployment resource group.') +param targetAccountName string + +@description('Principal/object ID of the source project managed identity, not the account identity or the deploying user.') +param projectPrincipalId string + +// Foundry User (Azure AI User). +var foundryUserRoleId = '53ca6127-db72-4b80-b1b0-d745d6d5456d' + +resource targetAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: targetAccountName +} + +resource projectModelRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: targetAccount + name: guid(targetAccount.id, projectPrincipalId, foundryUserRoleId) + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', foundryUserRoleId) + principalId: projectPrincipalId + principalType: 'ServicePrincipal' + } +} + +output roleAssignmentId string = projectModelRole.id diff --git a/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/samples/parameters.json b/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/samples/parameters.json new file mode 100644 index 000000000..ef8c2c06c --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/samples/parameters.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "accountName": { "value": "" }, + "projectName": { "value": "" }, + "targetAccountResourceId": { + "value": "/subscriptions//resourceGroups//providers/Microsoft.CognitiveServices/accounts/" + }, + "targetOpenAIBaseUrl": { + "value": "https://.services.ai.azure.com/openai/v1" + }, + "modelDeploymentName": { "value": "" }, + "connectionName": { "value": "foundry-models" }, + "assignBackendRole": { "value": true } + } +} diff --git a/infrastructure/infrastructure-setup-bicep/golden-path/README.md b/infrastructure/infrastructure-setup-bicep/golden-path/README.md index af8630d5d..080971023 100644 --- a/infrastructure/infrastructure-setup-bicep/golden-path/README.md +++ b/infrastructure/infrastructure-setup-bicep/golden-path/README.md @@ -42,26 +42,29 @@ Deploy [`41-standard-agent-setup`](../41-standard-agent-setup/) (standard, BYO d | 3 | **Third-party model provider** | Model hosted by a 3P provider (e.g. OpenAI) reached through the Foundry **ModelGateway** connection | [`01-connections/model-gateway`](../01-connections/model-gateway/) (`samples/parameters-openai.json`) | API key / OAuth2 | | 4 | **BYOM + BYOG** (your own 3P gateway) | Your own non-APIM gateway (LiteLLM, Kong, a custom proxy, …) in front of your models | [`01-connections/model-gateway`](../01-connections/model-gateway/) (custom `targetUrl` + headers / auth) | API key / OAuth2 | | 5 | **AI Gateway tier** (preview) — dedicated gateway fronting a Foundry model | You want the purpose-built AI Gateway tier (APIM **`AIGateway`** SKU) with a token-limit policy in front of a Foundry model, consumed by one or more spoke projects (same or cross-subscription) | [`01-connections/ai-gateway-tier`](../01-connections/ai-gateway-tier/) — deploy `main.bicep` (hub: account + model + gateway) **once**, then `connection.bicep` per spoke project | Gateway runtime key (`api-key`) | +| 6 | **Direct Foundry connection with PMI** | Reach an existing Foundry model without a separate gateway or API keys; grant the source project's identity access on the target account | [`01-connections/foundry-model-connection`](../01-connections/foundry-model-connection/) — `ApiManagement` connection plus optional Foundry User role assignment | Project MI | -All five produce a **connection** whose model is referenced as `/` +All six produce a **connection** whose model is referenced as `/` by the agent in step 6 (e.g. `ai-gateway/gpt-4o`). > [!IMPORTANT] > **BYOM `/` resolution only works for *gateway-category* connections — -> `ApiManagement` (variant 1) or `ModelGateway` (variants 2–4).** A plain `AzureOpenAI` or +> `ApiManagement` (variants 1 and 6) or `ModelGateway` (variants 2–5).** A plain `AzureOpenAI` or > `CognitiveService`/Foundry connection (`connection-azure-openai.bicep` / > `connection-foundry.bicep`) is **not** resolvable by a prompt agent as `/` > — a prompt agent bound to one fails with `Connection '' not found`. Those connection > types are for classic SDK / data-plane use, not gateway-routed agent inference. To reach a > model in *another* account without your own APIM, use variant 2 (a `ModelGateway` connection -> pointed directly at that account's inference endpoint). +> pointed directly at that account's inference endpoint) or variant 6 for a direct Foundry +> `ApiManagement` connection authenticated with the project's managed identity. > [!NOTE] > Variants **1 and 5** *create* the gateway infra for you — variant 1 a public APIM, variant 5 > the **AI Gateway tier** (`AIGateway` SKU) hub (deploy its `main.bicep` once; multiple spoke > projects/subscriptions attach with `connection.bicep`). Variants 2–4 add a **ModelGateway** > connection to an upstream endpoint that already exists (another Foundry/Azure OpenAI account, -> a 3P provider, or your own gateway). In every case the model is invoked through a +> a 3P provider, or your own gateway). Variant 6 adds a direct `ApiManagement` connection +> to an existing Foundry account without creating a gateway. In every case the model is invoked through a > **prompt agent + Responses API** — see [Step 6](#step-6--create-an-agent-both-paths). ### Step 6 — create an agent @@ -127,8 +130,9 @@ variant you deployed). Point the SDK at your **project endpoint** and reference the model as `/` (e.g. `ai-gateway/gpt-4o`) from step 5. The agent runs -server-side using the **project's managed identity**, which mints the token that APIM's -`validate-azure-ad-token` policy checks. +server-side. For the PMI variants, it uses the **project's managed identity**: +APIM's `validate-azure-ad-token` policy checks the token in variant 1, while the +target Foundry account authorizes that identity directly in variant 6. ```python # pip install azure-ai-projects>=2.0.0 azure-identity From 478f5c4089a07a1dc24d856bc5d099dcb1a04820 Mon Sep 17 00:00:00 2001 From: foundry-samples automation Date: Tue, 3 Nov 2026 21:20:41 +0000 Subject: [PATCH 2/5] Automatic fixes (cherry picked from commit 681cec6192ee05488bccd4e009fef4e65ea2ee73) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../foundry-model-connection/azuredeploy.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/azuredeploy.json b/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/azuredeploy.json index e263f51f5..8d10a67ae 100644 --- a/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/azuredeploy.json +++ b/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/azuredeploy.json @@ -4,8 +4,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "9915713739655857723" + "version": "0.48.1.52935", + "templateHash": "12627546456249312225" } }, "parameters": { @@ -129,8 +129,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "2605395722310853417" + "version": "0.48.1.52935", + "templateHash": "13985353306192642666" } }, "parameters": { From 06cf8c16fd014ebf617b23b6aec34f1f1632da28 Mon Sep 17 00:00:00 2001 From: Mukesh Agarwal <38921815+mukeshagmsft@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:06:33 +0000 Subject: [PATCH 3/5] feat(infrastructure): demonstrate secret APIM connection headers Add a PMI model connection with a secure CustomKeys companion, Key Vault parameter reference, generated ARM, and usage/rotation guidance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 40baedfa-7ef3-4419-9273-0bc015109527 (cherry picked from commit 7437b3c96bb46c81c939855d21d87c021a61883a) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../01-connections/README.md | 5 + .../apim-secret-headers/README.md | 121 ++++++++++++++ .../apim-secret-headers/azuredeploy.json | 148 ++++++++++++++++++ .../apim-secret-headers/main.bicep | 107 +++++++++++++ .../apim-secret-headers/metadata.json | 12 ++ .../samples/parameters.json | 24 +++ 6 files changed, 417 insertions(+) create mode 100644 infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/README.md create mode 100644 infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/azuredeploy.json create mode 100644 infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/main.bicep create mode 100644 infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/metadata.json create mode 100644 infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/samples/parameters.json diff --git a/infrastructure/infrastructure-setup-bicep/01-connections/README.md b/infrastructure/infrastructure-setup-bicep/01-connections/README.md index d3d2c7214..eb7f40e84 100644 --- a/infrastructure/infrastructure-setup-bicep/01-connections/README.md +++ b/infrastructure/infrastructure-setup-bicep/01-connections/README.md @@ -4,6 +4,11 @@ Connections enable your AI applications to access tools and objects managed else This folder provides a set of examples for the most common connection categories. +For a gateway requiring both project identity and a secret subscription-key +header, see [`apim-secret-headers`](./apim-secret-headers/). It creates an +`ApiManagement` model connection and a `CustomKeys` companion without exposing +the header value in model metadata. + ## BYOM golden-path variants (non-VNet) For the end-to-end **bring-your-own-model** golden path, these connections map to the diff --git a/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/README.md b/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/README.md new file mode 100644 index 000000000..894e636e4 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/README.md @@ -0,0 +1,121 @@ +# APIM connection with project identity and a secret header + +Add a model connection to an **existing** Foundry project whose gateway requires +both project-managed-identity authentication and an APIM subscription key. +The key is a secret HTTP header value, not an asymmetric private key. + +This sample creates two project-scoped connections using API `2026-07-01`: + +| Connection | Authentication | Contents | +|---|---|---| +| `apim-private-header` (configurable) | `ProjectManagedIdentity` | Gateway URL, audience, model metadata, and a reference to the secret header | +| `secret-headers-` | `CustomKeys` | `credentials.keys.header0` containing the subscription key and `metadata.secretHeadersFor` identifying the parent | + +Both use the same target and `isSharedToAll: false`. The model connection's +`metadata.secretHeaders` is a JSON string mapping `Ocp-Apim-Subscription-Key` to +`{ "connection": "", "key": "header0" }`. It contains no secret +value, and `customHeaders` remains `'{}'`. Referencing the companion resource +creates an ARM dependency so the companion is created before its parent. + +For a direct Foundry connection without a gateway or secret header, see +[`foundry-model-connection`](../foundry-model-connection/). For provisioning an +APIM gateway, see [`public-byom-apim`](../public-byom-apim/). + +## Prerequisites + +- An existing Foundry account and project with managed identity. Deploy into + that account's resource group using an identity with permission to write + both connections. +- An existing, reachable APIM gateway with a model API accepting the project's + identity token and the subscription-key header. Its Entra validation policy + must accept the supplied audience and project identity. +- Existing backend deployment and gateway-to-backend authentication/RBAC. + This template does **not** create gateway policies, model deployments, role + assignments, or network infrastructure. +- Secret-header support enabled in the consuming Foundry environment. Creating + these ARM resources alone does not enable runtime or flighted UI support + (`admin_connected_secret_headers`). +- Azure CLI and Bicep, and API `2026-07-01` availability in the target environment. + +Bicep 0.41.2 reports `BCP081` because its bundled type definitions do not include +the `2026-07-01` connection API. Compilation still succeeds, but cannot validate +that API's resource properties. This version matches the exported connection +example; deployment and runtime verification are still required. + +The endpoint in the parameter file is an AzureCloud placeholder. Supply the +actual gateway URL and audience for your environment; no cloud host is inferred. +The gateway application audience is independent of the subscription key and is +not necessarily the Cognitive Services audience. + +## Deploy + +Copy `samples/parameters.json` to an untracked local file and replace the +placeholders. Keep `subscriptionKey` as an ARM Key Vault reference rather than +placing the key in a committed file or command line. The vault must allow ARM +template deployment (`enabledForTemplateDeployment`), and the deploying identity +must have `Microsoft.KeyVault/vaults/deploy/action` on it. This template does not +change vault settings or permissions. + +```bash +az login +az account set --subscription "" +az bicep build --file main.bicep --outfile azuredeploy.json + +az deployment group create \ + --resource-group "" \ + --name apim-secret-headers \ + --template-file main.bicep \ + --parameters @parameters.local.json +``` + +`subscriptionKey` is a required `@secure()` parameter with no default. It flows +only into the companion's `credentials.keys.header0`; it is not emitted in +outputs or model metadata. Do not print connection secrets, enable verbose +payload logging, or commit populated parameter files. Normal ARM exports/GETs +can omit credentials: an exported companion without `credentials.keys` is not +a complete redeployable secret configuration. + +The sample uses a static model list. Set `modelDeploymentName` to the gateway's +deployment name and `modelName`/`modelVersion` to its actual catalog metadata. +The endpoint is deployment-path based (`deploymentInPath: 'true'`), matching +the APIM example; it is not the `/openai/v1` body-model configuration used by +the direct Foundry sample. + +## Verify and use + +Verify that both connections exist, have matching targets and project scope, +and that the parent references the companion's exact name and `header0`. +Do not retrieve the secret just to verify the relationship. + +Use the existing prompt-agent Responses script with your project endpoint: + +```bash +python ../public-byom-apim/samples/create-agent.py \ + --endpoint "" \ + --model "apim-private-header/" \ + --agent-name apim-secret-header-sample-agent +``` + +See the [shared agent instructions](../public-byom-apim/samples/create-agent.py) +for Python dependencies. Invocation incurs model usage charges and creates +an agent version and conversation. A successful response verifies runtime +resolution; Bicep compilation or ARM deployment alone does not. + +If authentication fails, check **both** the gateway's Entra policy/audience and +its subscription-key requirement. A correct PMI token does not substitute for +a required key, and a correct key does not substitute for a valid identity. + +## Updates and cleanup + +The companion name is deterministic for the project and parent name. Redeploying +with a new key updates that companion in place, affecting the existing parent +immediately, even if a subsequent parent update fails. This minimal template +does not implement the UX's fresh-companion replacement behavior. For isolated +testing or replacement, deploy with a new parent connection name, verify it, +and retire the old pair only after checking consumers. + +Delete the model connection before its companion, and only after ensuring no +other connection references it. Outputs include both resource IDs for scoped +cleanup, never the key. Separately remove any test agents/conversations you +created. Do not delete the existing project, gateway, Key Vault secret, or +resource group. Deleting a deployment record does not delete its resources. diff --git a/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/azuredeploy.json b/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/azuredeploy.json new file mode 100644 index 000000000..d6d5787af --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/azuredeploy.json @@ -0,0 +1,148 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.41.2.15936", + "templateHash": "1943399015281075095" + } + }, + "parameters": { + "accountName": { + "type": "string", + "metadata": { + "description": "Existing Foundry account in THIS deployment resource group." + } + }, + "projectName": { + "type": "string", + "metadata": { + "description": "Existing project under accountName. The project must have a managed identity." + } + }, + "targetUrl": { + "type": "string", + "metadata": { + "description": "Existing APIM model API base URL, without a trailing slash. Use the actual endpoint for your cloud." + } + }, + "audience": { + "type": "string", + "metadata": { + "description": "Entra token audience accepted by the gateway, for example its api:// application ID URI. This is separate from the subscription key." + } + }, + "modelDeploymentName": { + "type": "string", + "metadata": { + "description": "Existing deployment name exposed by the gateway." + } + }, + "modelName": { + "type": "string", + "metadata": { + "description": "Underlying model name, which may differ from the deployment name." + } + }, + "modelVersion": { + "type": "string", + "metadata": { + "description": "Underlying model version exposed by the gateway." + } + }, + "connectionName": { + "type": "string", + "defaultValue": "apim-private-header", + "metadata": { + "description": "Model connection name. Agents reference /." + } + }, + "headerName": { + "type": "string", + "defaultValue": "Ocp-Apim-Subscription-Key", + "metadata": { + "description": "Header whose value is stored as a secret in the companion connection." + } + }, + "subscriptionKey": { + "type": "securestring", + "minLength": 1, + "metadata": { + "description": "Gateway subscription key. Supply securely, preferably through an ARM Key Vault parameter reference. Never place the value in metadata or outputs." + } + }, + "inferenceAPIVersion": { + "type": "string", + "defaultValue": "2025-04-01-preview", + "metadata": { + "description": "Inference API version forwarded through the gateway." + } + } + }, + "variables": { + "companionName": "[format('secret-headers-{0}', uniqueString(resourceId('Microsoft.CognitiveServices/accounts/projects', parameters('accountName'), parameters('projectName')), parameters('connectionName')))]" + }, + "resources": [ + { + "type": "Microsoft.CognitiveServices/accounts/projects/connections", + "apiVersion": "2026-07-01", + "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), variables('companionName'))]", + "properties": { + "category": "CustomKeys", + "authType": "CustomKeys", + "target": "[parameters('targetUrl')]", + "isSharedToAll": false, + "credentials": { + "keys": { + "header0": "[parameters('subscriptionKey')]" + } + }, + "metadata": { + "secretHeadersFor": "[parameters('connectionName')]" + } + } + }, + { + "type": "Microsoft.CognitiveServices/accounts/projects/connections", + "apiVersion": "2026-07-01", + "name": "[format('{0}/{1}/{2}', parameters('accountName'), parameters('projectName'), parameters('connectionName'))]", + "properties": { + "category": "ApiManagement", + "authType": "ProjectManagedIdentity", + "audience": "[parameters('audience')]", + "target": "[parameters('targetUrl')]", + "isSharedToAll": false, + "credentials": {}, + "metadata": { + "deploymentInPath": "true", + "inferenceAPIVersion": "[parameters('inferenceAPIVersion')]", + "customHeaders": "{}", + "models": "[string(createArray(createObject('name', parameters('modelDeploymentName'), 'properties', createObject('model', createObject('name', parameters('modelName'), 'version', parameters('modelVersion'), 'format', 'OpenAI')))))]", + "secretHeaders": "[string(createObject(format('{0}', parameters('headerName')), createObject('connection', variables('companionName'), 'key', 'header0')))]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts/projects/connections', parameters('accountName'), parameters('projectName'), variables('companionName'))]" + ] + } + ], + "outputs": { + "connectionId": { + "type": "string", + "value": "[resourceId('Microsoft.CognitiveServices/accounts/projects/connections', parameters('accountName'), parameters('projectName'), parameters('connectionName'))]" + }, + "connectionName": { + "type": "string", + "value": "[parameters('connectionName')]" + }, + "secretHeaderConnectionId": { + "type": "string", + "value": "[resourceId('Microsoft.CognitiveServices/accounts/projects/connections', parameters('accountName'), parameters('projectName'), variables('companionName'))]" + }, + "modelReference": { + "type": "string", + "value": "[format('{0}/{1}', parameters('connectionName'), parameters('modelDeploymentName'))]" + } + } +} \ No newline at end of file diff --git a/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/main.bicep b/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/main.bicep new file mode 100644 index 000000000..d3cd091a6 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/main.bicep @@ -0,0 +1,107 @@ +targetScope = 'resourceGroup' + +@description('Existing Foundry account in THIS deployment resource group.') +param accountName string + +@description('Existing project under accountName. The project must have a managed identity.') +param projectName string + +@description('Existing APIM model API base URL, without a trailing slash. Use the actual endpoint for your cloud.') +param targetUrl string + +@description('Entra token audience accepted by the gateway, for example its api:// application ID URI. This is separate from the subscription key.') +param audience string + +@description('Existing deployment name exposed by the gateway.') +param modelDeploymentName string + +@description('Underlying model name, which may differ from the deployment name.') +param modelName string + +@description('Underlying model version exposed by the gateway.') +param modelVersion string + +@description('Model connection name. Agents reference /.') +param connectionName string = 'apim-private-header' + +@description('Header whose value is stored as a secret in the companion connection.') +param headerName string = 'Ocp-Apim-Subscription-Key' + +@secure() +@minLength(1) +@description('Gateway subscription key. Supply securely, preferably through an ARM Key Vault parameter reference. Never place the value in metadata or outputs.') +param subscriptionKey string + +@description('Inference API version forwarded through the gateway.') +param inferenceAPIVersion string = '2025-04-01-preview' + +resource account 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: accountName +} + +resource project 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' existing = { + parent: account + name: projectName +} + +var companionName = 'secret-headers-${uniqueString(project.id, connectionName)}' + +resource secretHeaderConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2026-07-01' = { + parent: project + name: companionName + properties: { + category: 'CustomKeys' + authType: 'CustomKeys' + target: targetUrl + isSharedToAll: false + credentials: { + keys: { + header0: subscriptionKey + } + } + metadata: { + secretHeadersFor: connectionName + } + } +} + +resource modelConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2026-07-01' = { + parent: project + name: connectionName + properties: { + category: 'ApiManagement' + authType: 'ProjectManagedIdentity' + audience: audience + target: targetUrl + isSharedToAll: false + credentials: {} + metadata: { + deploymentInPath: 'true' + inferenceAPIVersion: inferenceAPIVersion + customHeaders: '{}' + models: string([ + { + name: modelDeploymentName + properties: { + model: { + name: modelName + version: modelVersion + format: 'OpenAI' + } + } + } + ]) + secretHeaders: string({ + '${headerName}': { + connection: secretHeaderConnection.name + key: 'header0' + } + }) + } + } +} + +output connectionId string = modelConnection.id +output connectionName string = modelConnection.name +output secretHeaderConnectionId string = secretHeaderConnection.id +output modelReference string = '${connectionName}/${modelDeploymentName}' diff --git a/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/metadata.json b/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/metadata.json new file mode 100644 index 000000000..6813321f4 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/metadata.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://aka.ms/azure-quickstart-templates-metadata-schema#", + "type": "QuickStart", + "itemDisplayName": "APIM model connection with project identity and a secret header", + "description": "Creates an ApiManagement model connection authenticated with project managed identity and a CustomKeys companion holding an APIM subscription key. The parent stores a secretHeaders reference, never the header value.", + "summary": "Combine project managed identity with a secret APIM subscription-key header, supplied as a secure Bicep parameter.", + "githubUsername": "mukeshagmsft", + "dateUpdated": "2026-11-05", + "environments": [ + "AzureCloud" + ] +} diff --git a/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/samples/parameters.json b/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/samples/parameters.json new file mode 100644 index 000000000..0fc699ada --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/samples/parameters.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "accountName": { "value": "" }, + "projectName": { "value": "" }, + "targetUrl": { + "value": "https://.azure-api.net/v1/openai-gpt-4-1-mini/openai" + }, + "audience": { "value": "api://" }, + "modelDeploymentName": { "value": "gpt-4.1-mini" }, + "modelName": { "value": "gpt-4.1-mini" }, + "modelVersion": { "value": "2025-04-14" }, + "connectionName": { "value": "apim-private-header" }, + "subscriptionKey": { + "reference": { + "keyVault": { + "id": "/subscriptions//resourceGroups//providers/Microsoft.KeyVault/vaults/" + }, + "secretName": "" + } + } + } +} From 13bfbafbb60f8453be576d032b0cf7d6e2130f29 Mon Sep 17 00:00:00 2001 From: foundry-samples automation Date: Thu, 5 Nov 2026 19:06:59 +0000 Subject: [PATCH 4/5] Automatic fixes (cherry picked from commit bf9f539c1743c3bd7292d4985e5e42ba73e6cd11) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../01-connections/apim-secret-headers/azuredeploy.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/azuredeploy.json b/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/azuredeploy.json index d6d5787af..e8844a649 100644 --- a/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/azuredeploy.json +++ b/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/azuredeploy.json @@ -4,8 +4,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.41.2.15936", - "templateHash": "1943399015281075095" + "version": "0.48.1.52935", + "templateHash": "9763819720263725144" } }, "parameters": { From 309796180e2d05b88bf6d9c19e0e974fe8e2de0a Mon Sep 17 00:00:00 2001 From: Mukesh Agarwal Date: Mon, 9 Nov 2026 19:47:42 +0000 Subject: [PATCH 5/5] fix(infrastructure): provision disposable prerequisites for connection sample CI Select self-contained fixtures through main.bicepparam instead of deploying layer-on-existing samples into empty resource groups. Exercise the real connection modules and project identity role assignment, document ARM-only coverage, and cover fixture selection with a pipeline regression test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 40baedfa-7ef3-4419-9273-0bc015109527 --- .../scripts/bicep/test_bicep_pipeline.py | 23 +++++++ .../apim-secret-headers/README.md | 10 +++ .../apim-secret-headers/main.bicepparam | 4 ++ .../apim-secret-headers/tests/deploy.bicep | 51 ++++++++++++++++ .../foundry-model-connection/README.md | 10 +++ .../foundry-model-connection/main.bicepparam | 4 ++ .../tests/deploy.bicep | 61 +++++++++++++++++++ 7 files changed, 163 insertions(+) create mode 100644 infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/main.bicepparam create mode 100644 infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/tests/deploy.bicep create mode 100644 infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/main.bicepparam create mode 100644 infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/tests/deploy.bicep diff --git a/.azure-pipelines/scripts/bicep/test_bicep_pipeline.py b/.azure-pipelines/scripts/bicep/test_bicep_pipeline.py index 971553d36..0072b6fd7 100644 --- a/.azure-pipelines/scripts/bicep/test_bicep_pipeline.py +++ b/.azure-pipelines/scripts/bicep/test_bicep_pipeline.py @@ -101,6 +101,29 @@ def git(*args): self.assertIn(f"bicep build --file {SAMPLE_ROOT}/{expected}/main.bicep", calls) (repo / "calls.txt").unlink() + def test_connection_samples_select_self_contained_fixtures(self): + script = self.pipeline["stages"][2]["jobs"][0]["steps"][1]["inputs"]["inlineScript"] + start = script.index('if [ -n "${{ parameters.samplePath }}" ]; then') + selection = script[start:script.index("deploy_with_location() {")] + selection = selection.replace("${{ parameters.samplePath }}", "") + selection = selection.replace("${{ parameters.validateAll }}", "false") + selection = selection.replace("$(BICEP_ROOT)", SAMPLE_ROOT) + for sample in ("apim-secret-headers", "foundry-model-connection"): + directory = f"{SAMPLE_ROOT}/01-connections/{sample}" + with self.subTest(sample=sample): + result = subprocess.run( + [ + "bash", "-c", + "set -euo pipefail\nbase_ref=origin/main\n" + f"git() {{ printf '%s\\n' '{directory}/main.bicep' '{directory}/tests/deploy.bicep'; }}\n" + + selection + + '\nprintf "%s\\n" "${deployment_files[@]}"\n', + ], + cwd=ROOT, capture_output=True, text=True, + ) + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertEqual(f"{directory}/main.bicepparam\n", result.stdout) + def test_manual_sample_paths_are_normalized_and_contained(self): bash = shutil.which("bash") self.assertIsNotNone(bash, "Bash is required for pipeline selection checks") diff --git a/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/README.md b/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/README.md index 894e636e4..6fa3245d3 100644 --- a/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/README.md +++ b/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/README.md @@ -105,6 +105,16 @@ If authentication fails, check **both** the gateway's Entra policy/audience and its subscription-key requirement. A correct PMI token does not substitute for a required key, and a correct key does not substitute for a valid identity. +## CI deployment fixture + +The root `main.bicepparam` selects `tests/deploy.bicep` for the repository's +ephemeral deployment check. It creates a disposable account and project, then +deploys the **actual** `main.bicep` with a non-secret fixture key and a reserved +`.invalid` gateway hostname. It verifies ARM creation of the two connections, +not APIM authentication or inference; no real gateway or model is deployed. +The pipeline owns resource-group cleanup. Do not use this parameter file for +an existing project: follow the deployment instructions above instead. + ## Updates and cleanup The companion name is deterministic for the project and parent name. Redeploying diff --git a/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/main.bicepparam b/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/main.bicepparam new file mode 100644 index 000000000..f05a34a5b --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/main.bicepparam @@ -0,0 +1,4 @@ +// CI entry point only. For an existing project, deploy main.bicep with samples/parameters.json. +using './tests/deploy.bicep' + +param location = 'eastus2' diff --git a/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/tests/deploy.bicep b/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/tests/deploy.bicep new file mode 100644 index 000000000..6a4b2cef8 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/01-connections/apim-secret-headers/tests/deploy.bicep @@ -0,0 +1,51 @@ +targetScope = 'resourceGroup' + +param location string = resourceGroup().location +param accountName string = 'header-ci-${uniqueString(resourceGroup().id)}' +param modelName string = 'gpt-4.1-mini' +param modelVersion string = '2025-04-14' + +resource account 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' = { + name: accountName + location: location + kind: 'AIServices' + sku: { + name: 'S0' + } + identity: { + type: 'SystemAssigned' + } + properties: { + allowProjectManagement: true + customSubDomainName: accountName + disableLocalAuth: true + } +} + +resource project 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' = { + parent: account + name: 'connection-test' + location: location + identity: { + type: 'SystemAssigned' + } + properties: {} +} + +// ARM persistence smoke test, not a live gateway or model inference test. +module sample '../main.bicep' = { + name: 'secret-header-sample' + params: { + accountName: account.name + projectName: project.name + targetUrl: 'https://gateway.example.invalid/openai' + audience: 'api://connection-fixture' + modelDeploymentName: modelName + modelName: modelName + modelVersion: modelVersion + subscriptionKey: 'fixture-only-not-a-real-subscription-key' + } +} + +output connectionId string = sample.outputs.connectionId +output secretHeaderConnectionId string = sample.outputs.secretHeaderConnectionId diff --git a/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/README.md b/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/README.md index c8b69e918..7632b1a8c 100644 --- a/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/README.md +++ b/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/README.md @@ -137,6 +137,16 @@ Agent calls consume model quota and can incur charges. - A discovery timeout is separate from authentication. This template does not fix or extend the model gateway's discovery timeout. +## CI deployment fixture + +The root `main.bicepparam` selects `tests/deploy.bicep` for the repository's +ephemeral deployment check. It provisions disposable source/target accounts +and a source project, then invokes the **actual** `main.bicep`, including its +project-identity role assignment. No model deployment or inference quota is +required: the fixture verifies ARM resources and RBAC, not inference or model +discovery. The pipeline owns resource-group cleanup. For an existing project, +deploy `main.bicep` with your own parameters as described above, not the fixture. + ## Cleanup Delete the sample agent versions, agent, and conversation created by the script diff --git a/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/main.bicepparam b/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/main.bicepparam new file mode 100644 index 000000000..888545d5d --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/main.bicepparam @@ -0,0 +1,4 @@ +// CI entry point only. For existing accounts, deploy main.bicep with samples/parameters.json. +using './tests/deploy.bicep' + +param location = 'eastus2' diff --git a/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/tests/deploy.bicep b/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/tests/deploy.bicep new file mode 100644 index 000000000..364eec7e5 --- /dev/null +++ b/infrastructure/infrastructure-setup-bicep/01-connections/foundry-model-connection/tests/deploy.bicep @@ -0,0 +1,61 @@ +targetScope = 'resourceGroup' + +param location string = resourceGroup().location +param accountName string = 'model-ci-${uniqueString(resourceGroup().id)}' + +resource account 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' = { + name: accountName + location: location + kind: 'AIServices' + sku: { + name: 'S0' + } + identity: { + type: 'SystemAssigned' + } + properties: { + allowProjectManagement: true + customSubDomainName: accountName + disableLocalAuth: true + } +} + +resource project 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' = { + parent: account + name: 'connection-test' + location: location + identity: { + type: 'SystemAssigned' + } + properties: {} +} + +resource targetAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' = { + name: '${accountName}-target' + location: location + kind: 'AIServices' + sku: { + name: 'S0' + } + properties: { + allowProjectManagement: true + customSubDomainName: '${accountName}-target' + disableLocalAuth: true + } +} + +// Exercise the real connection and project-identity role assignment, without model quota. +module sample '../main.bicep' = { + name: 'foundry-model-sample' + params: { + accountName: account.name + projectName: project.name + targetAccountResourceId: targetAccount.id + targetOpenAIBaseUrl: 'https://${targetAccount.name}.services.ai.azure.com/openai/v1' + modelDeploymentName: 'fixture-model-not-deployed' + assignBackendRole: true + } +} + +output connectionId string = sample.outputs.connectionId +output roleAssignmentId string = sample.outputs.roleAssignmentId