diff --git a/dotnet/copilot-studio/relay-agent/.gitignore b/dotnet/copilot-studio/relay-agent/.gitignore new file mode 100644 index 00000000..72bb1753 --- /dev/null +++ b/dotnet/copilot-studio/relay-agent/.gitignore @@ -0,0 +1,2 @@ +appsettings.Development.json +**/Properties/launchSettings.json \ No newline at end of file diff --git a/dotnet/copilot-studio/relay-agent/AgenticRelay/AgenticRelay.cs b/dotnet/copilot-studio/relay-agent/AgenticRelay/AgenticRelay.cs new file mode 100644 index 00000000..0c69a629 --- /dev/null +++ b/dotnet/copilot-studio/relay-agent/AgenticRelay/AgenticRelay.cs @@ -0,0 +1,149 @@ +using AgentNotification; +using Microsoft.Agents.A365.Notifications.Models; +using Microsoft.Agents.Builder; +using Microsoft.Agents.Builder.App; +using Microsoft.Agents.Builder.State; +using Microsoft.Agents.CopilotStudio.Client; +using Microsoft.Agents.Core.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using System; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace OBOAuthorization +{ + public class AgenticRelay : AgentApplication + { + private readonly IConfiguration _configuration; + private readonly IServiceProvider _serviceProvider; + private const string MCSConversationPropertyName = "MCSConversationId8"; + + public AgenticRelay(AgentApplicationOptions options, IServiceProvider service, IConfiguration configuration) : base(options) + { + _configuration = configuration; + _serviceProvider = service; + RegisterExtension(new AgentNotification.AgentNotification(this), a365 => + { + a365.OnAgentNotification("*", OnAgentNotification, autoSignInHandlers: ["agentic"]); + }); + OnActivity(ActivityTypes.Message, OnGeneralActivity, isAgenticOnly: true, autoSignInHandlers: ["agentic"]); + } + + private async Task OnAgentNotification(ITurnContext turnContext, ITurnState turnState, AgentNotificationActivity agentNotificationActivity, CancellationToken cancellationToken) + { + string response = string.Empty; + switch (agentNotificationActivity.NotificationType) + { + case NotificationTypeEnum.WpxComment: + // handle Word/PowerPoint/Excel comment notification - relay to MCS. + response = await RelayToMCS(turnContext, turnState, agentNotificationActivity.WpxCommentNotification, cancellationToken); + await turnContext.SendActivityAsync(MessageFactory.CreateMessageActivity(response)); + break; + case NotificationTypeEnum.EmailNotification: + response = await RelayToMCS(turnContext, turnState, agentNotificationActivity.EmailNotification, cancellationToken); + if (!string.IsNullOrEmpty(response)) + { + await turnContext.SendActivityAsync(EmailResponse.CreateEmailResponseActivity(response)); + } + break; + case NotificationTypeEnum.Unknown: + case NotificationTypeEnum.FederatedKnowledgeServiceNotification: + case NotificationTypeEnum.AgentLifecycleNotification: + default: + // Not supported notification types. + break; + } + } + + private async Task OnGeneralActivity(ITurnContext turnContext, ITurnState turnState, CancellationToken cancellationToken) + { + // relaying teams messages to MCS Agent + await RelayToMCS(turnContext,turnState, turnContext.Activity.Attachments, cancellationToken).ContinueWith(async (t) => + { + var responseText = t.Result; + if (!string.IsNullOrEmpty(responseText)) + { + var messageActivity = MessageFactory.CreateMessageActivity(responseText); + messageActivity.TextFormat = "markdown"; + await turnContext.SendActivityAsync(messageActivity, cancellationToken); + } + }, cancellationToken); + } + + private async Task RelayToMCS(ITurnContext context, ITurnState turnState, Object? notificationMetadata, CancellationToken cancellationToken) + { + var mcsConversationId = turnState.Conversation.GetValue(MCSConversationPropertyName); + var cpsClient = GetClient(context, "agentic"); + StringBuilder responseText = new(); + if (string.IsNullOrEmpty(mcsConversationId)) + { + // Regardless of the Activity Type, start the conversation. + await foreach (IActivity activity in cpsClient.StartConversationAsync(emitStartConversationEvent: false, cancellationToken: cancellationToken)) + { + if (activity.IsType(ActivityTypes.Message)) + { + // await turnContext.SendActivityAsync(activity.Text, cancellationToken: cancellationToken); + responseText.AppendLine(activity.Text); + } + } + } + if (context.Activity.IsType(ActivityTypes.Message)) + { + // Set the conversation ID. + IActivity activityToSend = context.Activity.Clone(); + activityToSend.Conversation = new ConversationAccount(id: mcsConversationId); + + //serialize and prepend notification metadata if any; wrap the notification metadata in tags to indicate it's system info + if (notificationMetadata != null) + { + var serializedMetadata = System.Text.Json.JsonSerializer.Serialize(notificationMetadata); + activityToSend.Text = $"notification metadata:{serializedMetadata}\n{activityToSend.Text}"; + } + + // now do the same for the sender info in the activity by adding it as tags + var serializedSender = System.Text.Json.JsonSerializer.Serialize(context.Activity.From); + activityToSend.Text = $"sender:{serializedSender}\n{activityToSend.Text}"; + + // Send the Copilot Studio Agent whatever the sent and send the responses back. + await foreach (IActivity activity in cpsClient.SendActivityAsync(activityToSend, cancellationToken)) + { + if (activity.IsType(ActivityTypes.Message)) + { + if (activity.Text != null) + { + responseText.AppendLine(activity.Text); + } + } + + if (activity.Conversation != null && !string.IsNullOrEmpty(activity.Conversation.Id)) + { + // Update the conversation ID in case it has changed. + turnState.Conversation.SetValue(MCSConversationPropertyName, activity.Conversation.Id); + } + } + } + return responseText.ToString(); + } + + private CopilotClient GetClient(ITurnContext turnContext, string authHandlerName) + { + var settings = new ConnectionSettings(_configuration.GetSection("CopilotStudioAgent")); + string[] scopes = [CopilotClient.ScopeFromSettings(settings)]; + + return new CopilotClient( + settings, + _serviceProvider.GetService()!, + tokenProviderFunction: async (s) => + { + return await UserAuthorization.ExchangeTurnTokenAsync(turnContext, authHandlerName, exchangeScopes: scopes); + }, + NullLogger.Instance, + "mcs"); + } + + } +} diff --git a/dotnet/copilot-studio/relay-agent/AgenticRelay/AgenticRelay.csproj b/dotnet/copilot-studio/relay-agent/AgenticRelay/AgenticRelay.csproj new file mode 100644 index 00000000..3f6e5cef --- /dev/null +++ b/dotnet/copilot-studio/relay-agent/AgenticRelay/AgenticRelay.csproj @@ -0,0 +1,27 @@ + + + + net9.0 + latest + disable + enable + + + + + + + + + + + + + + + + + + + + diff --git a/dotnet/copilot-studio/relay-agent/AgenticRelay/AspNetExtensions.cs b/dotnet/copilot-studio/relay-agent/AgenticRelay/AspNetExtensions.cs new file mode 100644 index 00000000..11e047ca --- /dev/null +++ b/dotnet/copilot-studio/relay-agent/AgenticRelay/AspNetExtensions.cs @@ -0,0 +1,251 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.Authentication; +using Microsoft.Agents.Core; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; +using Microsoft.IdentityModel.Validators; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Net.Http; +using System.Threading.Tasks; + +public static class AspNetExtensions +{ + private static readonly ConcurrentDictionary> _openIdMetadataCache = new(); + + /// + /// Adds AspNet token validation typical for ABS/SMBA and agent-to-agent using settings in configuration. + /// + /// + /// + /// Name of the config section to read. + /// Optional logger to use for authentication event logging. + /// + /// This extension reads settings from configuration. If configuration is missing JWT token + /// is not enabled. + /// The minimum, but typical, configuration is: + /// + /// "TokenValidation": { + /// "Audiences": [ + /// "{{ClientId}}" // this is the Client ID used for the Azure Bot + /// ], + /// "TenantId": "{{TenantId}}" + /// } + /// + /// + public static void AddAgentAspNetAuthentication(this IServiceCollection services, IConfiguration configuration, string tokenValidationSectionName = "TokenValidation") + { + IConfigurationSection tokenValidationSection = configuration.GetSection(tokenValidationSectionName); + + if (!tokenValidationSection.Exists() || !tokenValidationSection.GetValue("Enabled", true)) + { + // Noop if TokenValidation section missing or disabled. + System.Diagnostics.Trace.WriteLine("AddAgentAspNetAuthentication: Auth disabled"); + return; + } + + services.AddAgentAspNetAuthentication(tokenValidationSection.Get()!); + } + + /// + /// Adds AspNet token validation typical for ABS/SMBA and agent-to-agent. + /// + public static void AddAgentAspNetAuthentication(this IServiceCollection services, TokenValidationOptions validationOptions) + { + AssertionHelpers.ThrowIfNull(validationOptions, nameof(validationOptions)); + + // Must have at least one Audience. + if (validationOptions.Audiences == null || validationOptions.Audiences.Count == 0) + { + throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences requires at least one ClientId"); + } + + // Audience values must be GUID's + foreach (var audience in validationOptions.Audiences) + { + if (!Guid.TryParse(audience, out _)) + { + throw new ArgumentException($"{nameof(TokenValidationOptions)}:Audiences values must be a GUID"); + } + } + + // If ValidIssuers is empty, default for ABS Public Cloud + if (validationOptions.ValidIssuers == null || validationOptions.ValidIssuers.Count == 0) + { + validationOptions.ValidIssuers = + [ + "https://api.botframework.com", + "https://sts.windows.net/d6d49420-f39b-4df7-a1dc-d59a935871db/", + "https://login.microsoftonline.com/d6d49420-f39b-4df7-a1dc-d59a935871db/v2.0", + "https://sts.windows.net/f8cdef31-a31e-4b4a-93e4-5f571e91255a/", + "https://login.microsoftonline.com/f8cdef31-a31e-4b4a-93e4-5f571e91255a/v2.0", + "https://sts.windows.net/69e9b82d-4842-4902-8d1e-abc5b98a55e8/", + "https://login.microsoftonline.com/69e9b82d-4842-4902-8d1e-abc5b98a55e8/v2.0", + ]; + + if (!string.IsNullOrEmpty(validationOptions.TenantId) && Guid.TryParse(validationOptions.TenantId, out _)) + { + validationOptions.ValidIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV1, validationOptions.TenantId)); + validationOptions.ValidIssuers.Add(string.Format(CultureInfo.InvariantCulture, AuthenticationConstants.ValidTokenIssuerUrlTemplateV2, validationOptions.TenantId)); + } + } + + // If the `AzureBotServiceOpenIdMetadataUrl` setting is not specified, use the default based on `IsGov`. This is what is used to authenticate ABS tokens. + if (string.IsNullOrEmpty(validationOptions.AzureBotServiceOpenIdMetadataUrl)) + { + validationOptions.AzureBotServiceOpenIdMetadataUrl = validationOptions.IsGov ? AuthenticationConstants.GovAzureBotServiceOpenIdMetadataUrl : AuthenticationConstants.PublicAzureBotServiceOpenIdMetadataUrl; + } + + // If the `OpenIdMetadataUrl` setting is not specified, use the default based on `IsGov`. This is what is used to authenticate Entra ID tokens. + if (string.IsNullOrEmpty(validationOptions.OpenIdMetadataUrl)) + { + validationOptions.OpenIdMetadataUrl = validationOptions.IsGov ? AuthenticationConstants.GovOpenIdMetadataUrl : AuthenticationConstants.PublicOpenIdMetadataUrl; + } + + var openIdMetadataRefresh = validationOptions.OpenIdMetadataRefresh ?? BaseConfigurationManager.DefaultAutomaticRefreshInterval; + + _ = services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + options.SaveToken = true; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidateAudience = true, + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(5), + ValidIssuers = validationOptions.ValidIssuers, + ValidAudiences = validationOptions.Audiences, + ValidateIssuerSigningKey = true, + RequireSignedTokens = true, + }; + + // Using Microsoft.IdentityModel.Validators + options.TokenValidationParameters.EnableAadSigningKeyIssuerValidation(); + + options.Events = new JwtBearerEvents + { + // Create a ConfigurationManager based on the requestor. This is to handle ABS non-Entra tokens. + OnMessageReceived = async context => + { + string authorizationHeader = context.Request.Headers.Authorization.ToString(); + + if (string.IsNullOrEmpty(authorizationHeader)) + { + // Default to AadTokenValidation handling + context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager; + await Task.CompletedTask.ConfigureAwait(false); + return; + } + + string[] parts = authorizationHeader?.Split(' ')!; + if (parts.Length != 2 || parts[0] != "Bearer") + { + // Default to AadTokenValidation handling + context.Options.TokenValidationParameters.ConfigurationManager ??= options.ConfigurationManager as BaseConfigurationManager; + await Task.CompletedTask.ConfigureAwait(false); + return; + } + + JwtSecurityToken token = new(parts[1]); + string issuer = token.Claims.FirstOrDefault(claim => claim.Type == AuthenticationConstants.IssuerClaim)?.Value!; + + if (validationOptions.AzureBotServiceTokenHandling && AuthenticationConstants.BotFrameworkTokenIssuer.Equals(issuer)) + { + // Use the Azure Bot authority for this configuration manager + context.Options.TokenValidationParameters.ConfigurationManager = _openIdMetadataCache.GetOrAdd(validationOptions.AzureBotServiceOpenIdMetadataUrl, key => + { + return new ConfigurationManager(validationOptions.AzureBotServiceOpenIdMetadataUrl, new OpenIdConnectConfigurationRetriever(), new HttpClient()) + { + AutomaticRefreshInterval = openIdMetadataRefresh + }; + }); + } + else + { + context.Options.TokenValidationParameters.ConfigurationManager = _openIdMetadataCache.GetOrAdd(validationOptions.OpenIdMetadataUrl, key => + { + return new ConfigurationManager(validationOptions.OpenIdMetadataUrl, new OpenIdConnectConfigurationRetriever(), new HttpClient()) + { + AutomaticRefreshInterval = openIdMetadataRefresh + }; + }); + } + + await Task.CompletedTask.ConfigureAwait(false); + }, + + OnTokenValidated = context => + { + return Task.CompletedTask; + }, + OnForbidden = context => + { + return Task.CompletedTask; + }, + OnAuthenticationFailed = context => + { + return Task.CompletedTask; + } + }; + }); + } + + public class TokenValidationOptions + { + public IList? Audiences { get; set; } + + /// + /// TenantId of the Azure Bot. Optional but recommended. + /// + public string? TenantId { get; set; } + + /// + /// Additional valid issuers. Optional, in which case the Public Azure Bot Service issuers are used. + /// + public IList? ValidIssuers { get; set; } + + /// + /// Can be omitted, in which case public Azure Bot Service and Azure Cloud metadata urls are used. + /// + public bool IsGov { get; set; } = false; + + /// + /// Azure Bot Service OpenIdMetadataUrl. Optional, in which case default value depends on IsGov. + /// + /// + /// + public string? AzureBotServiceOpenIdMetadataUrl { get; set; } + + /// + /// Entra OpenIdMetadataUrl. Optional, in which case default value depends on IsGov. + /// + /// + /// + public string? OpenIdMetadataUrl { get; set; } + + /// + /// Determines if Azure Bot Service tokens are handled. Defaults to true and should always be true until Azure Bot Service sends Entra ID token. + /// + public bool AzureBotServiceTokenHandling { get; set; } = true; + + /// + /// OpenIdMetadata refresh interval. Defaults to 12 hours. + /// + public TimeSpan? OpenIdMetadataRefresh { get; set; } + } +} diff --git a/dotnet/copilot-studio/relay-agent/AgenticRelay/Program.cs b/dotnet/copilot-studio/relay-agent/AgenticRelay/Program.cs new file mode 100644 index 00000000..ee8284cf --- /dev/null +++ b/dotnet/copilot-studio/relay-agent/AgenticRelay/Program.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.Agents.Builder; +using Microsoft.Agents.Hosting.AspNetCore; +using Microsoft.Agents.Storage; +using Microsoft.Agents.Storage.Transcript; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using OBOAuthorization; +using System.Threading; + +WebApplicationBuilder builder = WebApplication.CreateBuilder(args); + +builder.Services.AddHttpClient(); + +// Add AgentApplicationOptions from appsettings section "AgentApplication". +builder.AddAgentApplicationOptions(); + +// Add the AgentApplication, which contains the logic for responding to +// user messages. +builder.AddAgent(); + +// Register IStorage. For development, MemoryStorage is suitable. +// For production Agents, persisted storage should be used so +// that state survives Agent restarts, and operates correctly +// in a cluster of Agent instances. +builder.Services.AddSingleton(); + +// Configure the HTTP request pipeline. + +// Add AspNet token validation for Azure Bot Service and Entra. Authentication is +// configured in the appsettings.json "TokenValidation" section. +builder.Services.AddControllers(); +builder.Services.AddAgentAspNetAuthentication(builder.Configuration); +builder.Services.AddSingleton([new TranscriptLoggerMiddleware(new FileTranscriptLogger())]); + +WebApplication app = builder.Build(); + +// Enable AspNet authentication and authorization +app.UseAuthentication(); +app.UseAuthorization(); + +app.MapGet("/", () => "Microsoft Agents SDK Sample"); + +// This receives incoming messages from Azure Bot Service or other SDK Agents +var incomingRoute = app.MapPost("/api/messages", async (HttpRequest request, HttpResponse response, IAgentHttpAdapter adapter, IAgent agent, CancellationToken cancellationToken) => +{ + await adapter.ProcessAsync(request, response, agent, cancellationToken); +}); + +if (!app.Environment.IsDevelopment()) +{ + incomingRoute.RequireAuthorization(); +} +else +{ + // Hardcoded for brevity and ease of testing. + // In production, this should be set in configuration. + app.Urls.Add($"http://localhost:3978"); +} + +app.Run(); diff --git a/dotnet/copilot-studio/relay-agent/AgenticRelayExample.sln b/dotnet/copilot-studio/relay-agent/AgenticRelayExample.sln new file mode 100644 index 00000000..447a891d --- /dev/null +++ b/dotnet/copilot-studio/relay-agent/AgenticRelayExample.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36623.8 d17.14 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AgenticRelay", "AgenticRelay\AgenticRelay.csproj", "{5715970C-ACEE-51E0-F40A-7B16EA4381C1}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5715970C-ACEE-51E0-F40A-7B16EA4381C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5715970C-ACEE-51E0-F40A-7B16EA4381C1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5715970C-ACEE-51E0-F40A-7B16EA4381C1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5715970C-ACEE-51E0-F40A-7B16EA4381C1}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {2FB4F3A9-92C1-49C7-91C0-9D874286ED70} + EndGlobalSection +EndGlobal diff --git a/dotnet/copilot-studio/relay-agent/README.md b/dotnet/copilot-studio/relay-agent/README.md new file mode 100644 index 00000000..b28651d2 --- /dev/null +++ b/dotnet/copilot-studio/relay-agent/README.md @@ -0,0 +1,541 @@ +# Calling Copilto Studio Setup + +This sample demonstrates how to call Copilot Studio Agents from an Agent instance. + +## Run the setup scripts + +Run the following scripts. You can find the scripts in the `scripts` folder. + +### 1. Creating Delegated Consent for Agent Application Creation + +**Script:** `DelegatedAgentApplicationCreateConsent.ps1` + +Callers of this script (users in your tenant) are required to be **Global Admins** to create Agent Applications. + +Also, for you to be able to create the Agent Blueprint, you need to grant the `AgentApplication.Create` permission to the **Microsoft Graph Command Line Tools** application. This script automates that step. + +You will need to provide: + +- **Tenant ID** – navigate to **Entra ID → Tenant properties** for this information +- **Calling App ID** – use `14d82eec-204b-4c2f-b7e8-296a70dab67e` for *Microsoft Graph Command Line Tools* + +Run from PowerShell: + +```powershell +.\DelegatedAgentApplicationCreateConsent.ps1 ` + -TenantId "" ` + -CallingAppId "14d82eec-204b-4c2f-b7e8-296a70dab67e" +``` + +### 2. Creating the Agent Blueprint + +**Script:** `createAgentBlueprint.ps1` + +This script: + +1. Creates an Agent Blueprint application in your tenant. +1. Optionally links it to your App Service managed identity (MSI Principal ID). +1. Configures default scopes and Graph permissions. + +You can run this script in two modes: + +**Interactive mode:** + +```powershell +.\createAgentBlueprint.ps1 +``` + +You will be prompted for: + +- **Tenant ID** +- **MSI Principal ID** – the Object (principal) ID of the managed identity for the App Service that you created (optional). +- **Display Name** – the display name for the Agent Blueprint application. + +**Config mode (recommended for CI / repeatable setup):** + +```powershell +.\createAgentBlueprint.ps1 -ConfigFile ".\config.json" +``` + +You will need a config.json file similar to: + +```json +{ + "TenantId": "", + "MsiPrincipalId": "", + "AgentBlueprintDisplayName": "Kairo Agent Blueprint" +} +``` + +The script will: + +1. Connect to the tenant +1. Create the Agent Blueprint app +1. Create the Service Principal +1. Configure default Graph scopes +1. (Optionally) create a federated credential for your managed identity + +After the script runs, record the Agent Blueprint Application ID. + +### 3. Adding Inheritable Permissions + +**Script:** `Add-AgentBlueprintPermissions.ps1` + +This script configures inheritable delegated scopes for your Agent Blueprint and admin-approves those scopes for the Blueprint service principal (via oauth2PermissionGrants). + +**Prerequisites:** + +Before running this script, you must: + +- Have already run Script #1 and Script #2. +- Have the Microsoft Graph PowerShell SDK installed. + +Connect with a token that includes the necessary permissions, for example: + +```powershell +Connect-MgGraph -TenantId "" ` + -Scopes @( + "AgentIdentityBlueprint.ReadWrite.All", + "Application.ReadWrite.All", + "Policy.ReadWrite.PermissionGrant" + ) +``` + +You can verify the scopes with: + +```powershell +(Get-MgContext).Scopes +``` + +### 4. Add CopilotStudio delegated scope for Power Platform API + +This call adds the `CopilotStudio.Copilots.Invoke` delegated scope as an inheritable scope for the Agent Blueprint, and admin-approves that scope for the Blueprint service principal. This is necessary for your Agent Blueprint to be able to call Copilot Studio Agents. + +**Resource:** + +- **Resource App ID:** 8578e004-a5c6-46e7-913e-12f58912df43 +- **Scope:** CopilotStudio.Copilots.Invoke + +**Command:** + +```powershell +.\Add-AgentBlueprintPermissions.ps1 ` + -TenantId "" ` + -AgentBlueprintAppId "" ` + -ResourceAppId "8578e004-a5c6-46e7-913e-12f58912df43" ` + -Scopes "CopilotStudio.Copilots.Invoke" +``` + +This will: + +1. Upsert the inheritable permissions on the Agent Blueprint: + - `inheritableScopes = microsoft.graph.enumeratedScopes` + - `scopes = ["CopilotStudio.Copilots.Invoke"]` +2. Ensure there is a tenant-wide oauth2PermissionGrant for: + - `clientId = ` + - `resourceId = ` + - `scope` includes `CopilotStudio.Copilots.Invoke` + +### 5. Enable all delegated scopes for the Messaging Bot API + +For the Messaging Bot API, you want the Agent Blueprint to inherit all allowed delegated scopes from the resource. + +**Resource:** + +- **Resource App ID:** 5a807f24-c9de-44ee-a3a7-329e88a00ffc + +**Command:** + +```powershell +.\Add-AgentBlueprintPermissions.ps1 ` + -TenantId "" ` + -AgentBlueprintAppId "" ` + -ResourceAppId "5a807f24-c9de-44ee-a3a7-329e88a00ffc" ` + -AllAllowed +``` + +This will: + +1. Configure inheritable permissions on the Agent Blueprint with: + - `inheritableScopes = microsoft.graph.allAllowedScopes` +1. Read all delegated scopes (oauth2PermissionScopes) from the Messaging Bot API service principal. +1. Create or update a tenant-wide oauth2PermissionGrant so that the Blueprint service principal is admin-consented for all those delegated scopes. + +### 6. Verifying the configuration + +To verify the inheritable permissions for your Agent Blueprint: + +```powershell +$bpAppId = "" +$bpObj = Get-MgApplication -Filter "appId eq '$bpAppId'" +$bpObjId = $bpObj.Id + +Invoke-MgGraphRequest -Method GET ` + -Uri "https://graph.microsoft.com/beta/applications/microsoft.graph.agentIdentityBlueprint/$bpObjId/inheritablePermissions" ` + -Headers @{ "OData-Version" = "4.0" } | + ConvertTo-Json -Depth 10 +``` + +You should see entries for: + +- `resourceAppId = 8578e004-a5c6-46e7-913e-12f58912df43 with enumeratedScopes containing CopilotStudio.Copilots.Invoke` +- `resourceAppId = 5a807f24-c9de-44ee-a3a7-329e88a00ffc with allAllowedScopes` + +To verify admin consent (optional): + +```powershell +$bpSp = Get-MgServicePrincipal -Filter "appId eq '$bpAppId'" +$bpSpId = $bpSp.Id +$resource1 = Get-MgServicePrincipal -Filter "appId eq '8578e004-a5c6-46e7-913e-12f58912df43'" +$resource2 = Get-MgServicePrincipal -Filter "appId eq '5a807f24-c9de-44ee-a3a7-329e88a00ffc'" + +$filter1 = "clientId eq '$bpSpId' and resourceId eq '$($resource1.Id)' and consentType eq 'AllPrincipals'" +$filter2 = "clientId eq '$bpSpId' and resourceId eq '$($resource2.Id)' and consentType eq 'AllPrincipals'" + +Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?`$filter=$([System.Uri]::EscapeDataString($filter1))" +Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?`$filter=$([System.Uri]::EscapeDataString($filter2))" +``` + +### 7. Create a client secret (optional) + +If your Agent Blueprint will not be using a managed identity, you need to create a client secret for it. + +Run the following command: + +```powershell +Invoke-MgGraphRequest -Method POST ` + -Uri "https://graph.microsoft.com/beta/applications//addPassword" ` + -Body (@{ + passwordCredential = @{ + displayName = "My Secret" + endDateTime = "2026-08-05T23:59:59Z" + } + } | ConvertTo-Json) ` + -Headers @{ "Content-Type" = "application/json" } +``` + +## Creating an Azure Bot Service Resource + +After creating the Agent Blueprint and configuring inheritable delegated permissions, the next step is to provision an **Azure Bot Service** resource. This bot becomes the *service identity* through which your agent code will communicate. + +> **Important:** +> The **Bot’s App ID must be exactly the same as the Agent Blueprint App ID** created in Step #2. +> This ensures the Bot Service and the Agent Blueprint share the same underlying identity and token configuration. + +### 1. Open the Azure Portal + +Navigate to: + +**https://portal.azure.com** + +#### 2. Create a New Resource + +1. Select **Create a Resource** (top-left). +2. Search for **Azure Bot**. +3. Select **Azure Bot** from the marketplace. +4. Click **Create**. + +### 3. Configure the Bot Basics + +Fill in the following fields: + +| Field | Value | +|-------|-------| +| **Subscription** | Your subscription | +| **Resource Group** | Choose an existing group or create a new one | +| **Bot handle** | A globally unique name (e.g., `my-agent-bot`) | +| **Type of App** | **Single-tenant** | +| **Microsoft App ID** | **Paste your Blueprint App ID** | +| **Type of App (App Registration)** | Select **Use existing app registration** | +| **Existing App Registration App ID** | **Same as above** | + +> **❗ Critical requirement**: +> The **Bot’s Microsoft App ID must be the Blueprint App ID** (`f5544c48-63d7-473c-887c-0a02ebbab2e7`) that was created earlier. + +Do **NOT** create a new App Registration. +Select **"Use existing"** and supply the Blueprint App ID. + +### 5. Review + Create + +1. Click **Review + Create** +2. Confirm your settings +3. Click **Create** + +Deployment usually takes 20–45 seconds. + +### 7. Connect to Teams + +1. After deployment, navigate to your Bot resource. +1. Select **Channels** from the left menu. +1. Click on the **Microsoft Teams** icon. +1. Accept the Terms of Service. +1. Complete the setup. + +## Connect notifications to your Bot + +You now need to relay notifications from your Agent instance to your Bot Service. + +1. Navigate to [[https://https://dev.teams.microsoft.com/tools/agent-blueprint](https://dev.teams.microsoft.com/tools/agent-blueprint) +1. Select your Agent Blueprint. +1. Navigate to **Configuration** +1. Select **Bot based** for **Agent type** +1. Paste in your Bot's **Microsoft App ID** which is the same as your Agent Blueprint App ID. +1. Select **Save** + +## Creating the Microsoft Admin Center Manifest + +After creating the Agent Blueprint, configuring permissions, and setting up the Azure Bot resource, you must package your agent and publish it to your organization via the **Microsoft Admin Center**. + +Publishing requires **two JSON files**: + +1. **App Manifest (manifest.json)** +2. **Agentic User Template Manifest (agenticUserTemplateManifest.json)** + +The two files work together: + +- The **App Manifest** represents the bot/agent as an app installable in Microsoft 365. +- The **Agentic User Template Manifest** describes how the Agent Blueprint is used to create Agent Users, enabling the “digital worker” or “custom agent” pattern inside M365. + +Your Agent Blueprint App ID will be used in both files to bind everything to the same identity. + +--- + +### 1. Create the Teams App Manifest (manifest.json) + +Create a file named **manifest.json** in your app folder. + +Replace: + +- `{{APP_ID}}` with your **Agent Blueprint App ID** +- App name, description, icons, and developer information as you see fit + +manifest.json: + +```json +{ + "$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/vDevPreview/MicrosoftTeams.schema.json", + "manifestVersion": "devPreview", + "version": "1.0.0", + "id": "{{APP_ID}}", + "developer": { + "name": "Your Team", + "websiteUrl": "https://contoso.com", + "privacyUrl": "https://contoso.com/privacy", + "termsOfUseUrl": "https://contoso.com/terms" + }, + "name": { + "short": "Your Agent Name", + "full": "Your Agent Full Name" + }, + "description": { + "short": "Short description of what your agent does.", + "full": "A longer description of your agent, its capabilities, and intended use." + }, + "icons": { + "color": "color-icon.png", + "outline": "outline-icon.png" + }, + "accentColor": "#4464ee", + "validDomains": [], + "webApplicationInfo": { + "id": "{{APP_ID}}", + "resource": "api://{{APP_ID}}" + }, + "bots": [ + { + "botId": "{{APP_ID}}", + "scopes": [ + "personal", + "team", + "groupChat", + "copilot" + ], + "supportsFiles": false, + "isNotificationOnly": false + } + ], + "copilotAgents": { + "customEngineAgents": [ + { + "id": "{{APP_ID}}", + "type": "bot", + "disclaimer": { + "text": "This agent uses AI. Please verify important information." + }, + "functionsAs": "agenticUserOnly", + "agenticUserTemplateId": "digitalWorkerTemplate" + } + ] + }, + "agenticUserTemplates": [ + { + "id": "digitalWorkerTemplate", + "file": "agenticUserTemplateManifest.json" + } + ] +} +``` + + +### 2. Create the Agentic User Template Manifest + +Create a second file named **agenticUserTemplateManifest.json**. + +Replace: + +- `{{APP_ID}}` with your **Agent Blueprint App ID** + +agenticUserTemplateManifest.json: + +```json +{ + "schemaVersion": "0.1.0-preview", + "id": "digitalWorkerTemplate", + "agentIdentityBlueprintId": "{{APP_ID}}", + "communicationProtocol": "activityProtocol" +} +``` + +This file defines how Microsoft 365 should create **Agent Users** from your Agent Blueprint when users interact with your app. + +### 3. Package the App + +Create a ZIP file that contains: + +- `manifest.json` +- `agenticUserTemplateManifest.json` +- `color-icon.png` (192×192) +- `outline-icon.png` (32×32) + +Example: + +my-agent-app.zip +├── manifest.json +├── agenticUserTemplateManifest.json +├── color-icon.png +└── outline-icon.png + +--- + +### 4. Upload to Microsoft Admin Center + +1. Visit: + **https://admin.microsoft.com/Adminportal/Home#/TeamsApps/ManageApps** + +2. Select **Agents** → **All Agents** → **Upload custom agent** + +3. Choose your ZIP file (`my-agent-app.zip`) + +4. Complete the remaining steps + +--- + +### 6. Activate the agent + +After uploading, you can activate the agent for your organization. + +1. Search for your agent by name +1. Select it +1. Click **Activate** +1. Complete the activation steps + +## Instantiate the agent + +You can now instantiate the agent. Eventually, you'll be able to do this via the Teams UI, but for now, you can use the `createAgentUser.ps1` script located in the `scripts` folder. + +### 1. Create the Agent User + +Follow the steps in [README_AgentUserCreation.md](./scripts/README_AgentUserCreation.md) to create an Agent User for your Agent Blueprint. You may need the client secret created earlier (see step 7 of "Creating the Agent Blueprint"). + +### 2. Assign Licenses + +1. Open Entra and search for the Agent User you created under **Users**. +1. Copy the **Object ID** of the Agent User. +1. Go to the `https://admin.cloud.microsoft/#/users/:/UserDetails//LicensesAndApps` page (replace `` with the actual Object ID). +1. Assign the agent with licenses to use Teams, Outlook, Microsoft 365, and Copilot Studio + +### 3. Approve scopes +After creating the Agent User, you need to approve the delegated scopes for the Agent Identity so that the Agent User can access the necessary resources. To do so, navigate to these URLs in your browser (replace `` and `` with your actual Tenant ID and Agent Identity Application ID): + +- https://login.microsoftonline.com//v2.0/adminconsent?client_id=&scope=User.ReadBasic.All Mail.Send Mail.Read Chat.Read Chat.ReadWrite 8578e004-a5c6-46e7-913e-12f58912df43/CopilotStudio.Copilots.Invoke 0ddb742a-e7dc-4899-a31e-80e797ec7144/CopilotStudio.Copilots.Invoke&redirect_uri=https://entra.microsoft.com/TokenAuthorize&state=xyz123 + +## Create an agent in Copilot Studio + +You now need to create an agent that will orchestrate the use of various MCP tools. + +### 1. Create the Agent + +1. Navigate to [https://copilotstudio.microsoft.com/](https://copilotstudio.microsoft.com/) +1. Select **Agents** → **New agent** +1. Setup your agent as you normally would +1. Go to **Tools** +1. Search for and add the following tools: + 1. **Microsoft Word MCP** – this allows the agent to create and reference Word documents + 1. **Microsoft SharePoint and OneDrive MCP** – this allows the agent to access and share files from SharePoint and OneDrive + 1. **Microsoft 365 User Profile MCP** – this allows the agent to look up user profile information + +### 2. Publish the Agent + +1. After configuring the agent, select **Publish** +1. Select **Force new version** +1. Select **Publish** +1. Wait for the agent to be published +1. Navigate to **Channels** +1. Select **Native app** +1. Copy the **Connection string** – you will need this in the next step + +### 3. Share the agent + +1. Select **...** in the top right corner of the agent authoring page +1. Select **Share agent** +1. Share the agent with the agent user you created earlier + +## Build and Run the Sample + +Congrats! You have finished the setup. We can now build and run the sample. + +### 1. Running the sample project + +1. Open appsettings.json in the sample project +1. Update the following settings: + 1. `ClientId` – your Agent Blueprint App ID + 1. `TenantId` – your Tenant ID + 1. `ConnectionUrl` – the connection string you copied from Copilot Studio + 1. `ClientSecret` – the client secret you created for your Agent Blueprint (if not using managed identity) +1. Save the file +1. Build and run the project by running the following commands from the terminal: + + ```bash + dotnet build + dotnet run + ``` + +### 2. Tunneling to your agent from Azure Bot Service + +1. Run `dev tunnels`. Please follow [Create and host a dev tunnel](https://learn.microsoft.com/en-us/azure/developer/dev-tunnels/get-started?tabs=windows) and host the tunnel with anonymous user access command as shown below: + + ```bash + devtunnel host -p 3978 --allow-anonymous + ``` + +1. Go back to your Azure Bot Service, select **Settings**, then **Configuration**, and update the **Messaging endpoint** to `{tunnel-url}/api/messages` + +## Test the agent + +You can now test the agent with the following scenarios: + +1. Start a chat in Microsoft Teams +1. Send an email to the Agent User you created earlier +1. @-mention the agent in a Microsoft Word comment + +## Troubleshooting + +### My agent isn't showing up in Teams or Outlook + +Ensure that an agent user has been created for your Agent Blueprint and that the Agent User has the necessary licenses assigned (Teams, Outlook, Microsoft 365, Copilot Studio). Without a license to Teams or Outlook, the agent will not appear in those applications. + +### My agent is not responding + +Ensure that your bot service is running and reachable from the internet. You can use the [Agent Playground](https://learn.microsoft.com/en-us/microsoft-365/agents-sdk/test-with-toolkit-project?tabs=windows) to test connectivity to your bot service. + +Also note that the response time for Word, Excel, and PowerPoint comment notification can take several minutes depending on system load. \ No newline at end of file diff --git a/dotnet/copilot-studio/relay-agent/appsettings.json b/dotnet/copilot-studio/relay-agent/appsettings.json new file mode 100644 index 00000000..d4f22dc7 --- /dev/null +++ b/dotnet/copilot-studio/relay-agent/appsettings.json @@ -0,0 +1,62 @@ +{ + "TokenValidation": { + "Enabled": false, + "Audiences": [ + "{{ClientId}}" // this is the Client ID used for the Azure Bot + ], + "TenantId": "{{TenantId}}" + }, + + "AgentApplication": { + "StartTypingTimer": false, + "RemoveRecipientMention": false, + "NormalizeMentions": false, + + "UserAuthorization": { + "AutoSignIn": false, + "Handlers": { + "agentic": { + "Type": "AgenticUserAuthorization", + "Settings": { + "Scopes": [ + "https://graph.microsoft.com/.default" + ] + } + } + } + } + }, + + "CopilotStudioAgent": { + "EnvironmentId": null, + "SchemaName": null, + "DirectConnectUrl": "{{ConnectionUrl}}" + }, + + "Connections": { + "ServiceConnection": { + "Settings": { + "AuthType": "ClientSecret", // this is the AuthType for the connection, valid values can be found in Microsoft.Agents.Authentication.Msal.Model.AuthTypes. The default is ClientSecret. + "AuthorityEndpoint": "https://login.microsoftonline.com/{{TenantId}}", + "ClientId": "{{ClientId}}", // this is the Client ID used for the Azure Bot + "ClientSecret": "00000000-0000-0000-0000-000000000000", // this is the Client Secret used for the connection. + "Scopes": [ + "https://api.botframework.com/.default" + ] + } + } + }, + "ConnectionsMap": [ + { + "ServiceUrl": "*", + "Connection": "ServiceConnection" + } + ], + + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} \ No newline at end of file diff --git a/dotnet/copilot-studio/relay-agent/scripts/Add-AgentBlueprintPermissions.ps1 b/dotnet/copilot-studio/relay-agent/scripts/Add-AgentBlueprintPermissions.ps1 new file mode 100644 index 00000000..d6e4598c --- /dev/null +++ b/dotnet/copilot-studio/relay-agent/scripts/Add-AgentBlueprintPermissions.ps1 @@ -0,0 +1,366 @@ +<# +.SYNOPSIS + Adds inheritable delegated scopes to an Agent Blueprint and + admin-approves those scopes for the Blueprint service principal. + + Modes: + • Enumerated scopes (specific list) + • AllAllowed (microsoft.graph.allAllowedScopes) + +.DESCRIPTION + Uses Graph beta for inheritable scopes and v1.0 for oauth2PermissionGrants. + + Inheritable permissions: + POST https://graph.microsoft.com/beta/applications/microsoft.graph.agentIdentityBlueprint/{appObjectId}/inheritablePermissions + PATCH https://graph.microsoft.com/beta/applications/microsoft.graph.agentIdentityBlueprint/{appObjectId}/inheritablePermissions/{resourceAppId} + + Admin consent (tenant-wide): + GET https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=... + POST https://graph.microsoft.com/v1.0/oauth2PermissionGrants + PATCH https://graph.microsoft.com/v1.0/oauth2PermissionGrants/{id} + +.PARAMETER TenantId + AAD tenant id (GUID). + +.PARAMETER AgentBlueprintObjectId + ObjectId of the Agent Blueprint application. Provide this OR AgentBlueprintAppId. + +.PARAMETER AgentBlueprintAppId + AppId (client id) of the Agent Blueprint application. Provide this OR AgentBlueprintObjectId. + +.PARAMETER ResourceAppId + AppId (client id) of the target resource. + +.PARAMETER Scopes + One or more delegated scopes to add as inheritable scopes (enumerated). + +.PARAMETER AllAllowed + Use microsoft.graph.allAllowedScopes (all delegated scopes) for this resource. + +.PARAMETER CreateResourceSpIfMissing + Create the resource service principal in this tenant if it does not exist. + +.PARAMETER SkipScopeValidation + Skip checking that enumerated scopes exist in the resource SP's oauth2PermissionScopes. + +.NOTES + Requires Microsoft Graph PowerShell SDK and sufficient directory/admin privileges. +#> + +[CmdletBinding(SupportsShouldProcess = $true)] +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F-]{36}$')] + [string]$TenantId, + + [Parameter(Mandatory = $false)] + [ValidatePattern('^[0-9a-fA-F-]{36}$')] + [string]$AgentBlueprintObjectId, + + [Parameter(Mandatory = $false)] + [ValidatePattern('^[0-9a-fA-F-]{36}$')] + [string]$AgentBlueprintAppId, + + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-fA-F-]{36}$')] + [Alias('ApiAppId')] + [string]$ResourceAppId, + + [Parameter(Mandatory = $false)] + [Alias('Scope')] + [string[]]$Scopes = @(), + + [Parameter(Mandatory = $false)] + [switch]$AllAllowed, + + [switch]$CreateResourceSpIfMissing, + [switch]$SkipScopeValidation +) + +function Write-Info { param([string]$m) Write-Host $m -ForegroundColor Cyan } +function Write-Warn { param([string]$m) Write-Host $m -ForegroundColor Yellow } +function Write-Err { param([string]$m) Write-Host "ERROR: $m" -ForegroundColor Red } + +function Show-GraphError { + param($err) + + Write-Host "----------- GRAPH ERROR -----------" -ForegroundColor Red + + if ($err.ErrorDetails -and $err.ErrorDetails.Message) { + Write-Host $err.ErrorDetails.Message -ForegroundColor Yellow + try { + ($err.ErrorDetails.Message | ConvertFrom-Json).error | Format-List * | Out-String | Write-Host + } catch {} + } + + if ($err.Exception.Response) { + try { + $resp = $err.Exception.Response + if ($resp.Content) { + Write-Host "Raw Response (ToString()):" -ForegroundColor Yellow + $resp.Content.ToString() | Write-Host + } + } catch {} + } + + Write-Host $err.Exception.Message -ForegroundColor Yellow +} + +function Resolve-Blueprint { + param([string]$ObjectId,[string]$AppId) + + if (-not $ObjectId -and -not $AppId) { + throw "Provide either -AgentBlueprintObjectId or -AgentBlueprintAppId." + } + + if ($ObjectId) { + $app = Get-MgApplication -ApplicationId $ObjectId -ErrorAction Stop + return @{ ObjectId = $ObjectId; AppId = $app.AppId } + } else { + $app = Get-MgApplication -Filter "appId eq '$AppId'" + if (-not $app) { throw "No application found for appId '$AppId'." } + if ($app -is [array]) { $app = $app[0] } + return @{ ObjectId = $app.Id; AppId = $app.AppId } + } +} + +function Grant-AdminConsentForScopes { + param( + [Parameter(Mandatory=$true)] [string]$BlueprintSpId, + [Parameter(Mandatory=$true)] [string]$ResourceSpId, + [Parameter(Mandatory=$true)] [string[]]$ScopesToGrant + ) + + # Flatten & de-dupe + $ScopesToGrant = @( + $ScopesToGrant | + Where-Object { $_ -and $_.Trim() -ne "" } | + ForEach-Object { $_.Trim() } | + Select-Object -Unique + ) + if ($ScopesToGrant.Count -eq 0) { + Write-Warn "No scopes to admin-approve for resource SP $ResourceSpId." + return + } + + $scopeStringToAdd = $ScopesToGrant -join " " + + Write-Info "Ensuring admin consent for scopes: $scopeStringToAdd" + + # Find existing oauth2PermissionGrant (AllPrincipals) for this client/resource + $filter = "clientId eq '$BlueprintSpId' and resourceId eq '$ResourceSpId' and consentType eq 'AllPrincipals'" + + try { + $url = "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?`$filter=$([System.Uri]::EscapeDataString($filter))" + $existing = Invoke-MgGraphRequest -Method GET -Uri $url + + $grant = $null + if ($existing.value -and $existing.value.Count -gt 0) { + $grant = $existing.value[0] + } + + if ($grant) { + # Merge scopes + $existingScopes = @() + if ($grant.scope) { + $existingScopes = $grant.scope -split ' ' | Where-Object { $_ -and $_.Trim() -ne "" } + } + $union = @($existingScopes + $ScopesToGrant | Select-Object -Unique) + $newScopeString = $union -join " " + + if ($newScopeString -eq $grant.scope) { + Write-Host "Admin consent already covers these scopes." -ForegroundColor Gray + } else { + Write-Info "Updating existing oauth2PermissionGrant $($grant.id) with scopes:" + Write-Host $newScopeString -ForegroundColor Gray + + Invoke-MgGraphRequest -Method PATCH ` + -Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$($grant.id)" ` + -Body (@{ scope = $newScopeString } | ConvertTo-Json) | Out-Null + + Write-Host "✓ Admin consent updated for Blueprint SP." -ForegroundColor Green + } + } else { + # Create new grant + $body = @{ + clientId = $BlueprintSpId + consentType = "AllPrincipals" + principalId = $null + resourceId = $ResourceSpId + scope = $scopeStringToAdd + } + + Write-Info "Creating new oauth2PermissionGrant for Blueprint SP." + Write-Host ($body | ConvertTo-Json -Depth 4) -ForegroundColor Gray + + Invoke-MgGraphRequest -Method POST ` + -Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants" ` + -Body ($body | ConvertTo-Json) | Out-Null + + Write-Host "✓ Admin consent created for Blueprint SP." -ForegroundColor Green + } + } catch { + Write-Err "Failed to admin-approve scopes for Blueprint SP." + Show-GraphError $_ + throw + } +} + +# ---------- Normalize scopes ---------- +$Scopes = @( + $Scopes | + Where-Object { $_ -and $_.Trim() -ne "" } | + ForEach-Object { $_.Trim() } | + Select-Object -Unique +) + +if ($AllAllowed -and $Scopes.Count -gt 0) { + Write-Warn "-AllAllowed provided → ignoring enumerated scopes." + $Scopes = @() +} + +# ---------- Connect ---------- +Write-Info "Connecting to Microsoft Graph..." +Connect-MgGraph -TenantId $TenantId | Out-Null + +# ---------- Resolve blueprint ---------- +try { + $bp = Resolve-Blueprint -ObjectId $AgentBlueprintObjectId -AppId $AgentBlueprintAppId +} catch { + Write-Err $_.Exception.Message + exit 1 +} + +$bpObjectId = $bp.ObjectId +$bpAppId = $bp.AppId +$bpSp = Get-MgServicePrincipal -Filter "appId eq '$bpAppId'" + +if (-not $bpSp) { + Write-Err "Blueprint service principal not found (appId $bpAppId). Ensure a service principal exists." + exit 1 +} + +Write-Info "Blueprint ObjectId: $bpObjectId" +Write-Info "Blueprint SP Id: $($bpSp.Id)" +Write-Info "Resource AppId: $ResourceAppId" +Write-Info "Scopes: $($Scopes -join ', ')" +Write-Host "" + +# ---------- Get or create resource SP ---------- +$resourceSp = Get-MgServicePrincipal -Filter "appId eq '$ResourceAppId'" + +if (-not $resourceSp -and $CreateResourceSpIfMissing) { + Write-Info "Creating service principal for resource $ResourceAppId..." + Invoke-MgGraphRequest -Method POST ` + -Uri "https://graph.microsoft.com/beta/serviceprincipals" ` + -Headers @{ "OData-Version" = "4.0" } ` + -Body (@{ appId = $ResourceAppId } | ConvertTo-Json) | Out-Null + + $resourceSp = Get-MgServicePrincipal -Filter "appId eq '$ResourceAppId'" +} + +if (-not $resourceSp) { + Write-Err "Resource service principal not found in tenant for appId '$ResourceAppId'." + exit 1 +} + +# ---------- Inheritable permissions ---------- +$baseUri = "https://graph.microsoft.com/beta/applications/microsoft.graph.agentIdentityBlueprint/$bpObjectId/inheritablePermissions" +$headers = @{ "OData-Version" = "4.0" } + +# Check existing entry +$existingEntry = $null +try { + $existing = Invoke-MgGraphRequest -Method GET -Uri $baseUri -Headers $headers + $existingEntry = @($existing.value | Where-Object { $_.resourceAppId -eq $ResourceAppId }) + if ($existingEntry.Count -gt 0) { $existingEntry = $existingEntry[0] } else { $existingEntry = $null } +} catch {} + +# ---------- AllAllowed ---------- +if ($AllAllowed) { + $payload = @{ + resourceAppId = $ResourceAppId + inheritableScopes = @{ + "@odata.type" = "microsoft.graph.allAllowedScopes" + } + } + + $json = $payload | ConvertTo-Json -Depth 5 + Write-Info "Upserting AllAllowed for resource $ResourceAppId" + Write-Host $json -ForegroundColor Gray + + try { + if ($existingEntry) { + Invoke-MgGraphRequest -Method PATCH ` + -Uri "$baseUri/$ResourceAppId" ` + -Headers $headers ` + -Body (@{ inheritableScopes = @{ "@odata.type"="microsoft.graph.allAllowedScopes" } } | ConvertTo-Json) | Out-Null + } else { + Invoke-MgGraphRequest -Method POST -Uri $baseUri -Headers $headers -Body $json | Out-Null + } + Write-Host "✓ AllAllowed inheritable scopes set successfully." -ForegroundColor Green + } catch { + Write-Err "Failed to update inheritable permissions (AllAllowed)." + Show-GraphError $_ + exit 1 + } + + # Admin-consent: all delegated scopes published by the resource + $delegatedValues = @($resourceSp.Oauth2PermissionScopes | ForEach-Object { $_.Value }) | + Where-Object { $_ -and $_.Trim() -ne "" } | + Select-Object -Unique + if ($delegatedValues.Count -gt 0) { + Grant-AdminConsentForScopes -BlueprintSpId $bpSp.Id -ResourceSpId $resourceSp.Id -ScopesToGrant $delegatedValues + } else { + Write-Warn "Resource has no delegated oauth2PermissionScopes; nothing to admin-approve." + } + + Write-Info "Done." + exit 0 +} + +# ---------- Enumerated scopes ---------- +if ($Scopes.Count -gt 0) { + + # Validate scope names against resource + if (-not $SkipScopeValidation) { + $published = @($resourceSp.Oauth2PermissionScopes | ForEach-Object { $_.Value }) + $missing = $Scopes | Where-Object { $published -notcontains $_ } + + if ($missing.Count -gt 0) { + Write-Warn "These scopes are NOT published by the resource: $($missing -join ', ')" + Write-Warn "Published: $($published -join ', ')" + } + } + + $body = @{ + resourceAppId = $ResourceAppId + inheritableScopes = @{ + "@odata.type" = "microsoft.graph.enumeratedScopes" + scopes = @($Scopes) # force array + } + } + + $json = $body | ConvertTo-Json -Depth 6 + Write-Info "Upserting enumerated scopes for resource $ResourceAppId" + Write-Host $json -ForegroundColor Gray + + try { + if ($existingEntry) { + Invoke-MgGraphRequest -Method PATCH ` + -Uri "$baseUri/$ResourceAppId" -Headers $headers -Body $json | Out-Null + } else { + Invoke-MgGraphRequest -Method POST -Uri $baseUri -Headers $headers -Body $json | Out-Null + } + Write-Host "✓ Enumerated inheritable scopes updated successfully." -ForegroundColor Green + } catch { + Write-Err "Failed to update inheritable scopes (enumerated)." + Show-GraphError $_ + exit 1 + } + + # Admin-consent only for the enumerated scopes + Grant-AdminConsentForScopes -BlueprintSpId $bpSp.Id -ResourceSpId $resourceSp.Id -ScopesToGrant $Scopes +} + +Write-Info "Done." diff --git a/dotnet/copilot-studio/relay-agent/scripts/DelegatedAgentApplicationCreateConsent.ps1 b/dotnet/copilot-studio/relay-agent/scripts/DelegatedAgentApplicationCreateConsent.ps1 new file mode 100644 index 00000000..3310874b --- /dev/null +++ b/dotnet/copilot-studio/relay-agent/scripts/DelegatedAgentApplicationCreateConsent.ps1 @@ -0,0 +1,117 @@ +<# +.SYNOPSIS +Ensures an oauth2PermissionGrant exists for a given calling App (by App ID) +to Microsoft Graph (resource) with consentType AllPrincipals, and includes the scope +"AgentApplication.Create". If a grant exists, it will be updated to include the scope. +If it doesn't exist, it will be created. + +.PARAMETER CallingAppId +The Application (client) ID of the calling app registration. + +.EXAMPLE +.\Ensure-GraphGrant.ps1 -CallingAppId "11111111-2222-3333-4444-555555555555" +#> + +[CmdletBinding(SupportsShouldProcess)] +param( + [Parameter(Mandatory=$false)] + [ValidatePattern('^[0-9a-fA-F-]{36}$')] + [string]$CallingAppId, + [Parameter(Mandatory=$false)] + [ValidatePattern('^[0-9a-fA-F-]{36}$')] + [string]$TenantId, + [switch]$NonInteractive +) + +# --- Configuration --- +$RequiredScopes = @('Application.ReadWrite.All','DelegatedPermissionGrant.ReadWrite.All') +$GraphAppId = '00000003-0000-0000-c000-000000000000' # Microsoft Graph +$TargetScope = 'AgentApplication.Create Application.ReadWrite.All' +$AllPrincipalsConsentType = 'AllPrincipals' + +function Ensure-GraphSubModule { + param([string]$Name) + if (-not (Get-Module -ListAvailable -Name $Name)) { + Write-Host "Installing module '$Name'..." -ForegroundColor Yellow + Install-Module -Name $Name -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop + } + if (-not (Get-Module -Name $Name)) { + Write-Host "Importing module '$Name'..." -ForegroundColor Cyan + Import-Module $Name -ErrorAction Stop + } +} + +function Initialize-SpecificGraphModules { + $RequiredModules = @( + 'Microsoft.Graph.Authentication', + 'Microsoft.Graph.Applications', + 'Microsoft.Graph.Identity.SignIns' + ) + foreach ($m in $RequiredModules) { Ensure-GraphSubModule -Name $m } +} + +function Connect-GraphIfNeeded { + param([string[]]$Scopes,[string]$TenantId) + if (-not (Get-MgContext)) { + Write-Host "Connecting to Microsoft Graph for tenant '$TenantId'..." -ForegroundColor Cyan + if ($TenantId) { + Connect-MgGraph -Scopes $Scopes -TenantId $TenantId -ErrorAction Stop | Out-Null + } else { + Connect-MgGraph -Scopes $Scopes -ErrorAction Stop | Out-Null + } + $ctx = Get-MgContext + Write-Host "Connected to tenant '$($ctx.TenantId)'. Account: $($ctx.Account)" -ForegroundColor Green + } else { + Write-Host "Microsoft Graph already connected (context reused)." -ForegroundColor DarkGray + } +} + +function Get-OrCreateServicePrincipalByAppId { + param([string]$AppId) + $sp = Get-MgServicePrincipal -Filter "appId eq '$AppId'" -ErrorAction Stop + if ($sp) { return $sp } else { New-MgServicePrincipal -AppId $AppId -ErrorAction Stop } +} + +function Get-GraphServicePrincipal { param([string]$AppId) Get-MgServicePrincipal -Filter "appId eq '$AppId'" -ErrorAction Stop } + +function Get-ExistingAllPrincipalsGrant { param([string]$ClientId,[string]$ResourceId) Get-MgOauth2PermissionGrant -Filter "clientId eq '$ClientId' and resourceId eq '$ResourceId' and consentType eq '$AllPrincipalsConsentType'" } + +function Ensure-ScopeOnGrant { + param($Grant,[string]$ScopeToAdd) + $existingScopes = @() + if ($Grant.Scope) { $existingScopes = $Grant.Scope -split '\s+' | Where-Object { $_ } } + if ($existingScopes -contains $ScopeToAdd) { return } + $newScope = ($existingScopes + $ScopeToAdd | Sort-Object -Unique) -join ' ' + Update-MgOauth2PermissionGrant -OAuth2PermissionGrantId $Grant.Id -Scope $newScope -ErrorAction Stop | Out-Null +} + +function Create-AllPrincipalsGrant { param([string]$ClientId,[string]$ResourceId,[string]$Scope) New-MgOauth2PermissionGrant -BodyParameter @{clientId=$ClientId;consentType=$AllPrincipalsConsentType;resourceId=$ResourceId;scope=$Scope} -ErrorAction Stop } + +try { + Initialize-SpecificGraphModules + + if (-not $TenantId) { + if ($NonInteractive) { throw "TenantId parameter required in NonInteractive mode." } + $TenantId = Read-Host "Enter your Tenant ID (GUID)" + } + if (-not ($TenantId -match '^[0-9a-fA-F-]{36}$')) { throw "Tenant ID '$TenantId' is not a valid GUID." } + + Connect-GraphIfNeeded -Scopes $RequiredScopes -TenantId $TenantId + + if (-not $CallingAppId) { + if ($NonInteractive) { throw "CallingAppId parameter required in NonInteractive mode." } + $CallingAppId = Read-Host "Enter the calling App ID (Application/Client ID)" + } + if (-not ($CallingAppId -match '^[0-9a-fA-F-]{36}$')) { throw "Calling App ID '$CallingAppId' is not a valid GUID." } + + $clientSp = Get-OrCreateServicePrincipalByAppId -AppId $CallingAppId + $graphSp = Get-GraphServicePrincipal -AppId $GraphAppId + + $existingGrants = Get-ExistingAllPrincipalsGrant -ClientId $clientSp.Id -ResourceId $graphSp.Id + if ($existingGrants) { + foreach ($grant in $existingGrants) { Ensure-ScopeOnGrant -Grant $grant -ScopeToAdd $TargetScope } + } else { Create-AllPrincipalsGrant -ClientId $clientSp.Id -ResourceId $graphSp.Id -Scope $TargetScope | Out-Null } + + Write-Host "Grant ensured for scope '$TargetScope'." -ForegroundColor Green +} +catch { Write-Error $_; exit 1 } diff --git a/dotnet/copilot-studio/relay-agent/scripts/README.md b/dotnet/copilot-studio/relay-agent/scripts/README.md new file mode 100644 index 00000000..d2be6634 --- /dev/null +++ b/dotnet/copilot-studio/relay-agent/scripts/README.md @@ -0,0 +1,104 @@ +# Kairo Setup Scripts + +This directory contains scripts to help you set up Agent Blueprint, Agent Identities, and Agent Users for the Kairo platform. + +## Creating the Agent Blueprint + +### Prerequisite – Script #1 + +Callers of this script (users in your tenant) are required to be **Global Admins** to create Agent Applications. + +Also, for you to be able to create the Agent Blueprint, you need to grant the `AgentApplication.Create` permission to the Microsoft Graph Command Line Tools application. For that, you can execute this script in PowerShell. Copied from [here](https://learn.microsoft.com/en-us/graph/permissions-reference#agentapplication-permissions): + +- `DelegatedAgentApplicationCreateConsent.ps1` + +You will need to provide: +- **Tenant ID** – navigate to "Tenant properties" for this information +- **Calling App ID** – use `14d82eec-204b-4c2f-b7e8-296a70dab67e` for Microsoft Graph Command Line Tools + +### Creating the Agent Blueprint – Script #2 + +To create the Agent Blueprint and link it to your App Service, run this script in PowerShell: + +- `createAgentBlueprint.ps1` (interactive mode) +- `createAgentBlueprint.ps1 -ConfigFile "config.json"` (config mode) + +You will need to provide: +- **Tenant ID** +- **MSI Principal ID** – this is your Object (principal) ID of the managed identity for the App Service that you created + +Sample `config.json`, replace with appropriate values: +```json +{ + "TenantId": "", + "MsiPrincipalId": "" +} +``` + +## Granting Consent for the Agent Blueprint and Enabling Inheritance + +We can grant consent at Agent Blueprint level and choose which of these permissions should be passed down to the agent identities being created from this blueprint. + +### Assign Necessary Permissions to Agent Blueprint + +Navigate to this URL to automatically give permissions to your Agent Blueprint to necessary scopes needed by your agent and for token authorization: + +``` +https://login.microsoftonline.com/{TenantId}/v2.0/adminconsent?client_id={AgentApplicationIdentity}&scope={Scopes}&redirect_uri=https://entra.microsoft.com/TokenAuthorize&state=xyz123 +``` + +**Example for Graph scopes:** +``` +https://login.microsoftonline.com/5369a35c-46a5-4677-8ff9-2e65587654e7/v2.0/adminconsent?client_id=a9c3e0c7-b2ce-46db-adf7-d60120faa0cd&scope=Mail.ReadWrite Mail.Send Chat.ReadWrite&redirect_uri=https://entra.microsoft.com/TokenAuthorize&state=xyz123 +``` + +**Example for non-Graph scopes** (Connectivity.Connections.Read needed for MCP Tools): +``` +https://login.microsoftonline.com/5369a35c-46a5-4677-8ff9-2e65587654e7/v2.0/adminconsent?client_id=416fa9f7-e69d-4e7b-8c8f-7b116634d34e&scope=0ddb742a-e7dc-4899-a31e-80e797ec7144/Connectivity.Connections.Read&redirect_uri=https://entra.microsoft.com/TokenAuthorize&state=xyz123 +``` + +For non-Graph scopes note that you need to add the resourceId to the scope: `0ddb742a-e7dc-4899-a31e-80e797ec7144/Connectivity.Connections.Read` in the example above. + +Once this is done, you should be able to see the permissions granted in the Azure portal for your agent blueprint. + +### Enable Consent Permission Inheritance for the Agent Blueprint + +Once the inheritance is set, all Agent Identities that are created get the same consents defined in the inheritance allowed list no matter when the AAI created (i.e., before or after the inheritance call is done). + +```http +POST https://graph.microsoft.com/beta/applications/microsoft.graph.agentIdentityBlueprint/{ObjectId of AA}/inheritablePermissions + +Content-Type: application/json +{ + "resourceAppId": "ResourceId of the app that we are giving the consent. e.g, Graph Resource ID" + "inheritableScopes": { + "@odata.type": "microsoft.graph.enumeratedScopes", + "scopes": [ + // ... list of scope .. // + ] + } +} +``` + +**Example:** +```http +POST https://graph.microsoft.com/beta/applications/microsoft.graph.agentIdentityBlueprint/45f01fc6-c60e-4458-ac36-731d2ddb090f/inheritablePermissions + +Content-Type: application/json +{ + "resourceAppId": "00000003-0000-0000-c000-000000000000", + "inheritableScopes": { + "@odata.type": "microsoft.graph.enumeratedScopes", + "scopes": [ + "Mail.Read", + "Mail.Send", + "Mail.ReadWrite", + "Chat.ReadWrite", + "User.ReadBasic.All" + ] + } +} +``` + +Refer to [Agent User README](README_AgentUserCreation.md) for next steps on creation agent identity, user and granting permissions at identity level. + diff --git a/dotnet/copilot-studio/relay-agent/scripts/README_AgentUserCreation.md b/dotnet/copilot-studio/relay-agent/scripts/README_AgentUserCreation.md new file mode 100644 index 00000000..96e95d50 --- /dev/null +++ b/dotnet/copilot-studio/relay-agent/scripts/README_AgentUserCreation.md @@ -0,0 +1,53 @@ +## Creating the Agent Identity & Agent User + +This process will automatically create an Agent Identity (based on the Agent Blueprint Id) and the Agent User which is coming from the Agent Identity. + +To create the Agent Identity & Agent User, run this script in PowerShell: + +- `createAgenticUser.ps1` (interactive mode) +- `createAgenticUser.ps1 -ConfigFile "config.json"` (config mode) + +You will need to provide: +- **Tenant ID** +- **Agent Blueprint ID** – this is the Application (client) ID of the agent blueprint from the previous step +- **Agent Blueprint Client Secret** – navigate to your agent blueprint > Certificates & secrets > Client secrets > New client secret to obtain this information, make sure you save this value if you want to reuse it +- **Agent Identity Display Name** – this will be the name of your Agent Identity application +- **Agent User Display Name** – this will be the name your new Agent User +- **Agent User Principal Name** – this is a unique email address of your user; it should have the domain name that exists in your tenant + +Sample `config.json`: +```json +{ + "TenantId": "", + "AgentBlueprintId": "", + "AgentBlueprintClientSecret": "", + "AgentIdentityDisplayName": "Hello World Identity", + "AgentUserDisplayName": "Hello World User", + "AgentUserPrincipalName": "helloworld-user@" +} +``` + +## Granting Consent for the Agent Identity + +Navigate to this URL to automatically give permissions to your Agent Identity (and Agent User) to access Graph, which is needed for the token exchange: + +**Example for Graph scopes:** +``` +https://login.microsoftonline.com//v2.0/adminconsent?client_id=&scope=User.ReadBasic.All Mail.Send Mail.Read Chat.Read Chat.ReadWrite&redirect_uri=https://entra.microsoft.com/TokenAuthorize&state=xyz123 +``` + +**Note:** This is giving access to `User.ReadBasic.All Mail.Send Mail.Read Chat.Read Chat.ReadWrite`, you can expand the permissions if needed. +if it redirects after accepting using permissions and we see https://entra.microsoft.com/TokenAuthorize?admin_consent=True in the url, then its done. Permissions can be validated for the Agent Identity Id in the azure portal. + +**Example for non-Graph scopes** (Connectivity.Connections.Read needed for MCP Tools): +``` +https://login.microsoftonline.com/5369a35c-46a5-4677-8ff9-2e65587654e7/v2.0/adminconsent?client_id=416fa9f7-e69d-4e7b-8c8f-7b116634d34e&scope=0ddb742a-e7dc-4899-a31e-80e797ec7144/Connectivity.Connections.Read&redirect_uri=https://entra.microsoft.com/TokenAuthorize&state=xyz123 +``` + +For non-Graph scopes note that you need to add the resourceId to the scope: `0ddb742a-e7dc-4899-a31e-80e797ec7144/Connectivity.Connections.Read` in the example above. + +Once this is done, you should be able to see the permissions granted in the Azure portal for your agent application. + +For MCP tools you need to get the resource id for Power Platform API (and Power Platform API - Test). To get these, search for these apps in your azure tenant, open the service principal and copy the application id. + +Alternatively, you can manage permissions by going to your Agent Identity in Azure portal and navigating to Security > Permissions. \ No newline at end of file diff --git a/dotnet/copilot-studio/relay-agent/scripts/createAgentBlueprint.ps1 b/dotnet/copilot-studio/relay-agent/scripts/createAgentBlueprint.ps1 new file mode 100644 index 00000000..0df9ecdd --- /dev/null +++ b/dotnet/copilot-studio/relay-agent/scripts/createAgentBlueprint.ps1 @@ -0,0 +1,598 @@ +# Parameters +param( + [Parameter(Mandatory=$false)] + [string]$ConfigFile, + + [Parameter(Mandatory=$false)] + [string]$OutputJsonPath +) + +# Function to read configuration from JSON file +function Read-ConfigFile { + param( + [Parameter(Mandatory=$true)] + [string]$ConfigFilePath + ) + + if (-not (Test-Path $ConfigFilePath)) { + Write-Host "ERROR: Config file not found: $ConfigFilePath" -ForegroundColor Red + exit 1 + } + + try { + $configContent = Get-Content $ConfigFilePath -Raw | ConvertFrom-Json + + # Validate required properties + if (-not $configContent.TenantId) { + Write-Host "ERROR: Config file is missing 'TenantId' property" -ForegroundColor Red + exit 1 + } + + # Support both MsiPrincipalId and ManagedIdentityId for flexibility + $msiId = $null + if ($configContent.MsiPrincipalId) { + $msiId = $configContent.MsiPrincipalId + } elseif ($configContent.ManagedIdentityId) { + $msiId = $configContent.ManagedIdentityId + } + + if (-not $msiId) { + Write-Host "ERROR: Config file is missing 'MsiPrincipalId' or 'ManagedIdentityId' property" -ForegroundColor Red + exit 1 + } + + # Add the standardized property to the config object + $configContent | Add-Member -NotePropertyName "MsiPrincipalId" -NotePropertyValue $msiId -Force + + return $configContent + } catch { + Write-Host "ERROR: Failed to parse config file: $($_.Exception.Message)" -ForegroundColor Red + exit 1 + } +} + +# Function to create Agent Blueprint +function createAgentBlueprint { + param( + [Parameter(Mandatory=$true)] + [string]$TenantId, + + [Parameter(Mandatory=$true)] + [string]$DisplayName + ) + + try { + + $currentUser = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/me" + Write-Host "Current user: $($currentUser.DisplayName) <$($currentUser.UserPrincipalName)>" -ForegroundColor Gray + Write-Host "Sponsor details: "https://graph.microsoft.com/v1.0/users/$($currentUser.Id)" -ForegroundColor Gray" + + $body = @{ + "@odata.type" = "Microsoft.Graph.AgentIdentityBlueprint" + displayName = $DisplayName + "sponsors@odata.bind" = @("https://graph.microsoft.com/v1.0/users/$($currentUser.Id)") + } + $response = Invoke-MgGraphRequest -Method POST ` + -Uri "https://graph.microsoft.com/beta/applications/" ` + -Headers @{ "OData-Version" = "4.0" } ` + -Body ($body | ConvertTo-Json) + } + catch { + + if ($_.Exception.Response.StatusCode.value__ -eq 400) + { + Write-Host "Agent Blueprint creation failed with Bad Request (400). Fallback to call without sponsor request..." + + $body = @{ + "@odata.type" = "Microsoft.Graph.AgentIdentityBlueprint" + displayName = $DisplayName + } + $fallbackResponse = Invoke-MgGraphRequest -Method POST ` + -Uri "https://graph.microsoft.com/beta/applications/" ` + -Headers @{ "OData-Version" = "4.0" } ` + -Body ($body | ConvertTo-Json) + + return $fallbackResponse + } + } + return $response +} + +# Function to create Service Principal +function createServicePrincipal { + param( + [Parameter(Mandatory=$true)] + [string]$TenantId, + + [Parameter(Mandatory=$true)] + [string]$AppId + ) + + $body = @{ + appId = $AppId + } + #this is a workaround needed until the serviceprincipals/graph.agentServicePrincipal is supported in the new tenants. + $response = Invoke-MgGraphRequest -Method POST ` + -Uri "https://graph.microsoft.com/beta/serviceprincipals" ` + -Headers @{ "OData-Version" = "4.0" } ` + -Body ($body | ConvertTo-Json) + + return $response +} + +# Function to create Federated Identity Credential +function createFederatedIdentityCredential { + param( + [Parameter(Mandatory=$true)] + [string]$TenantId, + + [Parameter(Mandatory=$true)] + [string]$AgentBlueprintObjectId, + + [Parameter(Mandatory=$true)] + [string]$CredentialName, + + [Parameter(Mandatory=$true)] + [string]$MsiPrincipalId + ) + + $federatedCredential = @{ + Name = $CredentialName + Issuer = "https://login.microsoftonline.com/$TenantId/v2.0" + Subject = $MsiPrincipalId + Audiences = @("api://AzureADTokenExchange") + } + + $response = New-MgApplicationFederatedIdentityCredential ` + -ApplicationId $AgentBlueprintObjectId ` + -BodyParameter $federatedCredential + + return $response +} + +# Function to read MCP scopes from ToolingManifest.json +function Get-McpScopesFromManifest { + param( + [Parameter(Mandatory=$false)] + [string]$ManifestPath + ) + + $mcpScopes = @() + + if ([string]::IsNullOrWhiteSpace($ManifestPath)) { + # Default path - look for ToolingManifest.json in the script directory + $defaultManifestPath = Join-Path $PSScriptRoot "ToolingManifest.json" + if (Test-Path $defaultManifestPath) { + $ManifestPath = $defaultManifestPath + } else { + Write-Host "INFO: No ToolingManifest.json found at $defaultManifestPath, skipping MCP scopes" -ForegroundColor Yellow + return $mcpScopes + } + } + + if (-not (Test-Path $ManifestPath)) { + Write-Host "INFO: ToolingManifest.json not found at $ManifestPath, skipping MCP scopes" -ForegroundColor Yellow + return $mcpScopes + } + + try { + Write-Host "Reading MCP scopes from: $ManifestPath" -ForegroundColor Cyan + $manifest = Get-Content $ManifestPath -Raw | ConvertFrom-Json + + if ($manifest.mcpServers -and $manifest.mcpServers.Count -gt 0) { + foreach ($server in $manifest.mcpServers) { + # Support both 'scope' (new CLI format) and 'requiredScopes' (legacy) properties + if ($server.scope -and -not [string]::IsNullOrWhiteSpace($server.scope)) { + if ($mcpScopes -notcontains $server.scope) { + $mcpScopes += $server.scope + Write-Host " Found MCP scope: $server.scope (from mcpServerName: $($server.mcpServerName))" -ForegroundColor Green + } + } + elseif ($server.requiredScopes -and $server.requiredScopes.Count -gt 0) { + foreach ($scope in $server.requiredScopes) { + if (-not [string]::IsNullOrWhiteSpace($scope) -and $mcpScopes -notcontains $scope) { + $mcpScopes += $scope + Write-Host " Found MCP scope: $scope (legacy requiredScopes)" -ForegroundColor Green + } + } + } + } + } + + if ($mcpScopes.Count -gt 0) { + Write-Host "Total MCP scopes found: $($mcpScopes.Count)" -ForegroundColor Cyan + } else { + Write-Host "No MCP scopes found in manifest" -ForegroundColor Yellow + } + + } catch { + Write-Host "WARNING: Failed to read ToolingManifest.json: $($_.Exception.Message)" -ForegroundColor Yellow + Write-Host "Continuing without MCP scopes..." -ForegroundColor Yellow + } + + return $mcpScopes +} + +# Function to configure Agent Blueprint Scope +function configureAgentBlueprintScope { + param( + [Parameter(Mandatory=$true)] + [string]$TenantId, + + [Parameter(Mandatory=$true)] + [string]$AgentBlueprintObjectId, + + [Parameter(Mandatory=$true)] + [string]$AgentBlueprintId, + + [Parameter(Mandatory=$false)] + [string]$ToolingManifestPath + ) + + $IdentifierUri = "api://$AgentBlueprintId" + + # Start with the default access_agent scope + $scopes = @() + + # Add default agent access scope + $defaultScopeId = [guid]::NewGuid() + $defaultScope = @{ + adminConsentDescription = "Allow the application to access the agent on behalf of the signed-in user." + adminConsentDisplayName = "Access agent" + id = $defaultScopeId + isEnabled = $true + type = "User" + value = "access_agent" + } + $scopes += $defaultScope + + # Read and add MCP scopes from ToolingManifest.json + $mcpScopes = Get-McpScopesFromManifest -ManifestPath $ToolingManifestPath + + foreach ($mcpScope in $mcpScopes) { + $mcpScopeId = [guid]::NewGuid() + $mcpScopeObj = @{ + adminConsentDescription = "Allow the application to access MCP server requiring scope: $mcpScope" + adminConsentDisplayName = "MCP Access: $mcpScope" + id = $mcpScopeId + isEnabled = $true + type = "User" + value = $mcpScope + } + $scopes += $mcpScopeObj + Write-Host "Added MCP scope to blueprint: $mcpScope" -ForegroundColor Green + } + + Write-Host "Configuring blueprint with $($scopes.Count) OAuth2 permission scopes" -ForegroundColor Cyan + + $response = Update-MgApplication -ApplicationId $AgentBlueprintObjectId ` + -IdentifierUris @($IdentifierUri) ` + -Api @{ oauth2PermissionScopes = $scopes } + + return $response +} + +# Helper: get service principal (object) id for a given appId in this tenant +function Get-ServicePrincipalObjectIdByAppId { + param( + [Parameter(Mandatory=$true)] + [string]$AppId + ) + + if ([string]::IsNullOrWhiteSpace($AppId)) { + throw "AppId must be provided" + } + + try { + # Use the SDK cmdlet to find the service principal in this tenant + $sp = Get-MgServicePrincipal -Filter "appId eq '$AppId'" + + if (-not $sp -or ($sp -is [System.Array] -and $sp.Count -eq 0)) { + throw "No servicePrincipal found for appId '$AppId' in this tenant." + } + + # If multiple returned, pick the first + $servicePrincipal = $sp + if ($sp -is [System.Array]) { $servicePrincipal = $sp[0] } + + return $servicePrincipal.Id + } catch { + Write-Host "ERROR: Failed to retrieve service principal for appId '$AppId'." -ForegroundColor Red + Write-Host " Details: $($_.Exception.Message)" -ForegroundColor Gray + throw + } +} + +#Function to grant admin consent for specified Microsoft Graph scopes +function grantGraphScopeConsent { + param( + [Parameter(Mandatory=$true)] + [string]$TenantId, + + [Parameter(Mandatory=$true)] + [string]$ServicePrincipalId, + + [Parameter(Mandatory=$true)] + [string]$Scopes # List of scopes, e.g., "User.Read Directory.Read.All" + ) + + # Well-known Microsoft Graph application id (appId). We'll resolve its service principal (object id) in this tenant. + $GraphAppId = "00000003-0000-0000-c000-000000000000" + + try { + $GraphServicePrincipalId = Get-ServicePrincipalObjectIdByAppId -AppId $GraphAppId + Write-Host "Found Microsoft Graph service principal in this tenant: $GraphServicePrincipalId" -ForegroundColor Gray + } catch { + Write-Host "ERROR: Unable to determine Microsoft Graph service principal in this tenant." -ForegroundColor Red + Write-Host " Details: $($_.Exception.Message)" -ForegroundColor Gray + exit 1 + } + + $body = @{ + clientId = $ServicePrincipalId + consentType = "AllPrincipals" + principalId = $null + resourceId = $GraphServicePrincipalId + scope = $Scopes + } + + try { + $response = Invoke-MgGraphRequest -Method POST ` + -Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants" ` + -Body ($body | ConvertTo-Json) + Write-Host "Admin consent granted for scope '$Scopes'." -ForegroundColor Green + } catch { + Write-Host "ERROR: Failed to grant admin consent for scope '$Scopes'." -ForegroundColor Red + Write-Host " Details: $($_.Exception.Message) $($_.Exception.InnerException.Message) " -ForegroundColor Gray + exit 1 + } +} + +function addInheritablePermissionsForAgentIdentities { + param( + [Parameter(Mandatory=$true)] + [string]$AgentBlueprintObjectId, + + [Parameter(Mandatory=$true)] + [string]$ResourceAppId, # e.g., "00000003-0000-0000-c000-000000000000" for Graph + + [Parameter(Mandatory=$true)] + [string[]]$Scopes # Array of scopes, e.g., @("User.Read", "Mail.Send") + ) + + $body = @{ + resourceAppId = $ResourceAppId + inheritableScopes = @{ + "@odata.type" = "microsoft.graph.enumeratedScopes" + scopes = $Scopes + } + } + + try { + $response = Invoke-MgGraphRequest -Method POST ` + -Uri "https://graph.microsoft.com/beta/applications/microsoft.graph.agentIdentityBlueprint/$AgentBlueprintObjectId/inheritablePermissions" ` + -Body ($body | ConvertTo-Json) + Write-Host "Inheritable permissions added for agent identities." -ForegroundColor Green + return $response + } catch { + Write-Host "ERROR: Failed to add inheritable permissions for agent identities." -ForegroundColor Red + Write-Host " Details: $($_.Exception.Message)" -ForegroundColor Gray + exit 1 + } +} + + +# Display script header +Write-Host "" +Write-Host "================================================================================================" -ForegroundColor Cyan +Write-Host " Agent Blueprint Creation Script " -ForegroundColor Cyan +Write-Host "================================================================================================" -ForegroundColor Cyan +Write-Host "" + +# Initialize variables +$TenantId = $null +$MsiPrincipalId = $null +$DisplayName = $null + +# Check if config file is provided +if ($ConfigFile) { + Write-Host "Reading configuration from file: $ConfigFile" -ForegroundColor Yellow + $config = Read-ConfigFile -ConfigFilePath $ConfigFile + $TenantId = $config.TenantId + $MsiPrincipalId = $config.MsiPrincipalId + + # Read DisplayName from config + $DisplayName = $config.AgentBlueprintDisplayName + + Write-Host "Configuration loaded successfully!" -ForegroundColor Green + Write-Host " • Tenant ID: $TenantId" -ForegroundColor Gray + Write-Host " • MSI Principal ID: $MsiPrincipalId" -ForegroundColor Gray + if ($DisplayName) { + Write-Host " • Display Name: $DisplayName" -ForegroundColor Gray + } + Write-Host "" +} else { + # Prompt user for input when config file is not provided + Write-Host "Please provide the following information:" -ForegroundColor Yellow + Write-Host "" + Write-Host "Tenant ID: " -ForegroundColor Yellow -NoNewline + $TenantId = Read-Host + + Write-Host "" + Write-Host "Object (Principal) ID of the managed identity (optional - provide if you have an app service ready): " -ForegroundColor Yellow -NoNewline + $MsiPrincipalId = Read-Host +} + +Write-Host "" + +try { + Connect-AzAccount -TenantId $TenantId + Connect-MgGraph -TenantId $TenantId +} catch { + Write-Host "ERROR: Failed to connect to Microsoft Graph. Please ensure you have the Microsoft Graph PowerShell SDK installed and try again." -ForegroundColor Red + exit 1 +} + +# Validate that TenantId is not empty +if ([string]::IsNullOrWhiteSpace($TenantId) -or -not ($TenantId -match '^[0-9a-fA-F-]{36}$')) { + Write-Host "ERROR: Invalid Tenant ID format. Please provide a valid GUID." -ForegroundColor Red + Write-Host " Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -ForegroundColor Gray + exit 1 +} + +# Validate MSI Principal ID +if ($MsiPrincipalId -and -not ($MsiPrincipalId -match '^[0-9a-fA-F-]{36}$')) { + Write-Host "ERROR: Invalid Object (Principal) ID format. Please provide a valid GUID." -ForegroundColor Red + Write-Host " Expected format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -ForegroundColor Gray + exit 1 +} + +# Prompt user for Display Name only if not provided in config +if ([string]::IsNullOrWhiteSpace($DisplayName)) { + Write-Host "Display Name for the Agent Application: " -ForegroundColor Yellow -NoNewline + $DisplayName = Read-Host + + if ([string]::IsNullOrWhiteSpace($DisplayName)) { + Write-Host "ERROR: Display Name cannot be empty." -ForegroundColor Red + exit 1 + } +} + +Write-Host "" +Write-Host "Configuration Summary:" -ForegroundColor Blue +Write-Host " • Tenant ID: $TenantId" -ForegroundColor Gray +Write-Host " • MSI Principal ID: $MsiPrincipalId" -ForegroundColor Gray +Write-Host " • Display Name: $DisplayName" -ForegroundColor Gray +Write-Host "" + +Write-Host "Starting Agent Blueprint creation..." -ForegroundColor Blue +Write-Host "------------------------------------------------------------------------------------------------" -ForegroundColor Gray + +# 1. Create Agent Blueprint +Write-Host "" +Write-Host "Step 1/6: Creating Agent Blueprint..." -ForegroundColor Yellow +try { + $agentBlueprint = createAgentBlueprint -TenantId $TenantId -DisplayName $DisplayName + Write-Host "Agent Blueprint created successfully!" -ForegroundColor Green + Write-Host " App ID: $($agentBlueprint.appId)" -ForegroundColor Cyan + Write-Host " Object ID: $($agentBlueprint.id)" -ForegroundColor Cyan +} catch { + Write-Host "ERROR: Failed to create Agent Blueprint" -ForegroundColor Red + Write-Host " Details: $($_.Exception.Message)" -ForegroundColor Gray + exit 1 +} + +# 2. Create Service Principal for the Agent Blueprint +Write-Host "" +Write-Host "Step 2/6: Creating Service Principal..." -ForegroundColor Yellow +try { + $servicePrincipal = createServicePrincipal -TenantId $TenantId -AppId $agentBlueprint.appId + Write-Host "Service Principal created successfully!" -ForegroundColor Green + Write-Host " App ID: $($servicePrincipal.appId)" -ForegroundColor Cyan + Write-Host " Object ID: $($servicePrincipal.id)" -ForegroundColor Cyan +} catch { + Write-Host "ERROR: Failed to create Service Principal" -ForegroundColor Red + Write-Host " Details: $($_.Exception.Message)" -ForegroundColor Gray + exit 1 +} + +Write-Host "Waiting 10 seconds to ensure Service Principal is fully propagated..." -ForegroundColor Gray +Start-Sleep -Seconds 10 + +# 3. Create Federated Identity Credential +Write-Host "" +if ($MsiPrincipalId) { +Write-Host "Step 3/6: Creating Federated Identity Credential..." -ForegroundColor Yellow +$CredentialName = "$($DisplayName -replace '\s+', '')-MSI" + + try { + $federatedCredential = createFederatedIdentityCredential -TenantId $TenantId -AgentBlueprintObjectId $agentBlueprint.id -CredentialName $CredentialName -MsiPrincipalId $MsiPrincipalId + Write-Host "Federated Identity Credential created successfully!" -ForegroundColor Green + Write-Host " Credential Name: $DisplayName-MSI" -ForegroundColor Cyan + } catch { + Write-Host "ERROR: Failed to create Federated Identity Credential" -ForegroundColor Red + Write-Host " Details: $($_.Exception.Message)" -ForegroundColor Gray + exit 1 + } +} else { + Write-Host "Skipping Step 3/6 (Federated Identity Credential creation): MSI Principal ID is not provided." -ForegroundColor Yellow +} + + +# 4. Configure Agent Blueprint Scope +Write-Host "" +Write-Host "Step 4/6: Configuring Agent Blueprint Scope..." -ForegroundColor Yellow +try { + # Look for ToolingManifest.json in the deployment project path if available, otherwise script directory + $toolingManifestPath = $null + if ($ConfigFile -and $config.deploymentProjectPath) { + $toolingManifestPath = Join-Path $config.deploymentProjectPath "ToolingManifest.json" + Write-Host "Looking for ToolingManifest.json in deployment project path: $toolingManifestPath" -ForegroundColor Gray + } else { + $toolingManifestPath = Join-Path $PSScriptRoot "ToolingManifest.json" + Write-Host "Looking for ToolingManifest.json in script directory: $toolingManifestPath" -ForegroundColor Gray + } + + $configuredApp = configureAgentBlueprintScope -TenantId $TenantId -AgentBlueprintObjectId $agentBlueprint.id -AgentBlueprintId $agentBlueprint.appId -ToolingManifestPath $toolingManifestPath + Write-Host "Agent Blueprint scope configured successfully!" -ForegroundColor Green + Write-Host " Identifier URI: api://$($agentBlueprint.appId)" -ForegroundColor Cyan + Write-Host " Default scope: access_agent" -ForegroundColor Cyan +} catch { + Write-Host "ERROR: Failed to configure Agent Blueprint scope" -ForegroundColor Red + Write-Host " Details: $($_.Exception.Message)" -ForegroundColor Gray + exit 1 +} + +# 5. Grant Agent Blueprint consent to required graph scopes +$scopes = "Chat.ReadWrite Files.Read.All Mail.ReadWrite Mail.Send Sites.Read.All User.Read.All" + +Write-Host "" +Write-Host "Step 5/6: Granting Admin Consent for Graph Scopes..." -ForegroundColor Yellow +try { + grantGraphScopeConsent -TenantId $TenantId -ServicePrincipalId $servicePrincipal.id -Scopes $scopes +} catch { + Write-Host "ERROR: Failed to grant admin consent for Graph scopes" -ForegroundColor Red + Write-Host " Details: $($_.Exception.Message)" -ForegroundColor Gray + exit 1 +} + +# 6. Add inheritable perms for these scopes for agent identities created using this agent blueprint. +Write-Host "" +Write-Host "Step 6/6: Adding Inheritable Permissions for Agent Identities..." -ForegroundColor Yellow +try { + addInheritablePermissionsForAgentIdentities -AgentBlueprintObjectId $agentBlueprint.id -ResourceAppId "00000003-0000-0000-c000-000000000000" -Scopes $scopes +} catch { + Write-Host "ERROR: Failed to add inheritable permissions for agent identities" -ForegroundColor Red + Write-Host " Details: $($_.Exception.Message)" -ForegroundColor Gray + exit 1 +} + +Write-Host "" +Write-Host "================================================================================================" -ForegroundColor Green +Write-Host " INSTALLATION COMPLETED SUCCESSFULLY! " -ForegroundColor Green +Write-Host "================================================================================================" -ForegroundColor Green +Write-Host "" +Write-Host "Agent Blueprint Details:" -ForegroundColor Yellow +Write-Host " • Display Name: $DisplayName" -ForegroundColor White +Write-Host " • App ID: $($agentBlueprint.appId)" -ForegroundColor White +Write-Host " • Object ID: $($agentBlueprint.id)" -ForegroundColor White +Write-Host " • Service Principal ID: $($servicePrincipal.id)" -ForegroundColor White +Write-Host " • Identifier URI: api://$($agentBlueprint.appId)" -ForegroundColor White + +# Write output to JSON file if OutputJsonPath is provided +if ($OutputJsonPath) { + Write-Host "" + Write-Host "Writing output to: $OutputJsonPath" -ForegroundColor Cyan + + $outputData = @{ + AgentBlueprintId = $agentBlueprint.appId + AgentBlueprintObjectId = $agentBlueprint.id + DisplayName = $DisplayName + ServicePrincipalId = $servicePrincipal.id + IdentifierUri = "api://$($agentBlueprint.appId)" + TenantId = $TenantId + } + + $outputData | ConvertTo-Json -Depth 10 | Set-Content -Path $OutputJsonPath -Encoding UTF8 + Write-Host "Output written successfully" -ForegroundColor Green +} diff --git a/dotnet/copilot-studio/relay-agent/scripts/createAgenticUser.ps1 b/dotnet/copilot-studio/relay-agent/scripts/createAgenticUser.ps1 new file mode 100644 index 00000000..381c10dc --- /dev/null +++ b/dotnet/copilot-studio/relay-agent/scripts/createAgenticUser.ps1 @@ -0,0 +1,447 @@ +param( + [Parameter(Mandatory=$false)] + [string]$ConfigFile +) + +function Get-AgentBlueprintTokenForGraph { + param( + [Parameter(Mandatory=$true)] + [string]$TenantId, + + [Parameter(Mandatory=$true)] + [string]$AgentBlueprintId, + + [Parameter(Mandatory=$false)] + [string]$MsiToken, + + [Parameter(Mandatory=$false)] + [string]$ClientSecret + ) + + $tokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" + + $body = @{ + 'client_id' = $AgentBlueprintId + 'scope' = '00000003-0000-0000-c000-000000000000/.default' + 'grant_type' = 'client_credentials' + } + + # Use MSI token if provided, otherwise use client secret - we are using client secret for now + if ($MsiToken) { + $body['client_assertion_type'] = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + $body['client_assertion'] = $MsiToken + } + elseif ($ClientSecret) { + $body['client_secret'] = $ClientSecret + } + else { + throw "Either MsiToken or ClientSecret must be provided" + } + + $headers = @{ + 'Content-Type' = 'application/x-www-form-urlencoded' + } + + $response = Invoke-RestMethod -Uri $tokenEndpoint -Method POST -Body $body -Headers $headers + return $response +} + +function New-AgentIdentity { + param( + [Parameter(Mandatory=$true)] + [string]$AccessToken, + + [Parameter(Mandatory=$true)] + [string]$DisplayName, + + [Parameter(Mandatory=$true)] + [string]$AgentBlueprintId + ) + + $currentUser = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/me" + + $uri = "https://graph.microsoft.com/beta/serviceprincipals/Microsoft.Graph.AgentIdentity" + + $headers = @{ + 'OData-Version' = '4.0' + 'Content-Type' = 'application/json' + 'Authorization' = "Bearer $AccessToken" + } + + try { + $body = @{ + 'displayName' = $DisplayName + 'agentAppId' = $AgentBlueprintId + "sponsors@odata.bind" = @("https://graph.microsoft.com/v1.0/users/$($currentUser.Id)") + } | ConvertTo-Json + + $response = Invoke-RestMethod -Uri $uri -Method POST -Headers $headers -Body $body + } + catch { + + if ($_.Exception.Response.StatusCode.value__ -eq 400) + { + Write-Host "Agent Blueprint creation failed with Bad Request (400). Fallback to call without sponsor request..." + + $body = @{ + 'displayName' = $DisplayName + 'agentAppId' = $AgentBlueprintId + } | ConvertTo-Json + + $fallbackResponse = Invoke-RestMethod -Uri $uri -Method POST -Headers $headers -Body $body + + return $fallbackResponse + } + } + return $response +} + +function New-AgentUser { + param( + [Parameter(Mandatory=$true)] + [string]$DisplayName, + + [Parameter(Mandatory=$true)] + [string]$UserPrincipalName, + + [Parameter(Mandatory=$true)] + [string]$MailNickname, + + [Parameter(Mandatory=$true)] + [string]$AgentIdentityId, + + [Parameter(Mandatory=$false)] + [bool]$AccountEnabled = $true, + + [Parameter(Mandatory=$false)] + [string]$UsageLocation = 'US' + ) + + # Connect to Graph with beta profile + Connect-MgGraph -Scopes "User.ReadWrite.All" -TenantId $TenantId + + # Define request body + $body = @{ + "@odata.type" = "microsoft.graph.agentUser" + displayName = $DisplayName + userPrincipalName = $UserPrincipalName + mailNickname = $MailNickname + accountEnabled = $AccountEnabled + usageLocation = $UsageLocation + identityParent = @{ + id = $AgentIdentityId + } + } | ConvertTo-Json -Depth 5 + + + # Check if user already exists + try { + $existingUser = Get-AzADUser -ObjectId $UserPrincipalName -ErrorAction Stop + Write-Host "User already exists: $($existingUser.DisplayName) ($($existingUser.UserPrincipalName))." -ForegroundColor Yellow + Write-Host "Using existing user instead of creating new one." -ForegroundColor Green + + # Create a response object that matches the expected format + $response = @{ + id = $existingUser.Id + displayName = $existingUser.DisplayName + userPrincipalName = $existingUser.UserPrincipalName + usageLocation = $existingUser.UsageLocation + } + return $response + } + catch { + # User does not exist, proceed with creation + } + + $response = Invoke-MgGraphRequest -Method POST -Uri "https://graph.microsoft.com/beta/users" -Body $body -ContentType "application/json" + return $response +} + +function Set-AgentUserManager { + param( + [Parameter(Mandatory=$true)] + [string]$UserId, + + [Parameter(Mandatory=$true)] + [string]$ManagerEmail + ) + + try { + Write-Host " Looking up manager with email: $ManagerEmail" -ForegroundColor Gray + $manager = Get-MgUser -Filter "mail eq '$ManagerEmail'" -ErrorAction Stop + if ($manager) { + $managerId = $manager.Id + Write-Host " Found manager: $($manager.DisplayName) (ID: $managerId)" -ForegroundColor Gray + + $body = @{ + "@odata.id" = "https://graph.microsoft.com/v1.0/users/$managerId" + } | ConvertTo-Json + + Invoke-MgGraphRequest -Method PUT -Uri "https://graph.microsoft.com/v1.0/users/$UserId/manager/`$ref" -Body $body -ContentType "application/json" + + return $manager + } else { + Write-Host "WARNING: Manager with email '$ManagerEmail' not found. Skipping manager assignment." -ForegroundColor Yellow + return $null + } + } + catch { + Write-Host "ERROR: Failed to assign manager." -ForegroundColor Red + Write-Host " Details: $($_.Exception.Message)" -ForegroundColor Gray + # Don't exit, just warn. + } +} + +# Display script header +Write-Host "" +Write-Host "================================================================================================" -ForegroundColor Cyan +Write-Host " Agent User Creation Script " -ForegroundColor Cyan +Write-Host "================================================================================================" -ForegroundColor Cyan +Write-Host "" + + +# Check for configuration file parameter +if ($ConfigFile -and (Test-Path $ConfigFile)) { + Write-Host "Reading configuration from file: $ConfigFile" -ForegroundColor Blue + Write-Host "" + + try { + $config = Get-Content $ConfigFile | ConvertFrom-Json + + $TenantId = $config.TenantId + $AgentBlueprintId = $config.AgentBlueprintId + $ClientSecret = $config.AgentBlueprintClientSecret + $agentIdentityName = $config.AgentIdentityDisplayName + $agentUserDisplayName = $config.AgentUserDisplayName + $userPrincipalName = $config.AgentUserPrincipalName + $mailNickname = $userPrincipalName.split('@')[0] + $managerEmail = $config.ManagerEmail + $usageLocation = $config.UsageLocation + $existingAgentIdentityId = $config.AgentIdentityId + $existingAgentUserId = $config.AgentUserId + + Write-Host "Configuration loaded successfully!" -ForegroundColor Green + Write-Host " • Tenant ID: $TenantId" -ForegroundColor Gray + Write-Host " • Agent Blueprint ID: $AgentBlueprintId" -ForegroundColor Gray + Write-Host " • Agent Identity Display Name: $agentIdentityName" -ForegroundColor Gray + Write-Host " • Agent User Display Name: $agentUserDisplayName" -ForegroundColor Gray + Write-Host " • Agent User Principal Name: $userPrincipalName" -ForegroundColor Gray + Write-Host " • Agent User Mail Nickname: $mailNickname" -ForegroundColor Gray + if ($managerEmail) { + Write-Host " • Manager Email: $managerEmail" -ForegroundColor Gray + } + if ($usageLocation) { + Write-Host " • Usage Location: $usageLocation" -ForegroundColor Gray + } + } + catch { + Write-Host "ERROR: Failed to read configuration file" -ForegroundColor Red + Write-Host " Details: $($_.Exception.Message)" -ForegroundColor Gray + exit 1 + } +} +else { + # Interactive mode - prompt for configuration + Write-Host "Configuration:" -ForegroundColor Blue + Write-Host " • Tenant ID: " -ForegroundColor Gray -NoNewline + $TenantId = Read-Host + Write-Host $TenantId -ForegroundColor White + + Write-Host " • Agent Blueprint ID: " -ForegroundColor Gray -NoNewline + $AgentBlueprintId = Read-Host + Write-Host $AgentBlueprintId -ForegroundColor White + + Write-Host " • Agent Blueprint Client Secret: " -ForegroundColor Gray -NoNewline + $ClientSecret = Read-Host + +} + +Write-Host "" +Write-Host "Starting Agent User creation process..." -ForegroundColor Blue +Write-Host "------------------------------------------------------------------------------------------------" -ForegroundColor Gray + +try { + Connect-MgGraph -TenantId $TenantId +} catch { + Write-Host "ERROR: Failed to connect to Microsoft Graph. Please ensure you have the Microsoft Graph PowerShell SDK installed and try again." -ForegroundColor Red +exit 1 +} + +# 1. Get Agent Blueprint token +Write-Host "" +Write-Host "Step 1/4: Getting Agent Blueprint token..." -ForegroundColor Yellow +try { + + $agentBlueprintGraphToken = Get-AgentBlueprintTokenForGraph -TenantId $TenantId -AgentBlueprintId $AgentBlueprintId -ClientSecret $ClientSecret + Write-Host "Token retrieved successfully!" -ForegroundColor Green + Write-Host " Token Type: $($agentBlueprintGraphToken.token_type)" -ForegroundColor Cyan + $expiresInMinutes = [math]::Round($agentBlueprintGraphToken.expires_in / 60, 1) + Write-Host " Expires In: $expiresInMinutes minutes" -ForegroundColor Cyan + +} catch { + Write-Host "ERROR: Failed to get Agent Blueprint token" -ForegroundColor Red + Write-Host " Details: $($_.Exception.Message)" -ForegroundColor Gray + exit 1 +} + + +# 2. Create Agent Identity (or reuse existing) +Write-Host "" +Write-Host "Step 2/4: Creating Agent Identity..." -ForegroundColor Yellow + +# Check if agent identity already exists (idempotent) +if ($existingAgentIdentityId) { + Write-Host "Checking for existing agent identity with ID: $existingAgentIdentityId" -ForegroundColor Gray + try { + $uri = "https://graph.microsoft.com/beta/servicePrincipals/$existingAgentIdentityId" + $headers = @{ 'Authorization' = "Bearer $($agentBlueprintGraphToken.access_token)" } + $existingIdentity = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers -ErrorAction Stop + + Write-Host "Found existing agent identity!" -ForegroundColor Green + Write-Host " Agent Identity ID: $($existingIdentity.id)" -ForegroundColor Cyan + Write-Host " Display Name: $($existingIdentity.displayName)" -ForegroundColor Cyan + $agentIdentity = $existingIdentity + } + catch { + Write-Host "Existing identity not found (may have been deleted), creating new..." -ForegroundColor Yellow + $existingAgentIdentityId = $null + } +} + +# Create new agent identity if none exists +if (-not $existingAgentIdentityId -or -not $agentIdentity) { + if (-not $agentIdentityName) { + Write-Host "Display Name for Agent Identity: " -ForegroundColor Yellow -NoNewline + $agentIdentityName = Read-Host + } + + try { + $agentIdentity = New-AgentIdentity -AccessToken $agentBlueprintGraphToken.access_token -DisplayName $agentIdentityName -AgentBlueprintId $AgentBlueprintId + Write-Host "Agent Identity created successfully!" -ForegroundColor Green + Write-Host " Agent Identity ID: $($agentIdentity.id)" -ForegroundColor Cyan + Write-Host " Display Name: $($agentIdentity.displayName)" -ForegroundColor Cyan + + Write-Host "Waiting 10 seconds to ensure Agent Identity is fully propagated..." -ForegroundColor Gray + Start-Sleep -Seconds 10 + } catch { + Write-Host "ERROR: Failed to create Agent Identity" -ForegroundColor Red + Write-Host " Details: $($_.Exception.Message)" -ForegroundColor Gray + exit 1 + } +} + + +# 3. Create Agent User +Write-Host "" +Write-Host "Step 3/4: Creating Agent User..." -ForegroundColor Yellow +if (-not $agentUserDisplayName) { + Write-Host "Agent User Display Name: " -ForegroundColor Yellow -NoNewline + $agentUserDisplayName = Read-Host +} +if (-not $userPrincipalName) { + Write-Host "Agent User Principal Name (e.g., @): " -ForegroundColor Yellow -NoNewline + $userPrincipalName = Read-Host + $mailNickname = $userPrincipalName.split('@')[0] +} +$usageLocation = 'US' + +try { + $agentUser = New-AgentUser -DisplayName $agentUserDisplayName -UserPrincipalName $userPrincipalName -MailNickname $mailNickname -AgentIdentityId $agentIdentity.id -UsageLocation $usageLocation + Write-Host "Agent User created successfully!" -ForegroundColor Green + Write-Host " Agent User ID: $($agentUser.id)" -ForegroundColor Cyan + Write-Host " Agent User Principal Name: $($agentUser.userPrincipalName)" -ForegroundColor Cyan +} catch { + Write-Host "ERROR: Failed to create Agent User" -ForegroundColor Red + Write-Host "$($_)" + Write-Host " Details: $($_.Exception.Message)" -ForegroundColor Gray + + # Don't exit - try to find existing user and continue with config update + Write-Host "Attempting to find existing user..." -ForegroundColor Yellow + try { + $existingUser = Get-MgUser -Filter "userPrincipalName eq '$userPrincipalName'" -ErrorAction Stop + if ($existingUser) { + Write-Host "Found existing user!" -ForegroundColor Green + Write-Host " Agent User ID: $($existingUser.Id)" -ForegroundColor Cyan + Write-Host " Agent User Principal Name: $($existingUser.UserPrincipalName)" -ForegroundColor Cyan + $agentUser = $existingUser + } + } catch { + Write-Host "Could not find existing user: $($_.Exception.Message)" -ForegroundColor Yellow + # Continue anyway to save at least the identity ID + $agentUser = $null + } +} + +# 4. Assign Manager to Agent User +Write-Host "" +Write-Host "Step 4/4: Assigning Manager to Agent User..." -ForegroundColor Yellow +if (-not $managerEmail) { + Write-Host "Manager's Email (optional, press Enter to skip): " -ForegroundColor Yellow -NoNewline + $managerEmail = Read-Host +} + +if ($managerEmail -and $agentUser -and $agentUser.id) { + try { + $assignedManager = Set-AgentUserManager -UserId $agentUser.id -ManagerEmail $managerEmail + if ($assignedManager) { + Write-Host "Manager assigned successfully!" -ForegroundColor Green + Write-Host " Manager Name: $($assignedManager.DisplayName)" -ForegroundColor Cyan + Write-Host " Manager Email: $($assignedManager.Mail)" -ForegroundColor Cyan + } + } catch { + Write-Host "ERROR: Failed to assign manager" -ForegroundColor Red + Write-Host " Details: $($_.Exception.Message)" -ForegroundColor Gray + } +} else { + Write-Host "Skipping manager assignment." -ForegroundColor Gray +} + + +Write-Host "" +Write-Host "================================================================================================" -ForegroundColor Green +Write-Host " AGENT USER CREATION COMPLETED! " -ForegroundColor Green +Write-Host "================================================================================================" -ForegroundColor Green +Write-Host "" +Write-Host "Agent User Details:" -ForegroundColor Yellow +Write-Host " • Agent Identity ID: $($agentIdentity.id)" -ForegroundColor White +Write-Host " • Agent Identity Display Name: $($agentIdentity.displayName)" -ForegroundColor White +Write-Host " • Agent User ID: $($agentUser.id)" -ForegroundColor White +Write-Host " • Agent User Principal Name: $($agentUser.userPrincipalName)" -ForegroundColor White +Write-Host " • Agent User Display Name: $($agentUser.displayName)" -ForegroundColor White +Write-Host " • Agent User Usage Location: $($agentUser.usageLocation)" -ForegroundColor White +if ($assignedManager) { + Write-Host " • Assigned Manager: $($assignedManager.DisplayName) ($($assignedManager.Mail))" -ForegroundColor White +} +Write-Host "" + +# Update the configuration file with the created IDs +if ($ConfigFile -and (Test-Path $ConfigFile)) { + try { + Write-Host "Updating configuration file with created IDs..." -ForegroundColor Blue + $config = Get-Content $ConfigFile | ConvertFrom-Json + + # Always update identity ID (this should always be available) + if ($agentIdentity -and $agentIdentity.id) { + $config.AgentIdentityId = $agentIdentity.id + Write-Host " Updated AgentIdentityId: $($agentIdentity.id)" -ForegroundColor Green + } + + # Update user ID only if user was created or found + if ($agentUser -and $agentUser.id) { + $config.AgentUserId = $agentUser.id + $config.AgentUserPrincipalName = $agentUser.userPrincipalName + Write-Host " Updated AgentUserId: $($agentUser.id)" -ForegroundColor Green + } else { + Write-Host " Skipped AgentUserId (user not available)" -ForegroundColor Yellow + } + + $config | ConvertTo-Json -Depth 10 | Set-Content $ConfigFile -Encoding UTF8 + Write-Host "Configuration file updated successfully: $ConfigFile" -ForegroundColor Green + } + catch { + Write-Host "WARNING: Failed to update configuration file: $($_.Exception.Message)" -ForegroundColor Yellow + } +} +else { + Write-Host "No configuration file to update (running in interactive mode)" -ForegroundColor Gray +}