Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,17 @@ The AI agent definition would likely be deployed from your application's pipelin
| :information: | As a result, the agent becomes a nested Azure resource visible in the Azure control plane. Publishing the chat agent automatically created a dedicated agent identity blueprint and agent identity. Both are bound to the Azure Foundry application resource. This distinct identity represents the chat agent's system authority for accessing its own resources. Reassigning RBAC permissions was required so the new agent identity get permissions to access the conversation, vector store and storage resources. At this deployment time, it was a great moment to reassess only the permissions the agent needs for its tool actions. |
| :-------: | :------------------------- |

**Understanding RBAC for published agent invocation**

Invoking a published agent through the Responses API requires the calling identity to hold the **Azure AI User** role at two scopes: the **Foundry project** and the **agent application** resource. The project-scope assignment grants the `Microsoft.MachineLearningServices/workspaces/agents/action` permission, which authorizes the caller to interact with the agent runtime. The application-scope assignment restricts which specific published agent the caller can reach.

This two-level model gives you fine-grained control over agent access:

- **Onboarding access to a single agent.** Assign Azure AI User at the project level *and* at that agent's application resource. The project-level assignment is a one-time prerequisite; the application-level assignment is what gates access to the specific agent.
- **Onboarding access to a second agent.** The project-level assignment is already in place. You only need to add an application-level assignment on the new agent's application resource.
- **Removing access to a single agent.** Remove the application-level assignment for that agent. The caller retains access to any other agents where it still has an application-level assignment.
- **Removing access to all agents.** Remove the project-level assignment. Without it, no application-level assignment is sufficient to invoke any agent in the project.

1. Verify the agent deployment is running

*This step verify the Foundry AI Agent Service deployment is runnning by invoking the agent application's responses endpoint.*
Expand Down Expand Up @@ -334,27 +345,44 @@ For this deployment guide, you'll continue using your jump box to simulate part
Invoke-WebRequest -Uri https://github.com/Azure-Samples/microsoft-foundry-baseline/raw/refs/heads/main/website/chatui.zip -OutFile chatui.zip
```

1. *(Recommended)* Verify the integrity of the downloaded zip file before uploading.

From your **local workstation** (bash):

```bash
sha256sum website/chatui.zip
```

From the **jump box** (PowerShell):

```powershell
(Get-FileHash chatui.zip -Algorithm SHA256).Hash
```

Both outputs should match the same SHA-256 hash.

1. Upload the web application to Azure Storage, where the web app will load the code from.

```powershell
az storage blob upload -f chatui.zip --account-name "stwebapp${BASE_NAME}" --auth-mode login -c deploy -n chatui.zip
```

1. Update the app configuration to use the agent you deployed.
1. Update the app configuration to use the published agent application endpoint.

```powershell
az webapp config appsettings set -n "app-${BASE_NAME}" -g $RESOURCE_GROUP --settings AIAgentId="${AGENT_ID}"
az webapp config appsettings set -n "app-${BASE_NAME}" -g $RESOURCE_GROUP --settings AgentBaseUrl="${AGENT_BASE_URL}"
```

1. Restart the web app to load the site code and its updated configuation.
1. Stop and start the web app to load the site code, its updated configuration, and acquire a fresh authentication token.

```powershell
az webapp restart --name "app-${BASE_NAME}" --resource-group $RESOURCE_GROUP
az webapp stop --name "app-${BASE_NAME}" --resource-group $RESOURCE_GROUP
az webapp start --name "app-${BASE_NAME}" --resource-group $RESOURCE_GROUP
```

### 5. Try it out! Test the deployed application that calls into the Foundry Agent Service

This section will help you to validate that the workload is exposed correctly and responding to HTTP requests. This will validate that traffic is flowing through Application Gateway, into your Web App, and from your Web App, into the Foundry agent API endpoint, which hosts the agent and its chat history. The agent will interface with Bing for grounding data and an OpenAI model for generative responses.
This section will help you to validate that the workload is exposed correctly and responding to HTTP requests. This will validate that traffic is flowing through Application Gateway, into your Web App, and from your Web App, into the published agent application endpoint. The agent will interface with Bing for grounding data and an OpenAI model for generative responses.

| :computer: | Unless otherwise noted, the following steps are all performed from your original workstation, not from the jump box. |
| :--------: | :------------------------- |
Expand Down
33 changes: 33 additions & 0 deletions infra-as-code/bicep/ai-foundry-appdeploy.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,17 @@ param agentVersion string = '1'

// ---- Existing resources ----

@description('Built-in Role: [Azure AI User](https://learn.microsoft.com/azure/ai-foundry/concepts/rbac-azure-ai-foundry?pivots=fdp-project#azure-ai-user)')
resource azureAiUserRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
name: '53ca6127-db72-4b80-b1b0-d745d6d5456d'
scope: subscription()
}

@description('Existing App Service managed identity. Needs Azure AI User role on the Foundry project and Agent Application to invoke the published agent.')
resource appServiceManagedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2024-11-30' existing = {
name: 'id-app-${baseName}'
}

// Storage Blob Data Owner Role
resource storageBlobDataOwnerRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
name: 'b7e6dc6d-f1e8-4753-8033-0f276bb0955b'
Expand Down Expand Up @@ -204,5 +215,27 @@ module agentContainersWriterSqlAssignment './modules/cosmosdbSqlRoleAssignment.b
}
}

@description('Grant the App Service managed identity Azure AI User role on the Foundry project. Required for Microsoft.MachineLearningServices/workspaces/agents/action when invoking a published agent.')
resource azureAiUserProjectRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
scope: foundry::project
name: guid(foundry::project.id, appServiceManagedIdentity.id, azureAiUserRole.id)
properties: {
roleDefinitionId: azureAiUserRole.id
principalType: 'ServicePrincipal'
principalId: appServiceManagedIdentity.properties.principalId
}
}

@description('Grant the App Service managed identity Azure AI User role on the Agent Application for least-privilege invocation via the Responses API.')
resource azureAiUserAppRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
scope: foundry::project::application
name: guid(foundry::project::application.id, appServiceManagedIdentity.id, azureAiUserRole.id)
properties: {
roleDefinitionId: azureAiUserRole.id
principalType: 'ServicePrincipal'
principalId: appServiceManagedIdentity.properties.principalId
}
}

// ---- Outputs ----
output agentApplicationBaseUrl string = foundry::project::application.properties.baseUrl
2 changes: 0 additions & 2 deletions infra-as-code/bicep/main.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,6 @@ module deployWebApp 'web-app.bicep' = {
privateEndpointsSubnetName: deployVirtualNetwork.outputs.privateEndpointsSubnetName
existingWebAppDeploymentStorageAccountName: deployWebAppStorage.outputs.appDeployStorageName
existingWebApplicationInsightsResourceName: deployApplicationInsights.outputs.applicationInsightsName
existingFoundryResourceName: deployFoundry.outputs.foundryName
existingFoundryProjectName: deployFoundryProject.outputs.aiAgentProjectName
}
}

Expand Down
55 changes: 2 additions & 53 deletions infra-as-code/bicep/web-app.bicep
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,6 @@ param existingWebAppDeploymentStorageAccountName string
@minLength(1)
param existingWebApplicationInsightsResourceName string

@description('The name of the existing Microsoft Foundry instance that the Azure Web App code will be calling for Foundry Agent Service agents.')
@minLength(2)
param existingFoundryResourceName string

@description('The name of the existing Foundry project name.')
@minLength(2)
param existingFoundryProjectName string

// variables
var appName = 'app-${baseName}'
Expand Down Expand Up @@ -85,32 +78,10 @@ resource blobDataReaderRole 'Microsoft.Authorization/roleDefinitions@2022-04-01'
scope: subscription()
}

@description('Built-in Role: [Azure AI User](https://learn.microsoft.com/azure/ai-foundry/concepts/rbac-azure-ai-foundry?pivots=fdp-project#azure-ai-user)')
resource azureAiUserRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
name: '53ca6127-db72-4b80-b1b0-d745d6d5456d'
scope: subscription()
}

// If your web app/API code is going to be creating agents dynamically, you will need to assign a role such as this to App Service managed identity.
/*@description('Built-in Role: [Azure AI Project Manager](https://learn.microsoft.com/azure/ai-foundry/concepts/rbac-azure-ai-foundry?pivots=fdp-project#azure-ai-user)')
resource azureAiProjectManagerRole 'Microsoft.Authorization/roleDefinitions@2022-04-01' existing = {
name: 'eadc314b-1a2d-4efa-be10-5d325db5065e'
scope: subscription()
}*/

resource appServiceExistingPrivateDnsZone 'Microsoft.Network/privateDnsZones@2024-06-01' existing = {
name: 'privatelink.azurewebsites.net'
}

@description('Existing Foundry account. This account is where the agents hosted in Foundry Agent Service will be deployed. The web app code calls to these agents.')
resource foundry 'Microsoft.CognitiveServices/accounts@2025-10-01-preview' existing = {
name: existingFoundryResourceName

resource project 'projects' existing = {
name: existingFoundryProjectName
}
}

// ---- New resources ----

@description('Managed Identity for App Service')
Expand All @@ -130,28 +101,6 @@ resource blobDataReaderRoleAssignment 'Microsoft.Authorization/roleAssignments@2
}
}

@description('Grant the App Service managed identity Azure AI user role permission so it can call into the Foundry-hosted agent.')
resource azureAiUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
scope: foundry
name: guid(foundry.id, appServiceManagedIdentity.id, azureAiUserRole.id)
properties: {
roleDefinitionId: azureAiUserRole.id
principalType: 'ServicePrincipal'
principalId: appServiceManagedIdentity.properties.principalId
}
}

/*@description('Grant the App Service managed identity Azure AI manager role permission so it create the Foundry-hosted agent. Only needed if your code creates agents directly.')
resource azureAiManagerRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
scope: foundry
name: guid(foundry.id, appServiceManagedIdentity.id, azureAiProjectManagerRole.id)
properties: {
roleDefinitionId: azureAiProjectManagerRole.id
principalType: 'ServicePrincipal'
principalId: appServiceManagedIdentity.properties.principalId
}
}*/

@description('Linux, PremiumV3 App Service Plan to host the chat web application.')
resource appServicePlan 'Microsoft.Web/serverfarms@2024-04-01' = {
name: 'asp-${appName}${uniqueString(subscription().subscriptionId)}'
Expand Down Expand Up @@ -219,8 +168,8 @@ resource webApp 'Microsoft.Web/sites@2024-04-01' = {
APPLICATIONINSIGHTS_CONNECTION_STRING: applicationInsights.properties.ConnectionString
AZURE_CLIENT_ID: appServiceManagedIdentity.properties.clientId
ApplicationInsightsAgent_EXTENSION_VERSION: '~3'
AIProjectEndpoint: foundry::project.properties.endpoints['AI Foundry API']
AIAgentId: 'Not yet set' // Will be set once the agent is created
AgentBaseUrl: 'Not yet set' // Will be set via CLI after agent is published as an application
AgentModelDeploymentName: 'agent-model'
XDT_MicrosoftApplicationInsights_Mode: 'Recommended'
}
}
Expand Down
Binary file modified website/chatui.zip
100755 → 100644
Binary file not shown.
7 changes: 4 additions & 3 deletions website/chatui/Configuration/ChatApiOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ namespace chatui.Configuration;
public class ChatApiOptions
{
[Url]
public string AIProjectEndpoint { get; init; } = default!;
public string AgentBaseUrl { get; init; } = default!;

[Required]
public string AIAgentId { get; init; } = default!;
public string AgentApiVersion { get; init; } = "2025-11-15-preview";

public string AgentModelDeploymentName { get; init; } = "agent-model";
}
58 changes: 20 additions & 38 deletions website/chatui/Controllers/ChatController.cs
Original file line number Diff line number Diff line change
@@ -1,62 +1,44 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Azure.AI.Projects;
using Azure.AI.Projects.OpenAI;
using OpenAI.Responses;
using chatui.Configuration;

namespace chatui.Controllers;

#pragma warning disable OPENAI001 // Responses API is in preview

[ApiController]
[Route("[controller]/[action]")]

public class ChatController(
AIProjectClient projectClient,
ResponsesClient responsesClient,
IOptionsMonitor<ChatApiOptions> options,
ILogger<ChatController> logger) : ControllerBase
{
private readonly AIProjectClient _projectClient = projectClient;
private readonly ResponsesClient _responsesClient = responsesClient;
private readonly IOptionsMonitor<ChatApiOptions> _options = options;
private readonly ILogger<ChatController> _logger = logger;

// TODO: [security] Do not trust client to provide conversationId. Instead map current user to their active conversationId in your application's own state store.
// Without this security control in place, a user can inject messages into another user's conversation.
[HttpPost("{conversationId}")]
public async Task<IActionResult> Completions([FromRoute] string conversationId, [FromBody] string message)
[HttpPost]
public async Task<IActionResult> Responses([FromBody] ResponsesRequest request)
{
if (string.IsNullOrWhiteSpace(message))
throw new ArgumentException("Message cannot be null, empty, or whitespace.", nameof(message));
_logger.LogDebug("Prompt received {Prompt}", message);

// MessageResponseItem is currently intended for evaluation purposes and therefore requires explicit suppression of compiler diagnostics.
#pragma warning disable OPENAI001
MessageResponseItem userMessageResponseItem = ResponseItem.CreateUserMessageItem(
[ResponseContentPart.CreateInputTextPart(message)]);

var _config = _options.CurrentValue;
AgentRecord agentRecord = await _projectClient.Agents.GetAgentAsync(_config.AIAgentId);
var agent = agentRecord.Versions.Latest;

ProjectResponsesClient responsesClient
= _projectClient.OpenAI.GetProjectResponsesClientForAgent(agent, conversationId);
if (request.Messages is not { Length: > 0 })
throw new ArgumentException("At least one message is required.");
_logger.LogDebug("Prompt received {Prompt}", request.Messages[^1].Content);

var agentResponseItem = await responsesClient.CreateResponseAsync([userMessageResponseItem]);
var items = request.Messages.Select(m => m.Role switch
{
"user" => ResponseItem.CreateUserMessageItem(m.Content),
"assistant" => ResponseItem.CreateAssistantMessageItem(m.Content),
_ => throw new ArgumentException($"Unsupported message role: {m.Role}")
}).ToList();

var fullText = agentResponseItem.Value.GetOutputText();
var response = await _responsesClient.CreateResponseAsync(model: _options.CurrentValue.AgentModelDeploymentName, items);
var fullText = response.Value.GetOutputText();

return Ok(new { data = fullText });
}
}

[HttpPost]
public async Task<IActionResult> Conversations()
{
// TODO [performance efficiency] Delay creating a conversation until the first user message arrives.
ProjectConversationCreationOptions conversationOptions = new();

ProjectConversation conversation
= await _projectClient.OpenAI.Conversations.CreateProjectConversationAsync(
conversationOptions);

return Ok(new { id = conversation.Id });
}
}
public record ChatMessage(string Role, string Content);
public record ResponsesRequest(ChatMessage[] Messages);
18 changes: 13 additions & 5 deletions website/chatui/Program.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
using Microsoft.Extensions.Options;
using Azure.AI.Projects;
using Azure.AI.OpenAI;
using Azure.Identity;
using chatui.Configuration;

Expand All @@ -10,12 +10,20 @@
.ValidateDataAnnotations()
.ValidateOnStart();

builder.Services.AddSingleton((provider) =>
builder.Services.AddSingleton(provider =>
{
var config = provider.GetRequiredService<IOptions<ChatApiOptions>>().Value;
AIProjectClient projectClient = new(new Uri(config.AIProjectEndpoint), new DefaultAzureCredential());

return projectClient;
// The GA SDK falls through to the base ResponsesClient which appends /responses
// to the endpoint but adds neither /openai nor ?api-version. We construct the
// endpoint up to /protocols/openai with the required api-version, then the SDK
// appends /responses to produce the correct Foundry URL:
// {AgentBaseUrl}/protocols/openai/responses?api-version={AgentApiVersion}
var endpoint = new Uri($"{config.AgentBaseUrl.TrimEnd('/')}/protocols/openai?api-version={config.AgentApiVersion}");
var options = new AzureOpenAIClientOptions { Audience = "https://ai.azure.com" };
AzureOpenAIClient azureClient = new(endpoint, new DefaultAzureCredential(), options);

#pragma warning disable OPENAI001 // Responses API is in preview
return azureClient.GetResponsesClient();
});

builder.Services.AddControllersWithViews();
Expand Down
Loading