From 0e774d675ffc2f076fc6d55d1d92a33c82ec899f Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Thu, 2 Apr 2026 11:31:54 -0700 Subject: [PATCH 01/15] re enable create instance + update license --- .../Commands/CreateInstanceCommand.cs | 85 ++++--------------- .../Services/A365CreateInstanceRunner.cs | 75 +++++----------- .../Commands/CreateInstanceCommandTests.cs | 19 ++--- 3 files changed, 45 insertions(+), 134 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs index 7bd5e015..a66e1232 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs @@ -21,10 +21,10 @@ public class CreateInstanceCommand public static Command CreateCommand(ILogger logger, IConfigService configService, CommandExecutor executor, IBotConfigurator botConfigurator, GraphApiService graphApiService) { - // Command description - deprecated - // Old: Create and configure agent user identities with appropriate + // Command description + // Create and configure agent user identities with appropriate // licenses and notification settings for your deployed agent - var command = new Command("create-instance", "DEPRECATED: Use 'a365 publish', optionally 'a365 deploy', then create instances in Microsoft Teams"); + var command = new Command("create-instance", "Create and configure agent user identities with appropriate licenses and notification settings for your deployed agent"); // Options for the main create-instance command var configOption = new Option( @@ -44,18 +44,13 @@ public static Command CreateCommand(ILogger logger, IConf command.AddOption(verboseOption); command.AddOption(dryRunOption); - // Subcommands removed - command is deprecated - // Keeping subcommand implementation code for local development only + // Subcommands + command.AddCommand(CreateIdentitySubcommand(logger, configService, executor, graphApiService)); + command.AddCommand(CreateLicensesSubcommand(logger, configService, executor, graphApiService)); // Default handler command.SetHandler(async (config, verbose, dryRun) => { - LogDeprecationError(logger, "create-instance"); - Environment.Exit(1); - - // Unreachable code below - preserved for local development use cases - #pragma warning disable CS0162 // Unreachable code detected - if (dryRun) { logger.LogInformation("DRY RUN: Agent 365 Instance Creation - All Steps"); @@ -82,19 +77,13 @@ public static Command CreateCommand(ILogger logger, IConf logger.LogInformation("Step 1-3: Creating Agent Identity, adding licenses, and registering MCP servers..."); logger.LogInformation(""); - // Use C# runner with AuthenticationService and GraphApiService + // Use C# runner with the DI-injected GraphApiService (has MSAL auth context) var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); - var authService = new AuthenticationService( - cleanLoggerFactory.CreateLogger()); - - var graphService = new GraphApiService( - cleanLoggerFactory.CreateLogger(), - executor); var instanceRunner = new A365CreateInstanceRunner( cleanLoggerFactory.CreateLogger(), executor, - graphService); + graphApiService); var generatedConfigPath = Path.Combine( config.DirectoryName ?? Environment.CurrentDirectory, @@ -288,7 +277,8 @@ await ProjectSettingsSyncHelper.ExecuteAsync( private static Command CreateIdentitySubcommand( ILogger logger, IConfigService configService, - CommandExecutor executor) + CommandExecutor executor, + GraphApiService graphApiService) { var command = new Command("identity", "Create Agent Identity and Agent User"); @@ -311,12 +301,6 @@ private static Command CreateIdentitySubcommand( command.SetHandler(async (config, verbose, dryRun) => { - LogDeprecationError(logger, "create-instance identity"); - Environment.Exit(1); - - // Unreachable code below - preserved for local development - #pragma warning disable CS0162 - if (dryRun) { logger.LogInformation("DRY RUN: Creating Agent Identity and Agent User"); @@ -333,19 +317,13 @@ private static Command CreateIdentitySubcommand( var instanceConfig = await LoadConfigAsync(logger, configService, config.FullName); if (instanceConfig == null) Environment.Exit(1); - // Use C# runner with AuthenticationService and GraphApiService + // Use C# runner with the DI-injected GraphApiService (has MSAL auth context) var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); - var authService = new AuthenticationService( - cleanLoggerFactory.CreateLogger()); - - var graphService = new GraphApiService( - cleanLoggerFactory.CreateLogger(), - executor); var instanceRunner = new A365CreateInstanceRunner( cleanLoggerFactory.CreateLogger(), executor, - graphService); + graphApiService); var generatedConfigPath = Path.Combine( config.DirectoryName ?? Environment.CurrentDirectory, @@ -402,7 +380,8 @@ await ProjectSettingsSyncHelper.ExecuteAsync( private static Command CreateLicensesSubcommand( ILogger logger, IConfigService configService, - CommandExecutor executor) + CommandExecutor executor, + GraphApiService graphApiService) { var command = new Command("licenses", "Add licenses to Agent User"); @@ -425,12 +404,6 @@ private static Command CreateLicensesSubcommand( command.SetHandler(async (config, verbose, dryRun) => { - LogDeprecationError(logger, "create-instance licenses"); - Environment.Exit(1); - - // Unreachable code below - preserved for local development - #pragma warning disable CS0162 - if (dryRun) { logger.LogInformation("DRY RUN: Adding licenses to Agent User"); @@ -446,19 +419,13 @@ private static Command CreateLicensesSubcommand( var instanceConfig = await LoadConfigAsync(logger, configService, config.FullName); if (instanceConfig == null) Environment.Exit(1); - // Use C# runner with AuthenticationService and GraphApiService + // Use C# runner with the DI-injected GraphApiService (has MSAL auth context) var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); - var authService = new AuthenticationService( - cleanLoggerFactory.CreateLogger()); - - var graphService = new GraphApiService( - cleanLoggerFactory.CreateLogger(), - executor); var instanceRunner = new A365CreateInstanceRunner( cleanLoggerFactory.CreateLogger(), executor, - graphService); + graphApiService); var generatedConfigPath = Path.Combine( config.DirectoryName ?? Environment.CurrentDirectory, @@ -512,24 +479,4 @@ private static Command CreateLicensesSubcommand( } } - /// - /// Logs deprecation error message for create-instance command and its subcommands. - /// Uses plain ASCII text for cross-platform compatibility. - /// - private static void LogDeprecationError(ILogger logger, string commandName) - { - logger.LogError("ERROR: Command '{Command}' has been deprecated.", commandName); - logger.LogError(""); - logger.LogError("This command bypasses the standard agent registration workflow,"); - logger.LogError("which prevents proper agent registration and event propagation."); - logger.LogError(""); - logger.LogError("Use the recommended workflow instead:"); - logger.LogError(" 1. Run 'a365 publish' to package and upload your agent manifest"); - logger.LogError(" 2. Run 'a365 deploy' to deploy your application (if Azure-hosted)"); - logger.LogError(" 3. Create an agent instance through Microsoft Teams"); - logger.LogError(""); - logger.LogError("For more information, see:"); - logger.LogError("https://learn.microsoft.com/microsoft-agent-365/onboard"); - logger.LogError(""); - } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs index 448bb3eb..65212268 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs @@ -13,7 +13,6 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services; /// -/// C# implementation fully equivalent to a365-createinstance.ps1. /// Supports all phases: Identity/User creation and License assignment. /// MCP permissions are configured via inheritable permissions during setup phase. /// @@ -24,7 +23,7 @@ public sealed class A365CreateInstanceRunner private readonly GraphApiService _graphService; // License SKU IDs - private const string SkuTeamsEntNew = "7e31c0d9-9551-471d-836f-32ee72be4a01"; // Microsoft_Teams_Enterprise_New + private const string SkuAgent365Tier3 = "304b93a3-b1f1-427f-aa02-da21e7c7d675"; // Microsoft_Agent_365_Tier_3 private const string SkuE5NoTeams = "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e"; // Microsoft_365_E5_(no_Teams) public A365CreateInstanceRunner( @@ -49,25 +48,6 @@ public async Task RunAsync( string step = "all", CancellationToken cancellationToken = default) { - // DEPRECATED: This service bypasses the standard agent registration workflow - _logger.LogError("==============================================================================="); - _logger.LogError("WARNING: A365CreateInstanceRunner bypasses the standard agent registration workflow"); - _logger.LogError("==============================================================================="); - _logger.LogError(""); - _logger.LogError("This service uses Graph API directly and skips the standard agent registration"); - _logger.LogError("workflow. Agents provisioned this way will NOT:"); - _logger.LogError(" - Be properly registered with Microsoft 365 partners"); - _logger.LogError(" - Receive OnHire events"); - _logger.LogError(" - Work correctly with messaging and event propagation"); - _logger.LogError(""); - _logger.LogError("Use 'a365 publish' followed by Teams-based hiring instead."); - _logger.LogError("See: https://learn.microsoft.com/microsoft-agent-365/onboard"); - _logger.LogError(""); - return false; - - // Unreachable code below - preserved for local development use cases - #pragma warning disable CS0162 // Unreachable code detected - // Validate inputs if (!File.Exists(configPath)) { @@ -478,7 +458,7 @@ string GetConfig(string name) => using var httpClient = HttpClientFactory.CreateAuthenticatedClient(accessToken, correlationId: correlationId); - // Get current user for sponsor (optional - use delegated token for this) + // Get current user for sponsor (REQUIRED by Graph API) string? currentUserId = null; try { @@ -500,7 +480,19 @@ string GetConfig(string name) => } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to get current user ID for sponsor, will create without sponsor"); + _logger.LogWarning(ex, "Failed to get current user ID for sponsor"); + } + + if (string.IsNullOrWhiteSpace(currentUserId)) + { + _logger.LogError("A sponsor is required to create an agent identity."); + _logger.LogError("Could not determine the current user ID via Graph API."); + _logger.LogError(""); + _logger.LogError("RECOMMENDED ACTIONS:"); + _logger.LogError(" 1. Ensure you are logged in via Azure CLI: az login"); + _logger.LogError(" 2. Verify your account has access to Microsoft Graph"); + _logger.LogError(" 3. Re-run the command"); + return (false, null); } // Create agent identity via service principal endpoint @@ -508,17 +500,12 @@ string GetConfig(string name) => var identityBody = new JsonObject { ["displayName"] = displayName, - ["agentAppId"] = agentBlueprintId - }; - - // Add sponsor if we have current user ID - if (!string.IsNullOrWhiteSpace(currentUserId)) - { - identityBody["sponsors@odata.bind"] = new JsonArray + ["agentAppId"] = agentBlueprintId, + ["sponsors@odata.bind"] = new JsonArray { $"{GraphApiConstants.BaseUrl}/v1.0/users/{currentUserId}" - }; - } + } + }; _logger.LogInformation(" - Sending request to create agent identity..."); var identityResponse = await httpClient.PostAsync( @@ -526,7 +513,7 @@ string GetConfig(string name) => new StringContent(identityBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), ct); - // Handle case where sponsor is not supported (fallback without sponsor) + // Handle error responses if (!identityResponse.IsSuccessStatusCode) { var errorContent = await identityResponse.Content.ReadAsStringAsync(ct); @@ -544,25 +531,6 @@ string GetConfig(string name) => _logger.LogError(""); return (false, null); } - - if (identityResponse.StatusCode == System.Net.HttpStatusCode.BadRequest && - !string.IsNullOrWhiteSpace(currentUserId)) - { - _logger.LogWarning("Agent Identity creation with sponsor failed, retrying without sponsor..."); - - // Remove sponsor and try again - identityBody.Remove("sponsors@odata.bind"); - - identityResponse = await httpClient.PostAsync( - createIdentityUrl, - new StringContent(identityBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), - ct); - - if (!identityResponse.IsSuccessStatusCode) - { - errorContent = await identityResponse.Content.ReadAsStringAsync(ct); - } - } } if (!identityResponse.IsSuccessStatusCode) @@ -978,8 +946,7 @@ private async Task AssignLicensesAsync( { ["addLicenses"] = new JsonArray { - new JsonObject { ["skuId"] = SkuTeamsEntNew }, - new JsonObject { ["skuId"] = SkuE5NoTeams } + new JsonObject { ["skuId"] = SkuAgent365Tier3 } }, ["removeLicenses"] = new JsonArray() }; diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CreateInstanceCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CreateInstanceCommandTests.cs index 488806b7..9f144036 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CreateInstanceCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CreateInstanceCommandTests.cs @@ -34,7 +34,7 @@ public CreateInstanceCommandTests() } [Fact] - public void CreateInstanceCommand_Should_Not_Have_Identity_Subcommand_Due_To_Deprecation() + public void CreateInstanceCommand_Should_Have_Identity_Subcommand() { // Arrange var command = CreateInstanceCommand.CreateCommand( @@ -47,12 +47,12 @@ public void CreateInstanceCommand_Should_Not_Have_Identity_Subcommand_Due_To_Dep // Act var identitySubcommand = command.Subcommands.FirstOrDefault(c => c.Name == "identity"); - // Assert - Subcommand should not be registered since command is deprecated - Assert.Null(identitySubcommand); + // Assert - Subcommand should be registered + Assert.NotNull(identitySubcommand); } [Fact] - public void CreateInstanceCommand_Should_Not_Have_Licenses_Subcommand_Due_To_Deprecation() + public void CreateInstanceCommand_Should_Have_Licenses_Subcommand() { // Arrange var command = CreateInstanceCommand.CreateCommand( @@ -65,8 +65,8 @@ public void CreateInstanceCommand_Should_Not_Have_Licenses_Subcommand_Due_To_Dep // Act var licensesSubcommand = command.Subcommands.FirstOrDefault(c => c.Name == "licenses"); - // Assert - Subcommand should not be registered since command is deprecated - Assert.Null(licensesSubcommand); + // Assert - Subcommand should be registered + Assert.NotNull(licensesSubcommand); } [Fact] @@ -85,7 +85,7 @@ public void CreateInstanceCommand_Should_Have_Handler_For_Complete_Instance_Crea } [Fact] - public void CreateInstanceCommand_Should_Log_Deprecation_Error() + public void CreateInstanceCommand_Should_Be_Named_CreateInstance() { // Arrange var command = CreateInstanceCommand.CreateCommand( @@ -96,11 +96,8 @@ public void CreateInstanceCommand_Should_Log_Deprecation_Error() _mockGraphApiService); // Act - Command should be created successfully - // Assert - Command structure is valid + // Assert - Command is named "create-instance" for use as "a365 create-instance" Assert.NotNull(command); Assert.Equal("create-instance", command.Name); - - // Verify deprecation message structure through logger assertions would require execution - // which would call Environment.Exit(1). Testing the command creation is sufficient. } } \ No newline at end of file From c9f641f2f23699a39ad75e7de54d42e76583dec1 Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Thu, 2 Apr 2026 12:36:54 -0700 Subject: [PATCH 02/15] update logging --- .../Services/A365CreateInstanceRunner.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs index 65212268..68ec1cf9 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs @@ -959,8 +959,7 @@ private async Task AssignLicensesAsync( if (licenseResponse.IsSuccessStatusCode) { _logger.LogInformation("Licenses assigned successfully"); - _logger.LogInformation(" - Microsoft Teams Enterprise"); - _logger.LogInformation(" - Microsoft 365 E5 (no Teams)"); + _logger.LogInformation(" - Microsoft Agent 365 Tier 3"); } else { From 20bd0a59d9e416e82643aba0a9c1c78b1b988754 Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Thu, 2 Apr 2026 12:37:37 -0700 Subject: [PATCH 03/15] update SKUs --- .../Services/A365CreateInstanceRunner.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs index 68ec1cf9..a1d3a19a 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs @@ -24,7 +24,6 @@ public sealed class A365CreateInstanceRunner // License SKU IDs private const string SkuAgent365Tier3 = "304b93a3-b1f1-427f-aa02-da21e7c7d675"; // Microsoft_Agent_365_Tier_3 - private const string SkuE5NoTeams = "18a4bd3f-0b5b-4887-b04f-61dd0ee15f5e"; // Microsoft_365_E5_(no_Teams) public A365CreateInstanceRunner( ILogger logger, From 0dbf0e6d832d471926612318521178ae0e4bc710 Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Thu, 2 Apr 2026 12:52:51 -0700 Subject: [PATCH 04/15] updated changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8028f0e4..e6d7a529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- Re-enabled `a365 create-instance` command (previously deprecated) — creates agent identity, agent user, assigns licenses, and grants admin consent for Graph/MCP/Bot Framework scopes in a single command - Server-driven notice system: security advisories and critical upgrade prompts are displayed at startup when a maintainer updates `notices.json`. Notices are suppressed once the user upgrades past the specified `minimumVersion`. Results are cached locally for 4 hours to avoid network calls on every invocation. - `a365 cleanup azure --dry-run` — preview resources that would be deleted without making any changes or requiring Azure authentication - `AppServiceAuthRequirementCheck` — validates App Service deployment token before `a365 deploy` begins, catching revoked grants (AADSTS50173) early @@ -15,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - `a365 publish` updates manifest IDs, creates `manifest.zip`, and prints concise upload instructions for Microsoft 365 Admin Center (Agents > All agents > Upload custom agent). Interactive prompts only occur in interactive terminals; redirect stdin to suppress them in scripts. ### Fixed +- `A365CreateInstanceRunner` sponsor handling: sponsor is now required (Graph API rejects requests without one) — removed fallback that silently stripped the sponsor on retry, which caused `BadRequest` errors - Intermittent `ConnectionResetError (10054)` failures on corporate networks with TLS inspection proxies (Zscaler, Netskope) — Graph and ARM API calls now use direct MSAL.NET token acquisition instead of `az account get-access-token` subprocesses, bypassing the Python HTTP stack that triggered proxy resets (#321) - `a365 cleanup` blueprint deletion now succeeds for Global Administrators even when the blueprint was created by a different user - `a365 setup all` no longer times out for non-admin users — the CLI immediately surfaces a consent URL to share with an administrator instead of waiting for a browser prompt From e9d1bff85b41672510e402bc0eedca6e8a69fff3 Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Fri, 3 Apr 2026 17:11:17 -0700 Subject: [PATCH 05/15] PR comments --- .../Commands/CreateInstanceCommand.cs | 10 +++++----- .../Services/A365CreateInstanceRunner.cs | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs index b5caed24..6f8cf8dc 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs @@ -78,7 +78,7 @@ public static Command CreateCommand(ILogger logger, IConf logger.LogInformation(""); // Use C# runner with the DI-injected GraphApiService (has MSAL auth context) - var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); + using var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); var instanceRunner = new A365CreateInstanceRunner( cleanLoggerFactory.CreateLogger(), @@ -243,7 +243,7 @@ public static Command CreateCommand(ILogger logger, IConf generatedConfigPath = Path.Combine( config.DirectoryName ?? Environment.CurrentDirectory, "a365.generated.config.json"); - var syncLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); + using var syncLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); var platformDetector = new PlatformDetector(syncLoggerFactory.CreateLogger()); await ProjectSettingsSyncHelper.ExecuteAsync( @@ -318,7 +318,7 @@ private static Command CreateIdentitySubcommand( if (instanceConfig == null) Environment.Exit(1); // Use C# runner with the DI-injected GraphApiService (has MSAL auth context) - var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); + using var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); var instanceRunner = new A365CreateInstanceRunner( cleanLoggerFactory.CreateLogger(), @@ -346,7 +346,7 @@ private static Command CreateIdentitySubcommand( config.DirectoryName ?? Environment.CurrentDirectory, "a365.generated.config.json"); - var syncLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); + using var syncLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); var platformDetector = new PlatformDetector(syncLoggerFactory.CreateLogger()); await ProjectSettingsSyncHelper.ExecuteAsync( @@ -420,7 +420,7 @@ private static Command CreateLicensesSubcommand( if (instanceConfig == null) Environment.Exit(1); // Use C# runner with the DI-injected GraphApiService (has MSAL auth context) - var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); + using var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); var instanceRunner = new A365CreateInstanceRunner( cleanLoggerFactory.CreateLogger(), diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs index a1d3a19a..a739b977 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs @@ -507,7 +507,7 @@ string GetConfig(string name) => }; _logger.LogInformation(" - Sending request to create agent identity..."); - var identityResponse = await httpClient.PostAsync( + using var identityResponse = await httpClient.PostAsync( createIdentityUrl, new StringContent(identityBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), ct); @@ -698,7 +698,7 @@ string GetConfig(string name) => } }; - var userResponse = await httpClient.PostAsync( + using var userResponse = await httpClient.PostAsync( createUserUrl, new StringContent(userBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), ct); @@ -926,7 +926,7 @@ private async Task AssignLicensesAsync( ["usageLocation"] = usageLocation }; - var updateResponse = await httpClient.PatchAsync( + using var updateResponse = await httpClient.PatchAsync( updateUserUrl, new StringContent(updateBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), cancellationToken); @@ -950,7 +950,7 @@ private async Task AssignLicensesAsync( ["removeLicenses"] = new JsonArray() }; - var licenseResponse = await httpClient.PostAsync( + using var licenseResponse = await httpClient.PostAsync( assignLicenseUrl, new StringContent(licenseBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), cancellationToken); From e4fd58e9b38554709d60feb3664d0bbcf913bde5 Mon Sep 17 00:00:00 2001 From: Grant Harris <96964444+gwharris7@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:22:28 -0700 Subject: [PATCH 06/15] Update CHANGELOG.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f4bab49..ff336603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added -<<<<<<< users/gwharris7/create-instance - Re-enabled `a365 create-instance` command (previously deprecated) — creates agent identity, agent user, assigns licenses, and grants admin consent for Graph/MCP/Bot Framework scopes in a single command -======= - `Agent365.Observability.OtelWrite` scope now granted to all provisioned agent identities on the Observability API alongside `user_impersonation`, enabling agents to write OpenTelemetry data to the Agent 365 observability service - `ChannelMessage.Read.All` and `ChannelMessage.Send` added to default blueprint Microsoft Graph delegated scopes (`agentIdentityScopes`) - `Files.ReadWrite.All`, `ChannelMessage.Read.All`, and `ChannelMessage.Send` added to default blueprint Microsoft Graph application scopes (`agentApplicationScopes`) ->>>>>>> main - Server-driven notice system: security advisories and critical upgrade prompts are displayed at startup when a maintainer updates `notices.json`. Notices are suppressed once the user upgrades past the specified `minimumVersion`. Results are cached locally for 4 hours to avoid network calls on every invocation. - `a365 cleanup azure --dry-run` — preview resources that would be deleted without making any changes or requiring Azure authentication - `AppServiceAuthRequirementCheck` — validates App Service deployment token before `a365 deploy` begins, catching revoked grants (AADSTS50173) early From 485ab29ffc6c4358e57b2b5135aef866751501d9 Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Fri, 3 Apr 2026 18:07:51 -0700 Subject: [PATCH 07/15] fix error where admin consent was still required --- .../Commands/CreateInstanceCommand.cs | 113 ++++++++++-------- 1 file changed, 61 insertions(+), 52 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs index 6f8cf8dc..9ffe2698 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs @@ -107,69 +107,78 @@ public static Command CreateCommand(ILogger logger, IConf logger.LogInformation(" Agent User ID: {AgenticUserId}", instanceConfig.AgenticUserId ?? "(not set)"); logger.LogInformation(" Agent User Principal Name: {AgentUserPrincipalName}", instanceConfig.AgentUserPrincipalName ?? "(not set)"); - // Admin consent for MCP scopes (oauth2PermissionGrants) - logger.LogInformation("Granting MCP scopes to Agent Identity via oauth2PermissionGrants"); - - var manifestPath = Path.Combine(instanceConfig.DeploymentProjectPath ?? string.Empty, McpConstants.ToolingManifestFileName); - var scopesForAgent = await ManifestHelper.GetRequiredScopesAsync(manifestPath); - - // clientId must be the *service principal objectId* of the agentic app - var agenticAppSpObjectId = await graphApiService.LookupServicePrincipalByAppIdAsync( - instanceConfig.TenantId, - instanceConfig.AgenticAppId ?? string.Empty - ) ?? throw new InvalidOperationException($"Service Principal not found for agentic app Id {instanceConfig.AgenticAppId}"); - - var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(instanceConfig.Environment); - var Agent365ToolsResourceSpObjectId = await graphApiService.LookupServicePrincipalByAppIdAsync(instanceConfig.TenantId, resourceAppId) - ?? throw new InvalidOperationException("Agent 365 Tools Service Principal not found for appId " + resourceAppId); - - var response = await graphApiService.CreateOrUpdateOauth2PermissionGrantAsync( - instanceConfig.TenantId, - agenticAppSpObjectId, - Agent365ToolsResourceSpObjectId, - scopesForAgent - ); - - if (!response) + // When consent inheritance is configured, the agent identity inherits + // permissions from the blueprint — no tenant-wide admin consent grants needed. + if (instanceConfig.IsInheritanceConfigured()) { - logger.LogWarning("Failed to create/update oauth2PermissionGrant for agent identity."); + logger.LogInformation("Consent inheritance is configured; skipping oauth2PermissionGrant creation (admin consent not required)"); } + else + { + // Admin consent for MCP scopes (oauth2PermissionGrants) + logger.LogInformation("Granting MCP scopes to Agent Identity via oauth2PermissionGrants"); + + var manifestPath = Path.Combine(instanceConfig.DeploymentProjectPath ?? string.Empty, McpConstants.ToolingManifestFileName); + var scopesForAgent = await ManifestHelper.GetRequiredScopesAsync(manifestPath); + + // clientId must be the *service principal objectId* of the agentic app + var agenticAppSpObjectId = await graphApiService.LookupServicePrincipalByAppIdAsync( + instanceConfig.TenantId, + instanceConfig.AgenticAppId ?? string.Empty + ) ?? throw new InvalidOperationException($"Service Principal not found for agentic app Id {instanceConfig.AgenticAppId}"); + + var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(instanceConfig.Environment); + var Agent365ToolsResourceSpObjectId = await graphApiService.LookupServicePrincipalByAppIdAsync(instanceConfig.TenantId, resourceAppId) + ?? throw new InvalidOperationException("Agent 365 Tools Service Principal not found for appId " + resourceAppId); + + var response = await graphApiService.CreateOrUpdateOauth2PermissionGrantAsync( + instanceConfig.TenantId, + agenticAppSpObjectId, + Agent365ToolsResourceSpObjectId, + scopesForAgent + ); - logger.LogInformation(" OAuth2 admin consent completed for Agent Identity (scopes: {Scopes})", - string.Join(' ', scopesForAgent)); + if (!response) + { + logger.LogWarning("Failed to create/update oauth2PermissionGrant for agent identity."); + } - logger.LogInformation(""); - logger.LogInformation("Granting Bot Framework API scopes to Agent Identity"); + logger.LogInformation(" OAuth2 admin consent completed for Agent Identity (scopes: {Scopes})", + string.Join(' ', scopesForAgent)); + + logger.LogInformation(""); + logger.LogInformation("Granting Bot Framework API scopes to Agent Identity"); - var botApiResourceSpObjectId = await graphApiService.EnsureServicePrincipalForAppIdAsync( - instanceConfig.TenantId, - ConfigConstants.MessagingBotApiAppId); + var botApiResourceSpObjectId = await graphApiService.EnsureServicePrincipalForAppIdAsync( + instanceConfig.TenantId, + ConfigConstants.MessagingBotApiAppId); - // Grant oauth2PermissionGrants: *agent identity SP* -> Messaging Bot API SP - var botApiGrantOk = await graphApiService.CreateOrUpdateOauth2PermissionGrantAsync( - instanceConfig.TenantId, - agenticAppSpObjectId, - botApiResourceSpObjectId, - new[] { "Authorization.ReadWrite", "user_impersonation" }); + // Grant oauth2PermissionGrants: *agent identity SP* -> Messaging Bot API SP + var botApiGrantOk = await graphApiService.CreateOrUpdateOauth2PermissionGrantAsync( + instanceConfig.TenantId, + agenticAppSpObjectId, + botApiResourceSpObjectId, + new[] { "Authorization.ReadWrite", "user_impersonation" }); - if (!botApiGrantOk) - logger.LogWarning("Failed to create/update oauth2PermissionGrant for agent identity to Messaging Bot API."); + if (!botApiGrantOk) + logger.LogWarning("Failed to create/update oauth2PermissionGrant for agent identity to Messaging Bot API."); - var observabilityApiResourceSpObjectId = await graphApiService.EnsureServicePrincipalForAppIdAsync( - instanceConfig.TenantId, - ConfigConstants.ObservabilityApiAppId); + var observabilityApiResourceSpObjectId = await graphApiService.EnsureServicePrincipalForAppIdAsync( + instanceConfig.TenantId, + ConfigConstants.ObservabilityApiAppId); - // Grant oauth2PermissionGrants: *agent identity SP* -> Observability API SP - var observabilityApiGrantOk = await graphApiService.CreateOrUpdateOauth2PermissionGrantAsync( - instanceConfig.TenantId, - agenticAppSpObjectId, - observabilityApiResourceSpObjectId, - new[] { "user_impersonation", ConfigConstants.ObservabilityApiOtelWriteScope }); + // Grant oauth2PermissionGrants: *agent identity SP* -> Observability API SP + var observabilityApiGrantOk = await graphApiService.CreateOrUpdateOauth2PermissionGrantAsync( + instanceConfig.TenantId, + agenticAppSpObjectId, + observabilityApiResourceSpObjectId, + new[] { "user_impersonation", ConfigConstants.ObservabilityApiOtelWriteScope }); - if (!observabilityApiGrantOk) - logger.LogWarning("Failed to create/update oauth2PermissionGrant for agent identity to Observability API."); + if (!observabilityApiGrantOk) + logger.LogWarning("Failed to create/update oauth2PermissionGrant for agent identity to Observability API."); - logger.LogInformation("Admin consent granted for Agent Identity completed successfully"); + logger.LogInformation("Admin consent granted for Agent Identity completed successfully"); + } // Register agent with Microsoft Graph API logger.LogInformation(" Registering agent with Microsoft Graph API"); From 8a567918fb3992d9300ec5779f9e4f6510bcc0de Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Fri, 3 Apr 2026 18:29:49 -0700 Subject: [PATCH 08/15] updated required API permissions --- .../Constants/AuthenticationConstants.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs index 860fb0b3..363fecd1 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs @@ -182,7 +182,8 @@ public static string[] GetRequiredRedirectUris(string clientAppId) "AgentIdentityBlueprint.UpdateAuthProperties.All", "AgentIdentityBlueprint.AddRemoveCreds.All", // Required for passwordCredentials and FICs during setup and cleanup "DelegatedPermissionGrant.ReadWrite.All", - "Directory.Read.All" + "Directory.Read.All", + "User.ReadWrite.All" // Required for agent user creation, usage location update, and license assignment // Note: RoleManagementReadDirectoryScope and AgentIdentityBlueprint.DeleteRestore.All are // intentionally excluded. DeleteRestore.All is a cleanup-only scope acquired on-demand via // interactive consent during 'a365 cleanup'. RoleManagementReadDirectoryScope is excluded From b1ae67bc77bb5b208be98688e9eefd3b87f89b9e Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Fri, 3 Apr 2026 18:50:30 -0700 Subject: [PATCH 09/15] update agent instructions --- docs/agent365-guided-setup/a365-setup-instructions.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/agent365-guided-setup/a365-setup-instructions.md b/docs/agent365-guided-setup/a365-setup-instructions.md index 1167da32..dd9e1a61 100644 --- a/docs/agent365-guided-setup/a365-setup-instructions.md +++ b/docs/agent365-guided-setup/a365-setup-instructions.md @@ -89,7 +89,7 @@ Once the user provides the ID, replace `` in the command below an az ad app show --id --query "{appId:appId, displayName:displayName, requiredResourceAccess:requiredResourceAccess}" -o json && az ad app permission list-grants --id --query "[].{resourceDisplayName:resourceDisplayName, scope:scope}" -o table ``` -From the output of the command above, verify these 5 permissions appear with admin consent. If any are missing or consent is not granted, see "What to do if validation fails" below. +From the output of the command above, verify these 6 permissions appear with admin consent. If any are missing or consent is not granted, see "What to do if validation fails" below. Required **delegated** Microsoft Graph permissions (all must have **admin consent granted**): @@ -100,6 +100,7 @@ Required **delegated** Microsoft Graph permissions (all must have **admin consen | `Application.ReadWrite.All` | Create and manage Azure AD applications | | `DelegatedPermissionGrant.ReadWrite.All` | Grant delegated permissions | | `Directory.Read.All` | Read directory data | +| `User.ReadWrite.All` | Create agent users, set usage location, and assign licenses | If the app does not exist, permissions are missing, or admin consent has not been granted, see "What to do if validation fails" below. @@ -107,7 +108,7 @@ If the app does not exist, permissions are missing, or admin consent has not bee 1. STOP — do not proceed to run any `a365` CLI commands. 2. Inform the user the custom client app registration is missing or incomplete. -3. Direct the user to the official setup guide: register the app, configure as a Public client with redirect URI `http://localhost:8400`, add all five permissions above, and have a Global Admin grant admin consent. +3. Direct the user to the official setup guide: register the app, configure as a Public client with redirect URI `http://localhost:8400`, add all six permissions above, and have a Global Admin grant admin consent. 4. Wait for the user to confirm the app is properly configured, then re-run the same validation command above. Save the `clientAppId` value — it will be used automatically in Step 3 (do NOT ask the user for it again). From e1ba5dc384a46f59f00d50d0fc3dcec3c9a4831d Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Fri, 3 Apr 2026 19:22:31 -0700 Subject: [PATCH 10/15] fixed PR comments --- .../Commands/CreateInstanceCommand.cs | 12 +++++++----- src/Microsoft.Agents.A365.DevTools.Cli/Program.cs | 2 +- .../Services/A365CreateInstanceRunner.cs | 9 +++++---- .../Commands/CreateInstanceCommandTests.cs | 8 +------- .../Services/ClientAppValidatorTests.cs | 11 +++++++---- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs index 9ffe2698..681766ec 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs @@ -19,7 +19,7 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands; public class CreateInstanceCommand { public static Command CreateCommand(ILogger logger, IConfigService configService, CommandExecutor executor, - IBotConfigurator botConfigurator, GraphApiService graphApiService) + GraphApiService graphApiService) { // Command description // Create and configure agent user identities with appropriate @@ -57,7 +57,6 @@ public static Command CreateCommand(ILogger logger, IConf logger.LogInformation("This would execute the following operations:"); logger.LogInformation(" 1. Create Agent Identity and Agent User"); logger.LogInformation(" 2. Add licenses to Agent User"); - logger.LogInformation(" 3. Configure Bot Service"); logger.LogInformation("No actual changes will be made."); return; } @@ -78,7 +77,8 @@ public static Command CreateCommand(ILogger logger, IConf logger.LogInformation(""); // Use C# runner with the DI-injected GraphApiService (has MSAL auth context) - using var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); + var logLevel = verbose ? LogLevel.Debug : LogLevel.Information; + using var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(logLevel); var instanceRunner = new A365CreateInstanceRunner( cleanLoggerFactory.CreateLogger(), @@ -327,7 +327,8 @@ private static Command CreateIdentitySubcommand( if (instanceConfig == null) Environment.Exit(1); // Use C# runner with the DI-injected GraphApiService (has MSAL auth context) - using var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); + var logLevel = verbose ? LogLevel.Debug : LogLevel.Information; + using var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(logLevel); var instanceRunner = new A365CreateInstanceRunner( cleanLoggerFactory.CreateLogger(), @@ -429,7 +430,8 @@ private static Command CreateLicensesSubcommand( if (instanceConfig == null) Environment.Exit(1); // Use C# runner with the DI-injected GraphApiService (has MSAL auth context) - using var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); + var logLevel = verbose ? LogLevel.Debug : LogLevel.Information; + using var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(logLevel); var instanceRunner = new A365CreateInstanceRunner( cleanLoggerFactory.CreateLogger(), diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 7878f4ea..d7fd0d00 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -151,7 +151,7 @@ await Task.WhenAll( rootCommand.AddCommand(SetupCommand.CreateCommand(setupLogger, configService, executor, deploymentService, botConfigurator, azureAuthValidator, platformDetector, graphApiService, agentBlueprintService, blueprintLookupService, federatedCredentialService, clientAppValidator, confirmationProvider, armApiService)); rootCommand.AddCommand(CreateInstanceCommand.CreateCommand(createInstanceLogger, configService, executor, - botConfigurator, graphApiService)); + graphApiService)); rootCommand.AddCommand(DeployCommand.CreateCommand(deployLogger, configService, executor, deploymentService, azureAuthValidator, graphApiService, agentBlueprintService)); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs index a739b977..4b956d57 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs @@ -488,21 +488,22 @@ string GetConfig(string name) => _logger.LogError("Could not determine the current user ID via Graph API."); _logger.LogError(""); _logger.LogError("RECOMMENDED ACTIONS:"); - _logger.LogError(" 1. Ensure you are logged in via Azure CLI: az login"); - _logger.LogError(" 2. Verify your account has access to Microsoft Graph"); + _logger.LogError(" 1. Ensure you have completed MSAL sign-in (the CLI authenticates via browser or device code, not Azure CLI)"); + _logger.LogError(" 2. Verify your custom client app has the required delegated scopes (User.ReadWrite.All)"); _logger.LogError(" 3. Re-run the command"); return (false, null); } // Create agent identity via service principal endpoint - var createIdentityUrl = $"{GraphApiConstants.BaseUrl}/beta/serviceprincipals/Microsoft.Graph.AgentIdentity"; + var graphBaseUrl = _graphService.GraphBaseUrl; + var createIdentityUrl = $"{graphBaseUrl}/beta/serviceprincipals/Microsoft.Graph.AgentIdentity"; var identityBody = new JsonObject { ["displayName"] = displayName, ["agentAppId"] = agentBlueprintId, ["sponsors@odata.bind"] = new JsonArray { - $"{GraphApiConstants.BaseUrl}/v1.0/users/{currentUserId}" + $"{graphBaseUrl}/v1.0/users/{currentUserId}" } }; diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CreateInstanceCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CreateInstanceCommandTests.cs index 9f144036..a0f62c0e 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CreateInstanceCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CreateInstanceCommandTests.cs @@ -19,7 +19,6 @@ public class CreateInstanceCommandTests private readonly ILogger _mockLogger; private readonly ConfigService _mockConfigService; private readonly CommandExecutor _mockExecutor; - private readonly IBotConfigurator _mockBotConfigurator; private readonly GraphApiService _mockGraphApiService; public CreateInstanceCommandTests() @@ -29,7 +28,6 @@ public CreateInstanceCommandTests() // Use NullLogger instead of console logger to avoid I/O bottleneck _mockConfigService = Substitute.ForPartsOf(NullLogger.Instance); _mockExecutor = Substitute.ForPartsOf(NullLogger.Instance); - _mockBotConfigurator = Substitute.For(); _mockGraphApiService = Substitute.ForPartsOf(NullLogger.Instance, _mockExecutor); } @@ -41,7 +39,6 @@ public void CreateInstanceCommand_Should_Have_Identity_Subcommand() _mockLogger, _mockConfigService, _mockExecutor, - _mockBotConfigurator, _mockGraphApiService); // Act @@ -59,7 +56,6 @@ public void CreateInstanceCommand_Should_Have_Licenses_Subcommand() _mockLogger, _mockConfigService, _mockExecutor, - _mockBotConfigurator, _mockGraphApiService); // Act @@ -77,7 +73,6 @@ public void CreateInstanceCommand_Should_Have_Handler_For_Complete_Instance_Crea _mockLogger, _mockConfigService, _mockExecutor, - _mockBotConfigurator, _mockGraphApiService); // Act & Assert - Main command should have handler for running all steps @@ -92,7 +87,6 @@ public void CreateInstanceCommand_Should_Be_Named_CreateInstance() _mockLogger, _mockConfigService, _mockExecutor, - _mockBotConfigurator, _mockGraphApiService); // Act - Command should be created successfully @@ -100,4 +94,4 @@ public void CreateInstanceCommand_Should_Be_Named_CreateInstance() Assert.NotNull(command); Assert.Equal("create-instance", command.Name); } -} \ No newline at end of file +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs index 98f415b0..1dc0c502 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs @@ -39,6 +39,7 @@ public class ClientAppValidatorTests private const string AgentBlueprintAddRemoveCredsId = "aaaa0004-0000-0000-0000-000000000000"; private const string DelegatedPermissionGrantReadWriteAllId = "aaaa0005-0000-0000-0000-000000000000"; private const string DirectoryReadAllId = "aaaa0006-0000-0000-0000-000000000000"; + private const string UserReadWriteAllId = "aaaa0007-0000-0000-0000-000000000000"; public ClientAppValidatorTests() { @@ -797,7 +798,7 @@ private void SetupAppInfoGetEmpty() } /// - /// Sets up the app info GET with all 6 required permissions. + /// Sets up the app info GET with all 7 required permissions. /// The permission GUIDs match those returned by SetupPermissionResolution so validation passes. /// private void SetupAppInfoWithAllPermissions(string appId) @@ -812,7 +813,8 @@ private void SetupAppInfoWithAllPermissions(string appId) {"id": "{{AgentBlueprintUpdateAuthId}}", "type": "Scope"}, {"id": "{{AgentBlueprintAddRemoveCredsId}}", "type": "Scope"}, {"id": "{{DelegatedPermissionGrantReadWriteAllId}}", "type": "Scope"}, - {"id": "{{DirectoryReadAllId}}", "type": "Scope"} + {"id": "{{DirectoryReadAllId}}", "type": "Scope"}, + {"id": "{{UserReadWriteAllId}}", "type": "Scope"} ] } ] @@ -823,7 +825,7 @@ private void SetupAppInfoWithAllPermissions(string appId) /// /// Sets up the Microsoft Graph SP permission resolution GET (select includes oauth2PermissionScopes). - /// Returns the 6 required permissions with GUIDs matching the test constants. + /// Returns the 7 required permissions with GUIDs matching the test constants. /// private void SetupPermissionResolution() { @@ -838,7 +840,8 @@ private void SetupPermissionResolution() {"id": "{{AgentBlueprintUpdateAuthId}}", "value": "AgentIdentityBlueprint.UpdateAuthProperties.All"}, {"id": "{{AgentBlueprintAddRemoveCredsId}}", "value": "AgentIdentityBlueprint.AddRemoveCreds.All"}, {"id": "{{DelegatedPermissionGrantReadWriteAllId}}", "value": "DelegatedPermissionGrant.ReadWrite.All"}, - {"id": "{{DirectoryReadAllId}}", "value": "Directory.Read.All"} + {"id": "{{DirectoryReadAllId}}", "value": "Directory.Read.All"}, + {"id": "{{UserReadWriteAllId}}", "value": "User.ReadWrite.All"} ] } ] From c15f92823889c9ec2457c842e076f2ba6c733c10 Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Fri, 3 Apr 2026 19:22:40 -0700 Subject: [PATCH 11/15] updated changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ff336603..7f625256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added -- Re-enabled `a365 create-instance` command (previously deprecated) — creates agent identity, agent user, assigns licenses, and grants admin consent for Graph/MCP/Bot Framework scopes in a single command +- Re-enabled `a365 create-instance` command (previously deprecated) — creates agent identity, agent user, and assigns licenses in a single command. The custom client app now requires the `User.ReadWrite.All` delegated permission for user creation and license assignment; existing users may need to update admin consent on their client app. - `Agent365.Observability.OtelWrite` scope now granted to all provisioned agent identities on the Observability API alongside `user_impersonation`, enabling agents to write OpenTelemetry data to the Agent 365 observability service - `ChannelMessage.Read.All` and `ChannelMessage.Send` added to default blueprint Microsoft Graph delegated scopes (`agentIdentityScopes`) - `Files.ReadWrite.All`, `ChannelMessage.Read.All`, and `ChannelMessage.Send` added to default blueprint Microsoft Graph application scopes (`agentApplicationScopes`) From 33a5d4596c0b171e3ab0f80d8b51108c41ed467a Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Mon, 6 Apr 2026 15:33:57 -0700 Subject: [PATCH 12/15] typo --- .../Helpers/ProjectSettingsSyncHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs index 0e8b0803..6c3ad3b8 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs @@ -99,7 +99,7 @@ ILogger logger } } - logger.LogInformation("Stamped TenantId, ServiceConnection, and AgentBluePrint settings into {ProjectPath}", project); + logger.LogInformation("Stamped TenantId, ServiceConnection, and AgentBlueprint settings into {ProjectPath}", project); } /// From 5ed3ccdeb0700f82472a84abbfe81ecd1e815a8f Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Mon, 6 Apr 2026 17:23:21 -0700 Subject: [PATCH 13/15] update permission checks and update app name --- .../Commands/CreateInstanceCommand.cs | 83 +--- .../Constants/ConfigConstants.cs | 4 +- .../Constants/McpConstants.cs | 4 +- .../Services/A365CreateInstanceRunner.cs | 416 ++++++------------ .../Services/AuthenticationService.cs | 10 +- .../Services/GraphApiService.cs | 146 ++++++ .../Services/Helpers/AdminConsentHelper.cs | 13 +- .../Helpers/SetupHelpersConsentUrlTests.cs | 6 +- .../Services/AuthenticationServiceTests.cs | 8 +- 9 files changed, 323 insertions(+), 367 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs index 681766ec..55eb1c3a 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs @@ -107,78 +107,9 @@ public static Command CreateCommand(ILogger logger, IConf logger.LogInformation(" Agent User ID: {AgenticUserId}", instanceConfig.AgenticUserId ?? "(not set)"); logger.LogInformation(" Agent User Principal Name: {AgentUserPrincipalName}", instanceConfig.AgentUserPrincipalName ?? "(not set)"); - // When consent inheritance is configured, the agent identity inherits - // permissions from the blueprint — no tenant-wide admin consent grants needed. - if (instanceConfig.IsInheritanceConfigured()) - { - logger.LogInformation("Consent inheritance is configured; skipping oauth2PermissionGrant creation (admin consent not required)"); - } - else - { - // Admin consent for MCP scopes (oauth2PermissionGrants) - logger.LogInformation("Granting MCP scopes to Agent Identity via oauth2PermissionGrants"); - - var manifestPath = Path.Combine(instanceConfig.DeploymentProjectPath ?? string.Empty, McpConstants.ToolingManifestFileName); - var scopesForAgent = await ManifestHelper.GetRequiredScopesAsync(manifestPath); - - // clientId must be the *service principal objectId* of the agentic app - var agenticAppSpObjectId = await graphApiService.LookupServicePrincipalByAppIdAsync( - instanceConfig.TenantId, - instanceConfig.AgenticAppId ?? string.Empty - ) ?? throw new InvalidOperationException($"Service Principal not found for agentic app Id {instanceConfig.AgenticAppId}"); - - var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(instanceConfig.Environment); - var Agent365ToolsResourceSpObjectId = await graphApiService.LookupServicePrincipalByAppIdAsync(instanceConfig.TenantId, resourceAppId) - ?? throw new InvalidOperationException("Agent 365 Tools Service Principal not found for appId " + resourceAppId); - - var response = await graphApiService.CreateOrUpdateOauth2PermissionGrantAsync( - instanceConfig.TenantId, - agenticAppSpObjectId, - Agent365ToolsResourceSpObjectId, - scopesForAgent - ); - - if (!response) - { - logger.LogWarning("Failed to create/update oauth2PermissionGrant for agent identity."); - } - - logger.LogInformation(" OAuth2 admin consent completed for Agent Identity (scopes: {Scopes})", - string.Join(' ', scopesForAgent)); - - logger.LogInformation(""); - logger.LogInformation("Granting Bot Framework API scopes to Agent Identity"); - - var botApiResourceSpObjectId = await graphApiService.EnsureServicePrincipalForAppIdAsync( - instanceConfig.TenantId, - ConfigConstants.MessagingBotApiAppId); - - // Grant oauth2PermissionGrants: *agent identity SP* -> Messaging Bot API SP - var botApiGrantOk = await graphApiService.CreateOrUpdateOauth2PermissionGrantAsync( - instanceConfig.TenantId, - agenticAppSpObjectId, - botApiResourceSpObjectId, - new[] { "Authorization.ReadWrite", "user_impersonation" }); - - if (!botApiGrantOk) - logger.LogWarning("Failed to create/update oauth2PermissionGrant for agent identity to Messaging Bot API."); - - var observabilityApiResourceSpObjectId = await graphApiService.EnsureServicePrincipalForAppIdAsync( - instanceConfig.TenantId, - ConfigConstants.ObservabilityApiAppId); - - // Grant oauth2PermissionGrants: *agent identity SP* -> Observability API SP - var observabilityApiGrantOk = await graphApiService.CreateOrUpdateOauth2PermissionGrantAsync( - instanceConfig.TenantId, - agenticAppSpObjectId, - observabilityApiResourceSpObjectId, - new[] { "user_impersonation", ConfigConstants.ObservabilityApiOtelWriteScope }); - - if (!observabilityApiGrantOk) - logger.LogWarning("Failed to create/update oauth2PermissionGrant for agent identity to Observability API."); - - logger.LogInformation("Admin consent granted for Agent Identity completed successfully"); - } + // The runner grants required permissions (from constants) merged with + // resourceConsents from the generated config via consentType=AllPrincipals. + logger.LogInformation("Permission grants configured for agent identity"); // Register agent with Microsoft Graph API logger.LogInformation(" Registering agent with Microsoft Graph API"); @@ -188,8 +119,8 @@ public static Command CreateCommand(ILogger logger, IConf logger.LogInformation(" - Agent Blueprint ID: {AgentBlueprintId}", instanceConfig.AgentBlueprintId); logger.LogInformation(" - Required Graph scopes: {Scopes}", string.Join(", ", instanceConfig.AgentIdentityScopes)); - // Attempt to read agent identity information from agenticuser.config.json - var agentUserConfigPath = Path.Combine(Environment.CurrentDirectory, "agenticuser.config.json"); + // Attempt to read agent identity information from a365.generated.config.json + var agentUserConfigPath = Path.Combine(Environment.CurrentDirectory, "a365.generated.config.json"); string? agenticAppId = instanceConfig.AgenticAppId; string? agenticUserId = instanceConfig.AgenticUserId; var baseEndpointName = $"{instanceConfig.WebAppName}-endpoint"; @@ -197,7 +128,7 @@ public static Command CreateCommand(ILogger logger, IConf if (File.Exists(agentUserConfigPath)) { - logger.LogInformation(" - Reading agent identity from agenticuser.config.json"); + logger.LogInformation(" - Reading agent identity from a365.generated.config.json"); try { var agentUserConfigText = await File.ReadAllTextAsync(agentUserConfigPath); @@ -238,7 +169,7 @@ public static Command CreateCommand(ILogger logger, IConf logger.LogInformation(" Agent Blueprint ID: {AgentBlueprintId}", instanceConfig.AgentBlueprintId); logger.LogInformation(" Agent Instance ID: {AgenticAppId}", instanceConfig.AgenticAppId); logger.LogInformation(" Agent User ID: {AgenticUserId}", instanceConfig.AgenticUserId); - logger.LogInformation(" Bot ID: {BotId}", instanceConfig.BotId); + logger.LogInformation(" Agent User Principal Name: {AgentUserPrincipalName}", instanceConfig.AgentUserPrincipalName); // Save the updated configuration using ConfigService await configService.SaveStateAsync(instanceConfig); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs index c9879ec6..f28eecc6 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs @@ -177,8 +177,8 @@ public static string GetAgent365ToolsResourceAppId(string environment) // Default to production app ID return environment?.ToLower() switch { - "prod" => McpConstants.Agent365ToolsProdAppId, - _ => McpConstants.Agent365ToolsProdAppId + "prod" => McpConstants.WorkIQToolsProdAppId, + _ => McpConstants.WorkIQToolsProdAppId }; } } \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs index d597798c..29168b35 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs @@ -9,8 +9,8 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Constants; public static class McpConstants { - // Agent 365 Tools App IDs for different environments - public const string Agent365ToolsProdAppId = "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1"; + // WorkIQ Tools App ID + public const string WorkIQToolsProdAppId = "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1"; /// /// Agent 365 Tools identifier URI (used for admin consent URL construction). diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs index 4b956d57..cc401e6d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs @@ -14,7 +14,7 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services; /// /// Supports all phases: Identity/User creation and License assignment. -/// MCP permissions are configured via inheritable permissions during setup phase. +/// Adds required permissions to agent identity via admin consent /// public sealed class A365CreateInstanceRunner { @@ -116,9 +116,6 @@ string GetConfig(string name) => ? protectedNode?.GetValue() ?? false : false; - // Check inheritance status from ResourceConsents - var inheritanceConfigured = IsInheritanceConfigured(instance); - // Decrypt the secret if it was encrypted if (!string.IsNullOrWhiteSpace(agentBlueprintClientSecret) && isProtected) { @@ -151,37 +148,6 @@ string GetConfig(string name) => _logger.LogInformation("Using environment from config: {Env}", environment); } - // AgentIdentityScopes (fallback to hardcoded defaults) - var agentIdentityScopes = new List(); - if (config.TryGetPropertyValue("agentIdentityScopes", out var scopesNode) && scopesNode is JsonArray scopesArray) - { - _logger.LogInformation("Found 'agentIdentityScopes' in config"); - agentIdentityScopes = scopesArray - .Select(s => s?.GetValue()) - .Where(s => !string.IsNullOrWhiteSpace(s)) - .ToList()!; - } - else if (config.TryGetPropertyValue("agentIdentityScope", out var singleScopeNode)) - { - var singleScope = singleScopeNode?.GetValue(); - if (!string.IsNullOrWhiteSpace(singleScope)) - { - _logger.LogInformation("Found single 'agentIdentityScope' in config"); - agentIdentityScopes.Add(singleScope); - } - } - else - { - _logger.LogInformation("'agentIdentityScopes' not found in config, using hardcoded defaults"); - agentIdentityScopes.AddRange(ConfigConstants.DefaultAgentIdentityScopes); - } - - if (agentIdentityScopes.Count == 0) - { - _logger.LogWarning("No agent identity scopes available, falling back to Graph default"); - agentIdentityScopes.Add($"{GraphApiConstants.BaseUrl}/.default"); - } - var usageLocation = GetConfig("agentUserUsageLocation"); await SaveInstanceAsync(generatedConfigPath, instance, cancellationToken); @@ -333,46 +299,161 @@ string GetConfig(string name) => _logger.LogInformation("Agent User already exists: {Id}", agenticUserId); } - // Consent URLs and polling + // Grant required permissions to the agent identity (AllPrincipals). + // Start with a baseline from constants, then merge any additional + // entries from resourceConsents in the generated config. if (!string.IsNullOrWhiteSpace(agenticAppId)) { - if (inheritanceConfigured) + // Look up the agent identity service principal + var agenticSpObjectId = await _graphService.LookupServicePrincipalByAppIdAsync( + tenantId, agenticAppId, cancellationToken); + + if (string.IsNullOrWhiteSpace(agenticSpObjectId)) { - _logger.LogInformation("Inheritance already configured; skipping admin consent requests"); - _logger.LogInformation("Phase 1 complete."); + _logger.LogError("Could not find service principal for agent identity {AppId}", agenticAppId); + return false; } - else + + // Build required permissions from well-known constants. + // Key = resourceAppId, Value = (displayName, scopes set) + var requiredPermissions = new Dictionary scopes)>(StringComparer.OrdinalIgnoreCase) { + [AuthenticationConstants.MicrosoftGraphResourceAppId] = ( + "Microsoft Graph", + new HashSet(ConfigConstants.DefaultAgentIdentityScopes, StringComparer.OrdinalIgnoreCase)), + [McpConstants.WorkIQToolsProdAppId] = ( + "Work IQ Tools", + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "McpServers.Mail.All", + "McpServersMetadata.Read.All" + }), + [ConfigConstants.ObservabilityApiAppId] = ( + "Observability API", + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "user_impersonation", + ConfigConstants.ObservabilityApiOtelWriteScope + }), + [ConfigConstants.MessagingBotApiAppId] = ( + "Messaging Bot API", + new HashSet(StringComparer.OrdinalIgnoreCase) + { + "Authorization.ReadWrite", + "user_impersonation" + }), + [PowerPlatformConstants.PowerPlatformApiResourceAppId] = ( + "Power Platform API", + new HashSet(StringComparer.OrdinalIgnoreCase) + { + PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead + }), + }; - var scopesJoined = string.Join(' ', agentIdentityScopes); - var consentGraph = $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={agenticAppId}&scope={Uri.EscapeDataString(scopesJoined)}&redirect_uri=https://entra.microsoft.com/TokenAuthorize&state=xyz123"; + // Merge additional scopes from resourceConsents in the generated config + if (instance.TryGetPropertyValue("resourceConsents", out var consentsNode) && + consentsNode is JsonArray consentsArray) + { + foreach (var consentEntry in consentsArray) + { + var obj = consentEntry?.AsObject(); + if (obj == null) continue; - SetInstanceField(instance, "agentIdentityConsentUrlGraph", consentGraph); + var resourceAppId = obj["resourceAppId"]?.GetValue(); + if (string.IsNullOrWhiteSpace(resourceAppId)) continue; - // Request admin consent for Graph API - var consentGraphSuccess = await RequestAdminConsentAsync( - consentGraph, - agenticAppId, - tenantId, - "Agent Instance Graph scopes", - 180, - cancellationToken); + var resourceName = obj["resourceName"]?.GetValue() ?? "(unknown)"; - // Consent for MCP servers from ToolingManifest.json - var consentMcpSuccess = await ProcessMcpConsentAsync( - instance, - agenticAppId, + if (!requiredPermissions.TryGetValue(resourceAppId, out var entry)) + { + entry = (resourceName, new HashSet(StringComparer.OrdinalIgnoreCase)); + requiredPermissions[resourceAppId] = entry; + } + + if (obj.TryGetPropertyValue("scopes", out var consentScopesNode) && consentScopesNode is JsonArray scopesArr) + { + foreach (var s in scopesArr) + { + var scopeValue = s?.GetValue(); + if (!string.IsNullOrWhiteSpace(scopeValue)) + entry.scopes.Add(scopeValue); + } + } + } + } + + _logger.LogInformation("Granting permissions to agent identity across {Count} resource(s)", requiredPermissions.Count); + + // Get existing oauth2PermissionGrants on the agent identity + var existingGrants = await _graphService.GetOauth2PermissionGrantsAsync( + tenantId, agenticSpObjectId, cancellationToken); + + // Build a lookup: resourceSpObjectId -> set of already-granted scopes + var existingScopesByResource = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var grant in existingGrants) + { + if (!existingScopesByResource.TryGetValue(grant.resourceId, out var scopeSet)) + { + scopeSet = new HashSet(StringComparer.OrdinalIgnoreCase); + existingScopesByResource[grant.resourceId] = scopeSet; + } + foreach (var s in grant.scope.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + scopeSet.Add(s); + } + + // For each resource, check if scopes are already granted; if not, add them + foreach (var (resourceAppId, (resourceName, scopes)) in requiredPermissions) + { + if (scopes.Count == 0) + { + _logger.LogDebug("No scopes for '{Name}' ({AppId}); skipping", resourceName, resourceAppId); + continue; + } + + var resourceSpObjectId = await _graphService.EnsureServicePrincipalForAppIdAsync( + tenantId, resourceAppId, cancellationToken); + + if (string.IsNullOrWhiteSpace(resourceSpObjectId)) + { + _logger.LogWarning("Could not find or create service principal for resource '{Name}' ({AppId}); skipping", + resourceName, resourceAppId); + continue; + } + + // Determine which scopes are missing + var alreadyGranted = existingScopesByResource.TryGetValue(resourceSpObjectId, out var existing) + ? existing + : new HashSet(StringComparer.OrdinalIgnoreCase); + + var missingScopes = scopes.Where(s => !alreadyGranted.Contains(s)).ToList(); + + if (missingScopes.Count == 0) + { + _logger.LogInformation("All scopes for '{Name}' ({AppId}) already granted to agent identity; skipping", + resourceName, resourceAppId); + continue; + } + + // Grant scopes as AllPrincipals (tenant-wide) on the agent identity SP. + // CreateOrUpdateOauth2PermissionGrantAsync merges with existing grants. + _logger.LogInformation("Granting scopes for '{Name}' ({AppId}) to agent identity (consentType=AllPrincipals): {Scopes}", + resourceName, resourceAppId, string.Join(", ", scopes)); + + var grantOk = await _graphService.CreateOrUpdateOauth2PermissionGrantAsync( tenantId, - configDirectory, + agenticSpObjectId, + resourceSpObjectId, + scopes, cancellationToken); - instance["consent1Granted"] = consentGraphSuccess; - instance["consent3Granted"] = consentMcpSuccess; - - if (!consentGraphSuccess || !consentMcpSuccess) + if (!grantOk) + { + _logger.LogWarning("Failed to grant scopes for '{Name}' ({AppId}) to agent identity", + resourceName, resourceAppId); + } + else { - _logger.LogWarning("One or more consents may not have been detected"); - _logger.LogInformation("The setup will continue, but you may need to grant consent manually if needed."); + _logger.LogInformation("Scopes for '{Name}' granted successfully", resourceName); } } } @@ -802,94 +883,6 @@ private async Task AssignManagerAsync( } } - /// - /// Process MCP consent from ToolingManifest.json - /// - private async Task ProcessMcpConsentAsync( - JsonObject instance, - string agenticAppId, - string tenantId, - string configDirectory, - CancellationToken ct) - { - var scriptDir = Path.GetDirectoryName(configDirectory) ?? Environment.CurrentDirectory; - var toolingManifestPath = Path.GetFullPath(Path.Combine( - scriptDir, - "../../dotnet/samples/semantic-kernel-multiturn/ToolingManifest.json")); - - if (!File.Exists(toolingManifestPath)) - { - _logger.LogWarning("ToolingManifest.json not found at {Path}; skipping MCP consent", toolingManifestPath); - return false; - } - - try - { - var manifest = JsonNode.Parse(await File.ReadAllTextAsync(toolingManifestPath, ct))!.AsObject(); - var mcpAudiences = new Dictionary>(); - - if (manifest.TryGetPropertyValue("mcpServers", out var serversNode) && - serversNode is JsonArray servers) - { - foreach (var server in servers) - { - var serverObj = server?.AsObject(); - if (serverObj == null) continue; - - var audience = serverObj["audience"]?.GetValue(); - var scope = serverObj["scope"]?.GetValue(); - - if (string.IsNullOrWhiteSpace(audience) || string.IsNullOrWhiteSpace(scope)) - continue; - - var audienceId = audience.Replace("api://", ""); - - if (!mcpAudiences.ContainsKey(audienceId)) - { - mcpAudiences[audienceId] = new List(); - } - - mcpAudiences[audienceId].Add(scope); - } - } - - // Build consent for each unique audience - var allConsentSuccess = true; - var consentCounter = 3; - - foreach (var (audienceId, scopes) in mcpAudiences) - { - var uniqueScopes = scopes.Distinct().ToList(); - var scopesWithAudience = uniqueScopes.Select(s => $"api://{audienceId}/{s}"); - var mcpScopesJoined = string.Join(' ', scopesWithAudience); - - var consentUrl = $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={agenticAppId}&scope={Uri.EscapeDataString(mcpScopesJoined)}&redirect_uri=https://entra.microsoft.com/TokenAuthorize&state=xyz123"; - - SetInstanceField(instance, $"agentIdentityConsentUrlMcp{consentCounter}", consentUrl); - - var consentSuccess = await RequestAdminConsentAsync( - consentUrl, - agenticAppId, - tenantId, - $"Agent Instance MCP scopes for audience {audienceId}", - 180, - ct); - - instance[$"consent{consentCounter}Granted"] = consentSuccess; - - if (!consentSuccess) allConsentSuccess = false; - consentCounter++; - } - - return allConsentSuccess; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to process ToolingManifest.json for MCP consent"); - return false; - } - } - /// /// Assign licenses using Microsoft Graph API /// Replaces inline PowerShell license assignment script @@ -998,106 +991,6 @@ await File.WriteAllTextAsync( _logger.LogInformation("Saved instance state to {Path}", path); } - private async Task RequestAdminConsentAsync( - string consentUrl, - string appId, - string tenantId, - string description, - int timeoutSeconds, - CancellationToken cancellationToken) - { - _logger.LogInformation(""); - _logger.LogInformation("=== Consent Required: {Desc} ===", description); - _logger.LogInformation("Opening browser for admin consent..."); - _logger.LogInformation("URL: {Url}", consentUrl); - - // Open browser - BrowserHelper.TryOpenUrl(consentUrl, _logger); - - _logger.LogInformation(""); - _logger.LogInformation("Waiting for admin consent (timeout: {Timeout} seconds)...", timeoutSeconds); - _logger.LogInformation("Polling for consent status..."); - - var startTime = DateTime.UtcNow; - var pollInterval = 5; - string? spId = null; - var dotCount = 0; - - while ((DateTime.UtcNow - startTime).TotalSeconds < timeoutSeconds && !cancellationToken.IsCancellationRequested) - { - // Get service principal ID - if (spId == null) - { - var spResult = await _executor.ExecuteAsync( - "az", - $"rest --method GET --url \"{GraphApiConstants.BaseUrl}/v1.0/servicePrincipals?$filter=appId eq '{appId}'\"", - captureOutput: true, - suppressErrorLogging: true, - cancellationToken: cancellationToken); - - if (spResult.Success) - { - try - { - using var doc = JsonDocument.Parse(spResult.StandardOutput); - var value = doc.RootElement.GetProperty("value"); - if (value.GetArrayLength() > 0) - { - spId = value[0].GetProperty("id").GetString(); - } - } - catch { /* ignore parse errors */ } - } - } - - // Check for grants - if (spId != null) - { - var grants = await _executor.ExecuteAsync( - "az", - $"rest --method GET --url \"{GraphApiConstants.BaseUrl}/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spId}'\"", - captureOutput: true, - suppressErrorLogging: true, - cancellationToken: cancellationToken); - - if (grants.Success) - { - try - { - using var gdoc = JsonDocument.Parse(grants.StandardOutput); - var arr = gdoc.RootElement.GetProperty("value"); - if (arr.GetArrayLength() > 0) - { - _logger.LogInformation(""); - _logger.LogInformation("Consent granted successfully!"); - await Task.Delay(2000, cancellationToken); // Brief pause to ensure consent propagates - return true; - } - } - catch { /* ignore parse errors */ } - } - } - - // Show progress - Console.Write("."); - dotCount++; - if (dotCount >= 12) - { - Console.Write(" (still waiting...)"); - Console.WriteLine(); - dotCount = 0; - } - - await Task.Delay(TimeSpan.FromSeconds(pollInterval), cancellationToken); - } - - Console.WriteLine(); - _logger.LogWarning("Timeout waiting for admin consent"); - _logger.LogInformation("You can manually verify consent was granted and continue."); - return false; - } - - /// /// Verify that a service principal exists in Azure AD for the given app ID. /// This is critical before creating an agent user that references the identity as a parent. @@ -1161,27 +1054,4 @@ private async Task VerifyServicePrincipalExistsAsync( return false; } } - - /// - /// Checks if inheritable permissions are configured by examining ResourceConsents. - /// Returns true if all resources with inheritable permissions have them configured. - /// - private static bool IsInheritanceConfigured(JsonNode instance) - { - if (!instance.AsObject().TryGetPropertyValue("resourceConsents", out var resourceConsentsNode)) - return false; - - if (resourceConsentsNode?.AsArray() is not { } resourceConsents || resourceConsents.Count == 0) - return false; - - var resourcesWithInheritance = resourceConsents - .Where(rc => rc?["inheritablePermissionsConfigured"] != null) - .ToList(); - - if (resourcesWithInheritance.Count == 0) - return false; - - return resourcesWithInheritance.All(rc => - rc?["inheritablePermissionsConfigured"]?.GetValue() == true); - } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs index 14cf72ce..fd38dd46 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs @@ -143,7 +143,7 @@ public async Task GetAccessTokenAsync( } // Authenticate interactively with specific tenant and scopes - _logger.LogInformation("Authentication required for Agent 365 Tools"); + _logger.LogInformation("Authentication required for Work IQ Tools"); var token = await AuthenticateInteractivelyAsync(resourceUrl, tenantId, clientId, scopes, useInteractiveBrowser, loginHint: userId); // Cache the token with the appropriate cache key @@ -194,7 +194,7 @@ private async Task AuthenticateInteractivelyAsync( { string scope; // Check if this is the production App ID - if (resourceUrl == McpConstants.Agent365ToolsProdAppId) + if (resourceUrl == McpConstants.WorkIQToolsProdAppId) { scope = $"{resourceUrl}/.default"; _logger.LogInformation("Authenticating to Agent 365 Tools"); @@ -205,9 +205,9 @@ private async Task AuthenticateInteractivelyAsync( // Use production App ID by default // For non-production environments, users should provide the App ID directly via config // or set environment variable A365_MCP_APP_ID (without environment suffix for backward compatibility) - var appId = Environment.GetEnvironmentVariable("A365_MCP_APP_ID") ?? McpConstants.Agent365ToolsProdAppId; + var appId = Environment.GetEnvironmentVariable("A365_MCP_APP_ID") ?? McpConstants.WorkIQToolsProdAppId; - if (appId != McpConstants.Agent365ToolsProdAppId) + if (appId != McpConstants.WorkIQToolsProdAppId) { _logger.LogInformation("Using custom Agent 365 Tools App ID from A365_MCP_APP_ID environment variable"); } @@ -426,7 +426,7 @@ public async Task GetAccessTokenForMcpAsync(string resourceUrl, string? public string[] ResolveScopesForResource(string resourceUrl, string? manifestPath = null) { // Default to Agent 365 Tools resource app ID scope for backward compatibility - var scope = $"{McpConstants.Agent365ToolsProdAppId}/.default"; + var scope = $"{McpConstants.WorkIQToolsProdAppId}/.default"; var defaultScopes = new[] { scope }; // If no manifest path provided, try to find it in current directory diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index 7ee33719..af39b13f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -608,6 +608,152 @@ public async Task CreateOrUpdateOauth2PermissionGrantAsync( return await GraphPatchAsync(tenantId, $"/v1.0/oauth2PermissionGrants/{existingId}", new { scope = merged }, ct, permissionGrantScopes); } + /// + /// Creates or updates an oauth2PermissionGrant with consentType=Principal, scoped to a + /// specific principal (service principal). This is used when admin consent has not been + /// granted on the blueprint, so permissions must be granted directly to the agent identity. + /// + /// Azure AD tenant ID + /// Object ID of the client service principal (agent identity) + /// Object ID of the resource service principal (e.g. Microsoft Graph) + /// Object ID of the principal (the agent identity SP) to scope the grant to + /// Scopes to grant + /// Cancellation token + /// Optional MSAL scopes for token acquisition + /// True on success + public async Task CreatePrincipalOauth2PermissionGrantAsync( + string tenantId, + string clientSpObjectId, + string resourceSpObjectId, + string principalId, + IEnumerable scopes, + CancellationToken ct = default, + IEnumerable? permissionGrantScopes = null) + { + var desiredScopeString = string.Join(' ', scopes); + + // Read existing principal-scoped grant for this client+resource+principal combination + // Note: The Graph API oauth2PermissionGrants endpoint only supports filtering by clientId. + // We filter the results in code for resourceId, consentType, and principalId. + string? existingId = null; + string existingScopes = ""; + + using (var listDoc = await GraphGetAsync( + tenantId, + $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{clientSpObjectId}'", + ct, + permissionGrantScopes)) + { + if (listDoc?.RootElement.TryGetProperty("value", out var arr) == true) + { + foreach (var grant in arr.EnumerateArray()) + { + var grantResourceId = grant.TryGetProperty("resourceId", out var rid) ? rid.GetString() : null; + var grantConsentType = grant.TryGetProperty("consentType", out var ctp) ? ctp.GetString() : null; + var grantPrincipalId = grant.TryGetProperty("principalId", out var pid) ? pid.GetString() : null; + + if (string.Equals(grantResourceId, resourceSpObjectId, StringComparison.OrdinalIgnoreCase) && + string.Equals(grantConsentType, "Principal", StringComparison.OrdinalIgnoreCase) && + string.Equals(grantPrincipalId, principalId, StringComparison.OrdinalIgnoreCase)) + { + existingId = grant.TryGetProperty("id", out var idProp) ? idProp.GetString() : null; + existingScopes = grant.TryGetProperty("scope", out var scopeProp) ? scopeProp.GetString() ?? "" : ""; + break; + } + } + } + } + + if (string.IsNullOrWhiteSpace(existingId)) + { + var payload = new + { + clientId = clientSpObjectId, + consentType = "Principal", + principalId, + resourceId = resourceSpObjectId, + scope = desiredScopeString + }; + + _logger.LogDebug("Graph POST /v1.0/oauth2PermissionGrants (Principal) body: {Body}", JsonSerializer.Serialize(payload)); + + const int maxRetries = 8; + const int baseDelaySeconds = 5; + for (int attempt = 0; attempt < maxRetries; attempt++) + { + var grantResponse = await GraphPostWithResponseAsync(tenantId, "/v1.0/oauth2PermissionGrants", payload, ct, permissionGrantScopes); + grantResponse.Json?.Dispose(); + + if (grantResponse.IsSuccess) + return true; + + if (!grantResponse.Body.Contains("Directory_ObjectNotFound", StringComparison.OrdinalIgnoreCase)) + return false; + + if (attempt < maxRetries - 1) + { + var delaySecs = (int)Math.Min(baseDelaySeconds * Math.Pow(2, attempt), 60); + _logger.LogWarning( + "Service principal not yet replicated to grants endpoint - retrying in {Delay}s (attempt {Attempt}/{Max})...", + delaySecs, attempt + 1, maxRetries - 1); + await Task.Delay(TimeSpan.FromSeconds(delaySecs), ct); + } + } + + _logger.LogWarning( + "OAuth2 permission grant (Principal) failed after {MaxRetries} retries - service principal may still be propagating.", + maxRetries); + return false; + } + + // Merge scopes if needed + var currentSet = new HashSet(existingScopes.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase); + var desiredSet = new HashSet(desiredScopeString.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase); + + if (desiredSet.IsSubsetOf(currentSet)) return true; + + currentSet.UnionWith(desiredSet); + var merged = string.Join(' ', currentSet); + + return await GraphPatchAsync(tenantId, $"/v1.0/oauth2PermissionGrants/{existingId}", new { scope = merged }, ct, permissionGrantScopes); + } + + /// + /// Retrieves the oauth2PermissionGrants (admin consent) for a given service principal. + /// Used to check whether admin consent has been granted on the blueprint. + /// + /// Azure AD tenant ID + /// Object ID of the service principal to check grants for + /// Cancellation token + /// List of grants with their scope strings and consent types, or empty list on failure + public virtual async Task> GetOauth2PermissionGrantsAsync( + string tenantId, + string clientSpObjectId, + CancellationToken ct = default) + { + var grants = new List<(string resourceId, string scope, string consentType)>(); + + using var doc = await GraphGetAsync( + tenantId, + $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{clientSpObjectId}'", + ct); + + if (doc == null) return grants; + + if (doc.RootElement.TryGetProperty("value", out var arr)) + { + foreach (var grant in arr.EnumerateArray()) + { + var resourceId = grant.TryGetProperty("resourceId", out var rid) ? rid.GetString() ?? "" : ""; + var scope = grant.TryGetProperty("scope", out var s) ? s.GetString() ?? "" : ""; + var consentType = grant.TryGetProperty("consentType", out var ct2) ? ct2.GetString() ?? "" : ""; + grants.Add((resourceId, scope, consentType)); + } + } + + return grants; + } + /// /// Checks if the current user has sufficient privileges to create service principals. /// Virtual to allow mocking in unit tests using Moq. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/AdminConsentHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/AdminConsentHelper.cs index 5c06052a..075d93aa 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/AdminConsentHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/AdminConsentHelper.cs @@ -199,6 +199,8 @@ public static async Task PollAdminConsentAsync( /// Cancellation token /// OAuth2 scopes for Graph token acquisition. Should include DelegatedPermissionGrant.ReadWrite.All /// to read oauth2PermissionGrants. When null, falls back to Azure CLI token which may lack required permissions. + /// Optional consent type filter (e.g. "AllPrincipals" or "Principal"). + /// When specified, only grants with this consent type are considered. When null, any consent type matches. /// True if all required scopes are already granted, false otherwise public static async Task CheckConsentExistsAsync( Services.GraphApiService graphApiService, @@ -208,7 +210,8 @@ public static async Task CheckConsentExistsAsync( System.Collections.Generic.IEnumerable requiredScopes, ILogger logger, CancellationToken ct, - System.Collections.Generic.IEnumerable? scopes = null) + System.Collections.Generic.IEnumerable? scopes = null, + string? consentType = null) { if (string.IsNullOrWhiteSpace(clientSpId) || string.IsNullOrWhiteSpace(resourceSpId)) { @@ -221,9 +224,15 @@ public static async Task CheckConsentExistsAsync( { // Query existing grants — pass scopes so EnsureGraphHeadersAsync uses the MSAL token provider // (which has DelegatedPermissionGrant.ReadWrite.All) instead of falling back to the Azure CLI token. + var filter = $"clientId eq '{clientSpId}' and resourceId eq '{resourceSpId}'"; + if (!string.IsNullOrWhiteSpace(consentType)) + { + filter += $" and consentType eq '{consentType}'"; + } + var grantDoc = await graphApiService.GraphGetAsync( tenantId, - $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{clientSpId}' and resourceId eq '{resourceSpId}'", + $"/v1.0/oauth2PermissionGrants?$filter={filter}", ct, scopes); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs index dd21eb6b..f23e76de 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs @@ -127,7 +127,7 @@ public void PopulateAdminConsentUrls_UpsertsConsentUrlIntoResourceConsents() }; var mcpScopes = new[] { "McpServers.Mail.All" }; - var names = SetupHelpers.PopulateAdminConsentUrls(config, McpConstants.Agent365ToolsProdAppId, mcpScopes); + var names = SetupHelpers.PopulateAdminConsentUrls(config, McpConstants.WorkIQToolsProdAppId, mcpScopes); names.Should().NotBeEmpty(); config.ResourceConsents.Should().NotBeEmpty(); @@ -143,7 +143,7 @@ public void PopulateAdminConsentUrls_ReturnsResourceNamesForAllPopulatedUrls() AgentBlueprintId = BlueprintClientId, }; - var names = SetupHelpers.PopulateAdminConsentUrls(config, McpConstants.Agent365ToolsProdAppId, new[] { "scope" }); + var names = SetupHelpers.PopulateAdminConsentUrls(config, McpConstants.WorkIQToolsProdAppId, new[] { "scope" }); names.Should().BeEquivalentTo(config.ResourceConsents.Select(rc => rc.ResourceName)); } @@ -163,7 +163,7 @@ public void PopulateAdminConsentUrls_WhenConsentAlreadyExists_UpdatesUrl() ConsentUrl = "https://old-url" }); - SetupHelpers.PopulateAdminConsentUrls(config, McpConstants.Agent365ToolsProdAppId, new[] { "scope" }); + SetupHelpers.PopulateAdminConsentUrls(config, McpConstants.WorkIQToolsProdAppId, new[] { "scope" }); var botConsent = config.ResourceConsents.First(rc => rc.ResourceName == "Messaging Bot API"); botConsent.ConsentUrl.Should().NotBe("https://old-url", diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AuthenticationServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AuthenticationServiceTests.cs index 1a8217a5..cfddfec4 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AuthenticationServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AuthenticationServiceTests.cs @@ -168,7 +168,7 @@ public void ResolveScopesForResource_WithNullOrEmptyScopes_ShouldReturnDefaultSc // Assert - Should return default Power Platform scope when no MCP scopes are found Assert.Single(noScopeResult); - var scope = $"{McpConstants.Agent365ToolsProdAppId}/.default"; + var scope = $"{McpConstants.WorkIQToolsProdAppId}/.default"; Assert.Equal(scope, noScopeResult[0]); } finally @@ -334,7 +334,7 @@ public void ResolveScopesForResource_WithMissingManifestFile_ShouldReturnDefault // Assert scopes.Should().ContainSingle(); - var expectedScope = $"{McpConstants.Agent365ToolsProdAppId}/.default"; + var expectedScope = $"{McpConstants.WorkIQToolsProdAppId}/.default"; scopes[0].Should().Be(expectedScope); } @@ -360,7 +360,7 @@ public void ResolveScopesForResource_WithEmptyMcpServers_ShouldReturnDefaultScop // Assert scopes.Should().ContainSingle(); - var expectedScope = $"{McpConstants.Agent365ToolsProdAppId}/.default"; + var expectedScope = $"{McpConstants.WorkIQToolsProdAppId}/.default"; scopes[0].Should().Be(expectedScope); } finally @@ -530,7 +530,7 @@ public void ResolveScopesForResource_WithMalformedJson_ShouldReturnDefaultScope( // Assert scopes.Should().ContainSingle(); - var expectedScope = $"{McpConstants.Agent365ToolsProdAppId}/.default"; + var expectedScope = $"{McpConstants.WorkIQToolsProdAppId}/.default"; scopes[0].Should().Be(expectedScope); } finally From 86af1d708f3cc563758f72d74b1b85c419960530 Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Tue, 7 Apr 2026 13:15:21 -0700 Subject: [PATCH 14/15] update instructions --- docs/agent365-guided-setup/a365-setup-instructions.md | 7 ++++--- .../Commands/CreateInstanceCommand.cs | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/agent365-guided-setup/a365-setup-instructions.md b/docs/agent365-guided-setup/a365-setup-instructions.md index dd9e1a61..8a29f4c8 100644 --- a/docs/agent365-guided-setup/a365-setup-instructions.md +++ b/docs/agent365-guided-setup/a365-setup-instructions.md @@ -57,7 +57,7 @@ After installing or updating, confirm the CLI is ready by running `a365 -h` to d ### Adapt to CLI version differences -The CLI is under active development, and some commands may have changed in recent versions. The instructions in this prompt assume you have the latest version. If you discover that a command referenced later (such as `publish`) is not recognized, it means you have an older version – in that case, upgrade the CLI. Using the latest version is essential because older flows (e.g. the `create-instance` command) have been deprecated in favor of new commands (`publish`, etc.). If upgrading isn't possible, adjust your steps according to the older CLI's documentation (for example, use the old `a365 create-instance` command in place of `publish`), but prefer to upgrade if at all feasible. +The CLI is under active development, and some commands may have changed in recent versions. The instructions in this prompt assume you have the latest version. If you discover that a command referenced later (such as `publish`) is not recognized, it means you have an older version – in that case, upgrade the CLI. Using the latest version is essential because the CLI evolves rapidly and newer versions include important fixes and new commands (e.g. `create-instance`, `publish`). If upgrading isn't possible, adjust your steps according to the older CLI's documentation, but prefer to upgrade if at all feasible. ### Step 1 completion @@ -89,7 +89,7 @@ Once the user provides the ID, replace `` in the command below an az ad app show --id --query "{appId:appId, displayName:displayName, requiredResourceAccess:requiredResourceAccess}" -o json && az ad app permission list-grants --id --query "[].{resourceDisplayName:resourceDisplayName, scope:scope}" -o table ``` -From the output of the command above, verify these 6 permissions appear with admin consent. If any are missing or consent is not granted, see "What to do if validation fails" below. +From the output of the command above, verify these 7 permissions appear with admin consent. If any are missing or consent is not granted, see "What to do if validation fails" below. Required **delegated** Microsoft Graph permissions (all must have **admin consent granted**): @@ -97,6 +97,7 @@ Required **delegated** Microsoft Graph permissions (all must have **admin consen |------------|-------------| | `AgentIdentityBlueprint.ReadWrite.All` | Manage Agent 365 Blueprints | | `AgentIdentityBlueprint.UpdateAuthProperties.All` | Update Blueprint auth properties | +| `AgentIdentityBlueprint.AddRemoveCreds.All` | Add and remove credentials on Agent Identity Blueprints | | `Application.ReadWrite.All` | Create and manage Azure AD applications | | `DelegatedPermissionGrant.ReadWrite.All` | Grant delegated permissions | | `Directory.Read.All` | Read directory data | @@ -108,7 +109,7 @@ If the app does not exist, permissions are missing, or admin consent has not bee 1. STOP — do not proceed to run any `a365` CLI commands. 2. Inform the user the custom client app registration is missing or incomplete. -3. Direct the user to the official setup guide: register the app, configure as a Public client with redirect URI `http://localhost:8400`, add all six permissions above, and have a Global Admin grant admin consent. +3. Direct the user to the official setup guide: register the app, configure as a Public client with redirect URI `http://localhost:8400`, add all seven permissions above, and have a Global Admin grant admin consent. 4. Wait for the user to confirm the app is properly configured, then re-run the same validation command above. Save the `clientAppId` value — it will be used automatically in Step 3 (do NOT ask the user for it again). diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs index 55eb1c3a..4eeb99da 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs @@ -55,7 +55,7 @@ public static Command CreateCommand(ILogger logger, IConf { logger.LogInformation("DRY RUN: Agent 365 Instance Creation - All Steps"); logger.LogInformation("This would execute the following operations:"); - logger.LogInformation(" 1. Create Agent Identity and Agent User"); + logger.LogInformation(" 1. Create Agent Identity and Agent User with required permissions"); logger.LogInformation(" 2. Add licenses to Agent User"); logger.LogInformation("No actual changes will be made."); return; From df2f9856afa2d23984ca184c5cb38f3d346d6fe1 Mon Sep 17 00:00:00 2001 From: Grant Harris Date: Tue, 7 Apr 2026 13:18:08 -0700 Subject: [PATCH 15/15] fixed comments --- .../Services/A365CreateInstanceRunner.cs | 22 +-- .../Services/GraphApiService.cs | 184 ++++++++---------- 2 files changed, 91 insertions(+), 115 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs index cc401e6d..9843d762 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs @@ -542,13 +542,13 @@ string GetConfig(string name) => string? currentUserId = null; try { - // Use Azure CLI token to get current user (this requires delegated context) + // Use MSAL delegated token to get current user (this requires delegated context) var delegatedToken = await _graphService.GetGraphAccessTokenAsync(tenantId, ct: ct); if (!string.IsNullOrWhiteSpace(delegatedToken)) { using var delegatedClient = HttpClientFactory.CreateAuthenticatedClient(delegatedToken, correlationId: correlationId); - using var meResponse = await delegatedClient.GetAsync($"{GraphApiConstants.BaseUrl}/v1.0/me", ct); + using var meResponse = await delegatedClient.GetAsync($"{_graphService.GraphBaseUrl}/v1.0/me", ct); if (meResponse.IsSuccessStatusCode) { var meJson = await meResponse.Content.ReadAsStringAsync(ct); @@ -664,7 +664,7 @@ string GetConfig(string name) => { new KeyValuePair("client_id", clientId), new KeyValuePair("client_secret", clientSecret), - new KeyValuePair("scope", $"{GraphApiConstants.BaseUrl}/.default"), + new KeyValuePair("scope", $"{_graphService.GraphBaseUrl}/.default"), new KeyValuePair("grant_type", "client_credentials") }); @@ -741,7 +741,7 @@ string GetConfig(string name) => // Check if user already exists try { - var checkUserUrl = $"{GraphApiConstants.BaseUrl}/beta/users/{Uri.EscapeDataString(userPrincipalName)}"; + var checkUserUrl = $"{_graphService.GraphBaseUrl}/beta/users/{Uri.EscapeDataString(userPrincipalName)}"; var checkResponse = await httpClient.GetAsync(checkUserUrl, ct); if (checkResponse.IsSuccessStatusCode) @@ -765,7 +765,7 @@ string GetConfig(string name) => // Create agent user var mailNickname = userPrincipalName.Split('@')[0]; - var createUserUrl = $"{GraphApiConstants.BaseUrl}/beta/users"; + var createUserUrl = $"{_graphService.GraphBaseUrl}/beta/users"; var userBody = new JsonObject { ["@odata.type"] = "microsoft.graph.agentUser", @@ -832,7 +832,7 @@ private async Task AssignManagerAsync( using var httpClient = HttpClientFactory.CreateAuthenticatedClient(graphToken, correlationId: correlationId); // Look up manager by email - var managerUrl = $"{GraphApiConstants.BaseUrl}/v1.0/users?$filter=mail eq '{managerEmail}'"; + var managerUrl = $"{_graphService.GraphBaseUrl}/v1.0/users?$filter=mail eq '{managerEmail}'"; var managerResponse = await httpClient.GetAsync(managerUrl, ct); if (!managerResponse.IsSuccessStatusCode) @@ -856,10 +856,10 @@ private async Task AssignManagerAsync( var managerName = manager["displayName"]?.GetValue(); // Assign manager - var assignManagerUrl = $"{GraphApiConstants.BaseUrl}/v1.0/users/{userId}/manager/$ref"; + var assignManagerUrl = $"{_graphService.GraphBaseUrl}/v1.0/users/{userId}/manager/$ref"; var assignBody = new JsonObject { - ["@odata.id"] = $"{GraphApiConstants.BaseUrl}/v1.0/users/{managerId}" + ["@odata.id"] = $"{_graphService.GraphBaseUrl}/v1.0/users/{managerId}" }; var assignResponse = await httpClient.PutAsync( @@ -914,7 +914,7 @@ private async Task AssignLicensesAsync( if (!string.IsNullOrWhiteSpace(usageLocation)) { _logger.LogInformation(" - Setting usage location: {Location}", usageLocation); - var updateUserUrl = $"{GraphApiConstants.BaseUrl}/v1.0/users/{userId}"; + var updateUserUrl = $"{_graphService.GraphBaseUrl}/v1.0/users/{userId}"; var updateBody = new JsonObject { ["usageLocation"] = usageLocation @@ -934,7 +934,7 @@ private async Task AssignLicensesAsync( // Assign licenses _logger.LogInformation(" - Assigning Microsoft 365 licenses"); - var assignLicenseUrl = $"{GraphApiConstants.BaseUrl}/v1.0/users/{userId}/assignLicense"; + var assignLicenseUrl = $"{_graphService.GraphBaseUrl}/v1.0/users/{userId}/assignLicense"; var licenseBody = new JsonObject { ["addLicenses"] = new JsonArray @@ -1020,7 +1020,7 @@ private async Task VerifyServicePrincipalExistsAsync( using var httpClient = HttpClientFactory.CreateAuthenticatedClient(graphToken, correlationId: correlationId); // Query for service principal by appId - var spUrl = $"{GraphApiConstants.BaseUrl}/v1.0/servicePrincipals?$filter=appId eq '{appId}'"; + var spUrl = $"{_graphService.GraphBaseUrl}/v1.0/servicePrincipals?$filter=appId eq '{appId}'"; using var response = await httpClient.GetAsync(spUrl, ct); if (!response.IsSuccessStatusCode) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index af39b13f..ae887dd6 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -528,84 +528,15 @@ public async Task CreateOrUpdateOauth2PermissionGrantAsync( CancellationToken ct = default, IEnumerable? permissionGrantScopes = null) { - var desiredScopeString = string.Join(' ', scopes); - - // Read existing — extract string values immediately so JsonDocument can be disposed - string? existingId = null; - string existingScopes = ""; - - using (var listDoc = await GraphGetAsync( + return await CreateOrUpdateOauth2PermissionGrantCoreAsync( tenantId, - $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{clientSpObjectId}' and resourceId eq '{resourceSpObjectId}'", + clientSpObjectId, + resourceSpObjectId, + principalId: null, + consentType: "AllPrincipals", + scopes, ct, - permissionGrantScopes)) - { - if (listDoc?.RootElement.TryGetProperty("value", out var arr) == true && arr.GetArrayLength() > 0) - { - var grant = arr[0]; - existingId = grant.TryGetProperty("id", out var idProp) ? idProp.GetString() : null; - existingScopes = grant.TryGetProperty("scope", out var scopeProp) ? scopeProp.GetString() ?? "" : ""; - } - } - - if (string.IsNullOrWhiteSpace(existingId)) - { - // AllPrincipals (tenant-wide) grants require Global Administrator. - // Only called from admin paths (setup admin or setup all run by GA). - var payload = new - { - clientId = clientSpObjectId, - consentType = "AllPrincipals", - resourceId = resourceSpObjectId, - scope = desiredScopeString - }; - - _logger.LogDebug("Graph POST /v1.0/oauth2PermissionGrants body: {Body}", JsonSerializer.Serialize(payload)); - - // A freshly-created service principal may not yet be visible to the - // oauth2PermissionGrants replica (Directory_ObjectNotFound). Retry with - // exponential back-off so the command is self-healing without user intervention. - const int maxRetries = 8; - const int baseDelaySeconds = 5; - for (int attempt = 0; attempt < maxRetries; attempt++) - { - var grantResponse = await GraphPostWithResponseAsync(tenantId, "/v1.0/oauth2PermissionGrants", payload, ct, permissionGrantScopes); - // Dispose the error JSON immediately — only IsSuccess and Body are needed below. - grantResponse.Json?.Dispose(); - - if (grantResponse.IsSuccess) - return true; - - if (!grantResponse.Body.Contains("Directory_ObjectNotFound", StringComparison.OrdinalIgnoreCase)) - return false; // non-transient error, do not retry - - if (attempt < maxRetries - 1) - { - var delaySecs = (int)Math.Min(baseDelaySeconds * Math.Pow(2, attempt), 60); - _logger.LogWarning( - "Service principal not yet replicated to grants endpoint — retrying in {Delay}s (attempt {Attempt}/{Max})...", - delaySecs, attempt + 1, maxRetries - 1); - await Task.Delay(TimeSpan.FromSeconds(delaySecs), ct); - } - } - - _logger.LogWarning( - "OAuth2 permission grant failed after {MaxRetries} retries — service principal may still be propagating. " + - "Re-run 'a365 setup admin' to retry.", - maxRetries); - return false; - } - - // Merge scopes if needed - var currentSet = new HashSet(existingScopes.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase); - var desiredSet = new HashSet(desiredScopeString.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase); - - if (desiredSet.IsSubsetOf(currentSet)) return true; // already satisfied - - currentSet.UnionWith(desiredSet); - var merged = string.Join(' ', currentSet); - - return await GraphPatchAsync(tenantId, $"/v1.0/oauth2PermissionGrants/{existingId}", new { scope = merged }, ct, permissionGrantScopes); + permissionGrantScopes); } /// @@ -629,80 +560,125 @@ public async Task CreatePrincipalOauth2PermissionGrantAsync( IEnumerable scopes, CancellationToken ct = default, IEnumerable? permissionGrantScopes = null) + { + return await CreateOrUpdateOauth2PermissionGrantCoreAsync( + tenantId, + clientSpObjectId, + resourceSpObjectId, + principalId, + consentType: "Principal", + scopes, + ct, + permissionGrantScopes); + } + + /// + /// Shared implementation for creating or updating an oauth2PermissionGrant. + /// Both AllPrincipals (tenant-wide) and Principal (scoped) consent types use the same + /// query → create-or-merge flow. The only differences are the OData filter, the payload + /// shape (Principal includes principalId), and the in-code matching for Principal grants. + /// + private async Task CreateOrUpdateOauth2PermissionGrantCoreAsync( + string tenantId, + string clientSpObjectId, + string resourceSpObjectId, + string? principalId, + string consentType, + IEnumerable scopes, + CancellationToken ct, + IEnumerable? permissionGrantScopes) { var desiredScopeString = string.Join(' ', scopes); + var isPrincipal = string.Equals(consentType, "Principal", StringComparison.OrdinalIgnoreCase); - // Read existing principal-scoped grant for this client+resource+principal combination - // Note: The Graph API oauth2PermissionGrants endpoint only supports filtering by clientId. - // We filter the results in code for resourceId, consentType, and principalId. + // Read existing — extract string values immediately so JsonDocument can be disposed. + // AllPrincipals grants can filter by clientId+resourceId server-side. + // Principal grants must filter by clientId only, then match resourceId/consentType/principalId in code + // because the Graph API oauth2PermissionGrants endpoint has limited $filter support. string? existingId = null; string existingScopes = ""; + var filter = isPrincipal + ? $"clientId eq '{clientSpObjectId}'" + : $"clientId eq '{clientSpObjectId}' and resourceId eq '{resourceSpObjectId}'"; + using (var listDoc = await GraphGetAsync( tenantId, - $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{clientSpObjectId}'", + $"/v1.0/oauth2PermissionGrants?$filter={filter}", ct, permissionGrantScopes)) { if (listDoc?.RootElement.TryGetProperty("value", out var arr) == true) { - foreach (var grant in arr.EnumerateArray()) + if (isPrincipal) { - var grantResourceId = grant.TryGetProperty("resourceId", out var rid) ? rid.GetString() : null; - var grantConsentType = grant.TryGetProperty("consentType", out var ctp) ? ctp.GetString() : null; - var grantPrincipalId = grant.TryGetProperty("principalId", out var pid) ? pid.GetString() : null; - - if (string.Equals(grantResourceId, resourceSpObjectId, StringComparison.OrdinalIgnoreCase) && - string.Equals(grantConsentType, "Principal", StringComparison.OrdinalIgnoreCase) && - string.Equals(grantPrincipalId, principalId, StringComparison.OrdinalIgnoreCase)) + // Principal grants: match resourceId, consentType, and principalId in code. + foreach (var grant in arr.EnumerateArray()) { - existingId = grant.TryGetProperty("id", out var idProp) ? idProp.GetString() : null; - existingScopes = grant.TryGetProperty("scope", out var scopeProp) ? scopeProp.GetString() ?? "" : ""; - break; + var grantResourceId = grant.TryGetProperty("resourceId", out var rid) ? rid.GetString() : null; + var grantConsentType = grant.TryGetProperty("consentType", out var ctp) ? ctp.GetString() : null; + var grantPrincipalId = grant.TryGetProperty("principalId", out var pid) ? pid.GetString() : null; + + if (string.Equals(grantResourceId, resourceSpObjectId, StringComparison.OrdinalIgnoreCase) && + string.Equals(grantConsentType, "Principal", StringComparison.OrdinalIgnoreCase) && + string.Equals(grantPrincipalId, principalId, StringComparison.OrdinalIgnoreCase)) + { + existingId = grant.TryGetProperty("id", out var idProp) ? idProp.GetString() : null; + existingScopes = grant.TryGetProperty("scope", out var scopeProp) ? scopeProp.GetString() ?? "" : ""; + break; + } } } + else if (arr.GetArrayLength() > 0) + { + // AllPrincipals grants: the server-side filter is precise enough. + var grant = arr[0]; + existingId = grant.TryGetProperty("id", out var idProp) ? idProp.GetString() : null; + existingScopes = grant.TryGetProperty("scope", out var scopeProp) ? scopeProp.GetString() ?? "" : ""; + } } } if (string.IsNullOrWhiteSpace(existingId)) { - var payload = new - { - clientId = clientSpObjectId, - consentType = "Principal", - principalId, - resourceId = resourceSpObjectId, - scope = desiredScopeString - }; + // Build payload — Principal grants include principalId. + object payload = isPrincipal + ? new { clientId = clientSpObjectId, consentType, principalId, resourceId = resourceSpObjectId, scope = desiredScopeString } + : new { clientId = clientSpObjectId, consentType, resourceId = resourceSpObjectId, scope = desiredScopeString }; - _logger.LogDebug("Graph POST /v1.0/oauth2PermissionGrants (Principal) body: {Body}", JsonSerializer.Serialize(payload)); + _logger.LogDebug("Graph POST /v1.0/oauth2PermissionGrants ({ConsentType}) body: {Body}", consentType, JsonSerializer.Serialize(payload)); + // A freshly-created service principal may not yet be visible to the + // oauth2PermissionGrants replica (Directory_ObjectNotFound). Retry with + // exponential back-off so the command is self-healing without user intervention. const int maxRetries = 8; const int baseDelaySeconds = 5; for (int attempt = 0; attempt < maxRetries; attempt++) { var grantResponse = await GraphPostWithResponseAsync(tenantId, "/v1.0/oauth2PermissionGrants", payload, ct, permissionGrantScopes); + // Dispose the error JSON immediately — only IsSuccess and Body are needed below. grantResponse.Json?.Dispose(); if (grantResponse.IsSuccess) return true; if (!grantResponse.Body.Contains("Directory_ObjectNotFound", StringComparison.OrdinalIgnoreCase)) - return false; + return false; // non-transient error, do not retry if (attempt < maxRetries - 1) { var delaySecs = (int)Math.Min(baseDelaySeconds * Math.Pow(2, attempt), 60); _logger.LogWarning( - "Service principal not yet replicated to grants endpoint - retrying in {Delay}s (attempt {Attempt}/{Max})...", + "Service principal not yet replicated to grants endpoint — retrying in {Delay}s (attempt {Attempt}/{Max})...", delaySecs, attempt + 1, maxRetries - 1); await Task.Delay(TimeSpan.FromSeconds(delaySecs), ct); } } _logger.LogWarning( - "OAuth2 permission grant (Principal) failed after {MaxRetries} retries - service principal may still be propagating.", - maxRetries); + "OAuth2 permission grant ({ConsentType}) failed after {MaxRetries} retries — service principal may still be propagating. " + + "Re-run the command to retry.", + consentType, maxRetries); return false; }