diff --git a/.gitignore b/.gitignore index 4de9ba1a81..25a77be32a 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ dab-config*.json !dab-config.*reference.json !dab-config.*.example.json !/dab-config.json +!samples/azure/container-apps-entra-mcp/dab-config.template.json *.cd @@ -36,6 +37,7 @@ dab-config*.json # Local-Only files .env +samples/azure/container-apps-entra-mcp/deployment.outputs.json # Verify test files -*.received.* \ No newline at end of file +*.received.* diff --git a/samples/azure/container-apps-entra-mcp/Dockerfile b/samples/azure/container-apps-entra-mcp/Dockerfile new file mode 100644 index 0000000000..f5ed0ce8db --- /dev/null +++ b/samples/azure/container-apps-entra-mcp/Dockerfile @@ -0,0 +1,15 @@ +# Version values referenced from https://hub.docker.com/_/microsoft-dotnet-aspnet + +FROM mcr.microsoft.com/dotnet/sdk:8.0-cbl-mariner2.0 AS build + +WORKDIR /src +COPY [".", "./"] +RUN dotnet build "./src/Service/Azure.DataApiBuilder.Service.csproj" -c Docker -o /out -r linux-x64 + +FROM mcr.microsoft.com/dotnet/aspnet:8.0-cbl-mariner2.0 AS runtime + +COPY --from=build /out /App +COPY samples/azure/container-apps-entra-mcp/dab-config.generated.json /App/dab-config.json +WORKDIR /App +ENV ASPNETCORE_URLS=http://+:5000 +ENTRYPOINT ["dotnet", "Azure.DataApiBuilder.Service.dll"] diff --git a/samples/azure/container-apps-entra-mcp/README.md b/samples/azure/container-apps-entra-mcp/README.md new file mode 100644 index 0000000000..88e99acecd --- /dev/null +++ b/samples/azure/container-apps-entra-mcp/README.md @@ -0,0 +1,199 @@ +# Data API Builder on Azure Container Apps with Entra Auth, MCP, Key Vault, and Azure SQL OBO + +This sample deploys Data API builder (DAB) to Azure Container Apps (ACA) for the customer scenario where a chat agent calls the DAB MCP endpoint, DAB validates Microsoft Entra ID tokens, and DAB connects to Azure SQL using user-delegated authentication. + +The deployment intentionally keeps Azure resources in one resource group so you can show the full flow to a customer without hunting through the portal. + +## What This Creates + +The script creates: + +- Resource group +- Azure Container Registry +- Azure Container Apps environment +- Azure Container App running DAB +- User-assigned managed identity for the Container App +- Azure Key Vault +- Azure SQL logical server and database +- Log Analytics workspace +- Microsoft Entra app registration for DAB JWT validation and OBO +- Sample `dbo.Todos` table + +The Microsoft Entra app registration is not an Azure resource group resource because app registrations live at tenant scope. It is named with the same suffix as the resource group resources. + +## Flow + +1. A user or agent client gets a Microsoft Entra access token for the DAB API audience. +2. The client calls DAB REST, GraphQL, or MCP with `Authorization: Bearer `. +3. DAB validates the JWT issuer and audience from `runtime.host.authentication`. +4. DAB resolves the request role to `authenticated`. +5. DAB uses OBO to exchange the incoming user token for an Azure SQL token. +6. DAB connects to Azure SQL as the delegated user and runs the generated SQL operation. +7. The DAB Container App uses its managed identity for startup metadata access and Key Vault reads. + +## Why This Diff Exists + +The repo already contains the DAB service, MCP runtime, and OBO code. The useful change here is a deployable ACA sample: + +- `dab-config.template.json` mirrors the customer scenario with Key Vault, Entra JWT auth, MCP, and `user-delegated-auth`. +- `deploy.ps1` provisions the end-to-end Azure environment. +- `Dockerfile` builds DAB from this repo and copies the generated deployment config into `/App/dab-config.json`. +- `schema.sql` creates a tiny table so REST, GraphQL, and MCP have something real to expose. +- `.gitignore` excludes generated deployment outputs. + +## Important Config Corrections + +The customer-provided JSON is close, but two details matter for the current DAB schema: + +- JWT authentication belongs under `runtime.host.authentication`, not directly under `runtime.authentication`. +- The runtime authentication provider value is `EntraID`. The data-source OBO provider remains `EntraId`. + +The test command requests the Microsoft Entra v2 scope `api:///access_as_user`. The resulting access token has the app client ID GUID as its `aud` claim, so this sample sets the DAB JWT audience to that GUID. The rule is simple: DAB's configured audience must match the token's `aud` claim. + +## Deploy + +From the repo root: + +```powershell +.\samples\azure\container-apps-entra-mcp\deploy.ps1 ` + -ResourceGroup rg-dab-aca-mcp-auth-demo ` + -Location westus3 +``` + +The script writes the real endpoints and IDs to: + +```text +samples/azure/container-apps-entra-mcp/deployment.outputs.json +``` + +It also writes a generated config file used by the image build: + +```text +samples/azure/container-apps-entra-mcp/dab-config.generated.json +``` + +That generated file is intentionally ignored by Git. + +## Test REST + +After deployment, open `deployment.outputs.json` and run the saved `tokenCommand`, or use this shape: + +```powershell +$token = az account get-access-token ` + --tenant ` + --scope "api:///access_as_user" ` + --query accessToken -o tsv + +Invoke-RestMethod ` + -Method Get ` + -Uri "https:///api/dbo_Todos?`$first=5" ` + -Headers @{ Authorization = "Bearer $token" } +``` + +Expected result: rows from `dbo.Todos`. + +## Test GraphQL + +```powershell +$body = @{ + query = "query { dbo_Todos(first: 5) { items { Id Title IsComplete CreatedAtUtc } } }" +} | ConvertTo-Json + +Invoke-RestMethod ` + -Method Post ` + -Uri "https:///graphql" ` + -ContentType "application/json" ` + -Headers @{ Authorization = "Bearer $token" } ` + -Body $body +``` + +## Test MCP + +Use the MCP inspector: + +```powershell +npx @modelcontextprotocol/inspector +``` + +In the inspector: + +- Transport: `Streamable HTTP` +- URL: `https:///mcp` +- Header: `Authorization: Bearer ` + +The autoentity configuration enables MCP DML tools for `dbo.Todos`. + +## How Authentication Is Split + +There are two separate authentication jobs: + +- DAB API authentication validates the token sent by the agent or client. That is configured in `runtime.host.authentication.jwt`. +- SQL user-delegated authentication exchanges that same user token for an Azure SQL token. That is configured in `data-source.user-delegated-auth` and the `DAB_OBO_*` environment variables. + +The Container App managed identity is a third identity. It is used for platform operations: + +- Pull the image from ACR. +- Read Key Vault secrets. +- Let DAB read SQL metadata at startup before there is any user request. + +## SQL Users + +The script creates contained Azure SQL users for: + +- The Container App user-assigned managed identity. +- The signed-in Azure CLI user running the deployment. + +For the user-assigned managed identity, Azure SQL maps the login to the managed identity client/application ID in SQL GUID byte order. The script handles that when creating the startup metadata user with `WITH SID = ..., TYPE = E`. + +That second user is what makes the local validation token work. In a customer tenant, create SQL users or group-based grants for the actual people or agents that will call DAB. + +## OBO Consent + +The DAB app registration needs delegated Azure SQL permission: + +```powershell +az ad app permission add ` + --id ` + --api 022907d3-0f1b-48f7-badc-1ba6abab6d66 ` + --api-permissions c39ef2d1-04ce-46dc-8b5f-e9a5c60f0fc9=Scope + +az ad app permission grant ` + --id ` + --api 022907d3-0f1b-48f7-badc-1ba6abab6d66 ` + --scope user_impersonation +``` + +Without this, DAB can validate the incoming JWT but OBO fails with `AADSTS65001`. + +## Common Customer Failure Points + +- The token audience does not match `runtime.host.authentication.jwt.audience`. +- The issuer is v1 but config expects v2, or the reverse. +- The auth block is under `runtime.authentication` instead of `runtime.host.authentication`. +- The app registration does not have delegated Azure SQL permission with admin consent. +- The incoming token is app-only instead of user-delegated. OBO needs a user assertion. +- The database does not contain a user or group matching the delegated user token. +- The Container App identity cannot read Key Vault or cannot connect for startup metadata. +- MCP clients forget to send the `Authorization` header to `/mcp`. + +## Cleanup + +Delete the Azure resources: + +```powershell +az group delete --name rg-dab-aca-mcp-auth-demo --yes +``` + +Delete the app registration separately because it is tenant-scoped: + +```powershell +az ad app list --display-name "dab-aca-mcp-demo-" --query "[].appId" -o tsv +az ad app delete --id +``` + +## Useful References + +- DAB configuration schema: `schemas/dab.draft.schema.json` +- DAB MCP testing guide: `docs/testing-guide/mcp-inspector-testing.md` +- Azure SQL managed identity and Microsoft Entra users: https://learn.microsoft.com/azure/azure-sql/database/authentication-azure-ad-user-assigned-managed-identity +- `CREATE USER` for Microsoft Entra principals: https://learn.microsoft.com/sql/t-sql/statements/create-user-transact-sql diff --git a/samples/azure/container-apps-entra-mcp/dab-config.template.json b/samples/azure/container-apps-entra-mcp/dab-config.template.json new file mode 100644 index 0000000000..f9e3eeaef6 --- /dev/null +++ b/samples/azure/container-apps-entra-mcp/dab-config.template.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://github.com/Azure/data-api-builder/releases/latest/download/dab.draft.schema.json", + "data-source": { + "database-type": "mssql", + "connection-string": "@akv('sql-connection-string')", + "options": { + "set-session-context": false + }, + "user-delegated-auth": { + "enabled": true, + "provider": "EntraId", + "database-audience": "https://database.windows.net" + } + }, + "runtime": { + "rest": { + "enabled": true, + "path": "/api", + "request-body-strict": true + }, + "graphql": { + "enabled": true, + "path": "/graphql", + "allow-introspection": true + }, + "mcp": { + "enabled": true, + "path": "/mcp" + }, + "host": { + "mode": "Production", + "cors": { + "origins": [ + "*" + ], + "allow-credentials": false + }, + "authentication": { + "provider": "EntraID", + "jwt": { + "audience": "__DAB_API_AUDIENCE__", + "issuer": "https://login.microsoftonline.com/__TENANT_ID__/v2.0" + } + } + }, + "cache": { + "enabled": false + } + }, + "entities": {}, + "autoentities": { + "dbo-only": { + "patterns": { + "include": [ + "dbo.%" + ], + "exclude": [], + "name": "{schema}_{object}" + }, + "template": { + "rest": { + "enabled": true + }, + "graphql": { + "enabled": true + }, + "mcp": { + "dml-tools": true + }, + "health": { + "enabled": true + }, + "cache": { + "enabled": false + } + }, + "permissions": [ + { + "role": "authenticated", + "actions": [ + { + "action": "*" + } + ] + } + ] + } + }, + "azure-key-vault": { + "endpoint": "__KEY_VAULT_ENDPOINT__", + "retry-policy": { + "mode": "exponential", + "max-count": 3, + "delay-seconds": 2, + "max-delay-seconds": 10 + } + } +} diff --git a/samples/azure/container-apps-entra-mcp/deploy.ps1 b/samples/azure/container-apps-entra-mcp/deploy.ps1 new file mode 100644 index 0000000000..779374b7ea --- /dev/null +++ b/samples/azure/container-apps-entra-mcp/deploy.ps1 @@ -0,0 +1,346 @@ +param( + [string] $ResourceGroup = "rg-dab-aca-mcp-auth-demo", + [string] $Location = "westus3", + [string] $Suffix, + [string] $SqlAdminLogin = "sqladminuser", + [string] $DatabaseName = "dabdemo" +) + +$ErrorActionPreference = "Stop" + +function New-LowerSuffix { + -join ((48..57) + (97..122) | Get-Random -Count 6 | ForEach-Object { [char] $_ }) +} + +function New-SqlPassword { + "Dab!" + [Guid]::NewGuid().ToString("N") + "9" +} + +function Convert-GuidToSqlSidHex { + param([string] $Guid) + $bytes = ([Guid] $Guid).ToByteArray() + "0x" + (($bytes | ForEach-Object { $_.ToString("X2") }) -join "") +} + +function Escape-SqlIdentifier { + param([string] $Value) + $Value.Replace("]", "]]") +} + +function Escape-SqlLiteral { + param([string] $Value) + $Value.Replace("'", "''") +} + +function Invoke-AzCli { + param([scriptblock] $Command) + & $Command + if ($LASTEXITCODE -ne 0) { + throw "Azure CLI command failed: $Command" + } +} + +if ([string]::IsNullOrWhiteSpace($Suffix)) { + $Suffix = New-LowerSuffix +} + +$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..\..") +$TemplatePath = Join-Path $PSScriptRoot "dab-config.template.json" +$GeneratedConfigPath = Join-Path $PSScriptRoot "dab-config.generated.json" +$SchemaPath = Join-Path $PSScriptRoot "schema.sql" +$OutputsPath = Join-Path $PSScriptRoot "deployment.outputs.json" + +$suffixCompact = ($Suffix -replace "[^a-z0-9]", "").ToLowerInvariant() +if ($suffixCompact.Length -lt 4) { + throw "Suffix must contain at least four lowercase letters or numbers." +} + +$acrName = "acrdabmcp$suffixCompact" +$keyVaultName = "kv-dabmcp-$suffixCompact" +$sqlServerName = "sql-dabmcp-$suffixCompact" +$identityName = "id-dabmcp-$suffixCompact" +$logAnalyticsName = "log-dabmcp-$suffixCompact" +$containerEnvName = "cae-dabmcp-$suffixCompact" +$containerAppName = "ca-dabmcp-$suffixCompact" +$appDisplayName = "dab-aca-mcp-demo-$suffixCompact" +$imageName = "dab-aca-mcp-demo:$suffixCompact" +$sqlAdminPassword = New-SqlPassword +$azureSqlAppId = "022907d3-0f1b-48f7-badc-1ba6abab6d66" +$azureSqlUserImpersonationScopeId = "c39ef2d1-04ce-46dc-8b5f-e9a5c60f0fc9" +$azureCliClientId = "04b07795-8ddb-461a-bbee-02f9e1bf7b46" + +Write-Host "Using suffix '$suffixCompact' in resource group '$ResourceGroup' ($Location)." + +$account = az account show -o json | ConvertFrom-Json +if (-not $account) { + throw "Azure CLI is not logged in. Run 'az login' and rerun this script." +} + +$tenantId = $account.tenantId +$subscriptionId = $account.id +$signedInUser = az ad signed-in-user show --query "{id:id,userPrincipalName:userPrincipalName,displayName:displayName}" -o json | ConvertFrom-Json +if (-not $signedInUser.id) { + throw "Could not resolve the signed-in Microsoft Entra user." +} + +Write-Host "Creating Microsoft Entra application for DAB JWT validation and OBO..." +$app = az ad app create --display-name $appDisplayName --sign-in-audience AzureADMyOrg -o json | ConvertFrom-Json +$apiClientId = $app.appId +$apiIdentifierUri = "api://$apiClientId" +$apiAudience = $apiClientId +$apiScope = "$apiIdentifierUri/access_as_user" +$scopeId = [Guid]::NewGuid().ToString() + +Invoke-AzCli { az ad app update --id $apiClientId --identifier-uris $apiIdentifierUri --requested-access-token-version 2 } + +$apiScopePatch = @{ + api = @{ + requestedAccessTokenVersion = 2 + oauth2PermissionScopes = @( + @{ + adminConsentDescription = "Allow signed-in users to call the DAB MCP demo API." + adminConsentDisplayName = "Access DAB MCP demo" + id = $scopeId + isEnabled = $true + type = "User" + userConsentDescription = "Allow this client to call the DAB MCP demo API on your behalf." + userConsentDisplayName = "Access DAB MCP demo" + value = "access_as_user" + } + ) + } +} | ConvertTo-Json -Depth 10 + +$apiScopePatchFile = New-TemporaryFile +$apiScopePatch | Set-Content -Path $apiScopePatchFile.FullName -Encoding utf8 +Invoke-AzCli { az rest --method PATCH --uri "https://graph.microsoft.com/v1.0/applications/$($app.id)" --headers "Content-Type=application/json" --body "@$($apiScopePatchFile.FullName)" } +Remove-Item -LiteralPath $apiScopePatchFile.FullName -Force + +$apiPreAuthPatch = @{ + api = @{ + preAuthorizedApplications = @( + @{ + appId = $azureCliClientId + delegatedPermissionIds = @($scopeId) + } + ) + } +} | ConvertTo-Json -Depth 10 + +$apiPreAuthPatchFile = New-TemporaryFile +$apiPreAuthPatch | Set-Content -Path $apiPreAuthPatchFile.FullName -Encoding utf8 +Invoke-AzCli { az rest --method PATCH --uri "https://graph.microsoft.com/v1.0/applications/$($app.id)" --headers "Content-Type=application/json" --body "@$($apiPreAuthPatchFile.FullName)" } +Remove-Item -LiteralPath $apiPreAuthPatchFile.FullName -Force + +Invoke-AzCli { az ad sp create --id $apiClientId | Out-Null } +Invoke-AzCli { az ad app permission add --id $apiClientId --api $azureSqlAppId --api-permissions "$azureSqlUserImpersonationScopeId=Scope" | Out-Null } + +try { + Invoke-AzCli { az ad app permission grant --id $apiClientId --api $azureSqlAppId --scope user_impersonation | Out-Null } + $adminConsentGranted = $true + try { + Invoke-AzCli { az ad app permission admin-consent --id $apiClientId | Out-Null } + } + catch { + Write-Warning "Delegated Azure SQL permission grant was created, but admin-consent returned a non-fatal warning: $($_.Exception.Message)" + } +} +catch { + $adminConsentGranted = $false + Write-Warning "Admin consent for Azure SQL delegated access was not granted automatically. OBO requests will fail until an admin grants consent to '$appDisplayName'." +} + +$credential = az ad app credential reset --id $apiClientId --append --display-name "dab-obo-client-secret" --years 1 -o json | ConvertFrom-Json +$oboClientSecret = $credential.password + +Write-Host "Creating Azure resources..." +Invoke-AzCli { az group create --name $ResourceGroup --location $Location | Out-Null } +$identity = az identity create --name $identityName --resource-group $ResourceGroup --location $Location -o json | ConvertFrom-Json +$identityId = $identity.id +$identityClientId = $identity.clientId +$identityPrincipalId = $identity.principalId + +Invoke-AzCli { az acr create --name $acrName --resource-group $ResourceGroup --location $Location --sku Basic --admin-enabled false | Out-Null } +for ($i = 0; $i -lt 30; $i++) { + $acr = az acr show --name $acrName --resource-group $ResourceGroup -o json 2>$null | ConvertFrom-Json + if ($acr -and $acr.id) { + break + } + + Start-Sleep -Seconds 10 +} + +if (-not $acr -or -not $acr.id) { + throw "ACR '$acrName' was not queryable after creation." +} + +$acrLoginServer = $acr.loginServer +Invoke-AzCli { az role assignment create --assignee $identityPrincipalId --role AcrPull --scope $acr.id | Out-Null } + +Invoke-AzCli { az keyvault create --name $keyVaultName --resource-group $ResourceGroup --location $Location --enable-rbac-authorization false | Out-Null } +Invoke-AzCli { az keyvault set-policy --name $keyVaultName --resource-group $ResourceGroup --object-id $identityPrincipalId --secret-permissions get list | Out-Null } +Invoke-AzCli { az keyvault set-policy --name $keyVaultName --resource-group $ResourceGroup --object-id $signedInUser.id --secret-permissions get list set delete recover purge | Out-Null } + +$keyVaultUri = "https://$keyVaultName.vault.azure.net/" +$sqlConnectionString = "Server=tcp:$sqlServerName.database.windows.net,1433;Database=$DatabaseName;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" +Invoke-AzCli { az keyvault secret set --vault-name $keyVaultName --name "sql-connection-string" --value $sqlConnectionString | Out-Null } +Invoke-AzCli { az keyvault secret set --vault-name $keyVaultName --name "obo-client-secret" --value $oboClientSecret | Out-Null } +Invoke-AzCli { az keyvault secret set --vault-name $keyVaultName --name "sql-admin-password" --value $sqlAdminPassword | Out-Null } +$oboSecretId = az keyvault secret show --vault-name $keyVaultName --name "obo-client-secret" --query id -o tsv + +Write-Host "Creating Azure SQL server and database..." +Invoke-AzCli { az sql server create --name $sqlServerName --resource-group $ResourceGroup --location $Location --admin-user $SqlAdminLogin --admin-password $sqlAdminPassword | Out-Null } +Invoke-AzCli { az sql server ad-admin create --resource-group $ResourceGroup --server-name $sqlServerName --display-name $signedInUser.userPrincipalName --object-id $signedInUser.id | Out-Null } +Invoke-AzCli { az sql db create --resource-group $ResourceGroup --server $sqlServerName --name $DatabaseName --edition Basic --capacity 5 | Out-Null } +Invoke-AzCli { az sql server firewall-rule create --resource-group $ResourceGroup --server $sqlServerName --name AllowAzureServices --start-ip-address 0.0.0.0 --end-ip-address 0.0.0.0 | Out-Null } + +try { + $localIp = (Invoke-RestMethod -Uri "https://api.ipify.org" -TimeoutSec 20).Trim() + Invoke-AzCli { az sql server firewall-rule create --resource-group $ResourceGroup --server $sqlServerName --name AllowLocalClient --start-ip-address $localIp --end-ip-address $localIp | Out-Null } +} +catch { + Write-Warning "Could not discover local public IP. If sqlcmd cannot connect, add a client firewall rule manually." +} + +Write-Host "Preparing sample schema and SQL users..." +$identitySid = Convert-GuidToSqlSidHex $identityClientId +$userSid = Convert-GuidToSqlSidHex $signedInUser.id +$identitySqlName = Escape-SqlIdentifier $identityName +$userSqlName = Escape-SqlIdentifier $signedInUser.userPrincipalName +$identitySqlLiteral = Escape-SqlLiteral $identityName +$userSqlLiteral = Escape-SqlLiteral $signedInUser.userPrincipalName + +$principalSql = @" +IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = N'$identitySqlLiteral') +BEGIN + CREATE USER [$identitySqlName] WITH SID = $identitySid, TYPE = E; +END; + +IF IS_ROLEMEMBER(N'db_datareader', N'$identitySqlLiteral') = 0 +BEGIN + ALTER ROLE db_datareader ADD MEMBER [$identitySqlName]; +END; + +IF IS_ROLEMEMBER(N'db_datawriter', N'$identitySqlLiteral') = 0 +BEGIN + ALTER ROLE db_datawriter ADD MEMBER [$identitySqlName]; +END; + +IF NOT EXISTS (SELECT 1 FROM sys.database_principals WHERE name = N'$userSqlLiteral') +BEGIN + CREATE USER [$userSqlName] WITH SID = $userSid, TYPE = E; +END; + +IF IS_ROLEMEMBER(N'db_datareader', N'$userSqlLiteral') = 0 +BEGIN + ALTER ROLE db_datareader ADD MEMBER [$userSqlName]; +END; + +IF IS_ROLEMEMBER(N'db_datawriter', N'$userSqlLiteral') = 0 +BEGIN + ALTER ROLE db_datawriter ADD MEMBER [$userSqlName]; +END; +"@ + +$principalSqlFile = New-TemporaryFile +$principalSql | Set-Content -Path $principalSqlFile.FullName -Encoding utf8 +sqlcmd -S "$sqlServerName.database.windows.net" -d $DatabaseName -U $SqlAdminLogin -P $sqlAdminPassword -N -C -b -i $SchemaPath +if ($LASTEXITCODE -ne 0) { throw "Failed to apply sample schema." } +sqlcmd -S "$sqlServerName.database.windows.net" -d $DatabaseName -U $SqlAdminLogin -P $sqlAdminPassword -N -C -b -i $principalSqlFile.FullName +if ($LASTEXITCODE -ne 0) { throw "Failed to create SQL users for managed identity and signed-in user." } +Remove-Item -LiteralPath $principalSqlFile.FullName -Force + +Write-Host "Generating ACA-specific dab-config.generated.json..." +$config = Get-Content -Path $TemplatePath -Raw +$config = $config.Replace("__KEY_VAULT_ENDPOINT__", $keyVaultUri) +$config = $config.Replace("__TENANT_ID__", $tenantId) +$config = $config.Replace("__DAB_API_AUDIENCE__", $apiAudience) +$config | Set-Content -Path $GeneratedConfigPath -Encoding utf8 + +Write-Host "Building DAB image in Azure Container Registry..." +Push-Location $RepoRoot +try { + Invoke-AzCli { az acr build --registry $acrName --resource-group $ResourceGroup --image $imageName --file "samples/azure/container-apps-entra-mcp/Dockerfile" . } +} +finally { + Pop-Location +} + +Write-Host "Creating Container Apps environment and app..." +$workspace = az monitor log-analytics workspace create --resource-group $ResourceGroup --workspace-name $logAnalyticsName --location $Location -o json | ConvertFrom-Json +$workspaceCustomerId = $workspace.customerId +$workspaceKey = az monitor log-analytics workspace get-shared-keys --resource-group $ResourceGroup --workspace-name $logAnalyticsName --query primarySharedKey -o tsv +Invoke-AzCli { az containerapp env create --name $containerEnvName --resource-group $ResourceGroup --location $Location --logs-workspace-id $workspaceCustomerId --logs-workspace-key $workspaceKey | Out-Null } + +Invoke-AzCli { + az containerapp create ` + --name $containerAppName ` + --resource-group $ResourceGroup ` + --environment $containerEnvName ` + --image "$acrLoginServer/$imageName" ` + --ingress external ` + --target-port 5000 ` + --min-replicas 1 ` + --max-replicas 1 ` + --user-assigned $identityId ` + --registry-server $acrLoginServer ` + --registry-identity $identityId ` + --secrets "obo-secret=keyvaultref:$oboSecretId,identityref:$identityId" ` + --env-vars "AZURE_CLIENT_ID=$identityClientId" "DAB_OBO_CLIENT_ID=$apiClientId" "DAB_OBO_TENANT_ID=$tenantId" "DAB_OBO_CLIENT_SECRET=secretref:obo-secret" | Out-Null +} + +$fqdn = az containerapp show --name $containerAppName --resource-group $ResourceGroup --query properties.configuration.ingress.fqdn -o tsv +$baseUrl = "https://$fqdn" +$tokenCommand = "az account get-access-token --tenant $tenantId --scope `"$apiScope`" --query accessToken -o tsv" + +$outputs = [ordered]@{ + resourceGroup = $ResourceGroup + location = $Location + tenantId = $tenantId + subscriptionId = $subscriptionId + containerAppName = $containerAppName + containerAppUrl = $baseUrl + mcpEndpoint = "$baseUrl/mcp" + restTodosEndpoint = "$baseUrl/api/dbo_Todos" + graphqlEndpoint = "$baseUrl/graphql" + keyVaultName = $keyVaultName + sqlServerName = $sqlServerName + sqlDatabaseName = $DatabaseName + managedIdentityName = $identityName + appRegistrationDisplayName = $appDisplayName + appClientId = $apiClientId + appAudience = $apiAudience + tokenScope = $apiScope + azureSqlAdminConsentGranted = $adminConsentGranted + tokenCommand = $tokenCommand +} + +$outputs | ConvertTo-Json -Depth 5 | Set-Content -Path $OutputsPath -Encoding utf8 + +Write-Host "" +Write-Host "Deployment complete." +Write-Host "Container App: $baseUrl" +Write-Host "REST sample: $baseUrl/api/dbo_Todos" +Write-Host "GraphQL: $baseUrl/graphql" +Write-Host "MCP: $baseUrl/mcp" +Write-Host "Outputs file: $OutputsPath" +Write-Host "" +Write-Host "Get a user token with:" +Write-Host $tokenCommand + +try { + Write-Host "" + Write-Host "Trying authenticated REST validation..." + $token = az account get-access-token --tenant $tenantId --scope $apiScope --query accessToken -o tsv + if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($token)) { + Start-Sleep -Seconds 20 + $headers = @{ Authorization = "Bearer $token" } + $result = Invoke-RestMethod -Method Get -Uri "$baseUrl/api/dbo_Todos?`$first=5" -Headers $headers -TimeoutSec 60 + Write-Host "REST validation succeeded. Returned rows:" + ($result.value | ConvertTo-Json -Depth 5) | Write-Host + } +} +catch { + Write-Warning "Deployment succeeded, but REST validation did not complete: $($_.Exception.Message)" + Write-Warning "Check Container App logs and confirm admin consent if token or OBO acquisition failed." +} diff --git a/samples/azure/container-apps-entra-mcp/schema.sql b/samples/azure/container-apps-entra-mcp/schema.sql new file mode 100644 index 0000000000..ca70ffa933 --- /dev/null +++ b/samples/azure/container-apps-entra-mcp/schema.sql @@ -0,0 +1,20 @@ +IF OBJECT_ID(N'dbo.Todos', N'U') IS NULL +BEGIN + CREATE TABLE dbo.Todos + ( + Id INT IDENTITY(1,1) NOT NULL CONSTRAINT PK_Todos PRIMARY KEY, + Title NVARCHAR(200) NOT NULL, + OwnerNote NVARCHAR(200) NULL, + IsComplete BIT NOT NULL CONSTRAINT DF_Todos_IsComplete DEFAULT 0, + CreatedAtUtc DATETIME2 NOT NULL CONSTRAINT DF_Todos_CreatedAtUtc DEFAULT SYSUTCDATETIME() + ); +END; + +IF NOT EXISTS (SELECT 1 FROM dbo.Todos) +BEGIN + INSERT INTO dbo.Todos (Title, OwnerNote, IsComplete) + VALUES + (N'Confirm DAB REST authentication', N'Seed row for REST and GraphQL validation', 0), + (N'Confirm DAB MCP tools', N'Seed row for MCP inspector or agent testing', 0), + (N'Explain OBO to customer', N'The database sees the delegated user token', 1); +END;