From c0d736cf6f67f581b7d0a10854f29ac40454f0e2 Mon Sep 17 00:00:00 2001 From: Rick Brighenti <202984599+rbrighenti@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:37:46 +0000 Subject: [PATCH 01/12] Add cloud-aware authority/graph endpoint resolution Normalize cloud keys for environment overrides and route consent/token/Graph URL generation through cloud-aware helpers so arbitrary cloud names can be configured without code changes Make client-credential token authority cloud-aware --- CHANGELOG.md | 1 + .../Commands/DevelopCommand.cs | 5 +- .../AddPermissionsSubcommand.cs | 5 + .../DevelopSubcommands/GetTokenSubcommand.cs | 14 ++- .../Commands/QueryEntraCommand.cs | 10 +- .../SetupSubcommands/AllSubcommand.cs | 18 ++-- .../SetupSubcommands/AzRestConsentRunner.cs | 19 ++-- .../SetupSubcommands/AzRestS2SRunner.cs | 20 ++-- .../BatchPermissionsOrchestrator.cs | 46 +++++---- .../SetupSubcommands/BlueprintSubcommand.cs | 87 +++++++++-------- .../CopilotStudioSubcommand.cs | 5 +- .../NonDwBlueprintSetupOrchestrator.cs | 6 +- .../SetupSubcommands/PermissionsSubcommand.cs | 25 ++--- .../RequirementsSubcommand.cs | 1 + .../Commands/SetupSubcommands/SetupHelpers.cs | 95 ++++++++++++------- .../Constants/ConfigConstants.cs | 92 ++++++++++++++++-- .../ClientAppValidationException.cs | 15 +-- .../Helpers/ProjectSettingsSyncHelper.cs | 17 ++-- .../Models/Agent365Config.cs | 13 ++- .../Program.cs | 6 +- .../Services/A365CreateInstanceRunner.cs | 19 +++- .../Services/Agent365ToolingService.cs | 33 ++++--- .../Services/AuthenticationService.cs | 70 +++++++++++--- .../Services/BootstrapConfigResolver.cs | 36 +++++++ .../Services/ClientAppValidator.cs | 6 +- .../Services/DelegatedConsentService.cs | 16 ++-- .../Services/GraphApiService.cs | 70 ++++++++++---- .../Services/Helpers/AdminConsentHelper.cs | 22 +++-- .../Services/Helpers/EndpointHelper.cs | 12 ++- .../Services/InteractiveGraphAuthService.cs | 28 +++++- .../Internal/IMicrosoftGraphTokenProvider.cs | 4 +- .../Internal/MicrosoftGraphTokenProvider.cs | 56 +++++++---- .../Services/MsalBrowserCredential.cs | 13 ++- .../WidsOptionalClaimRequirementCheck.cs | 8 +- .../Services/TeamsGraphBackendConfigurator.cs | 18 +++- .../Commands/AzRestConsentRunnerTests.cs | 6 +- .../Commands/AzRestS2SRunnerTests.cs | 6 +- ...chPermissionsOrchestratorMissingSpTests.cs | 5 +- .../Constants/ConfigConstantsTests.cs | 93 ++++++++++++++++++ .../ClientAppValidationExceptionTests.cs | 8 +- .../Helpers/SetupHelpersConsentUrlTests.cs | 7 +- .../Services/AdminConsentHelperTests.cs | 9 +- .../MicrosoftGraphTokenProviderTests.cs | 12 ++- 43 files changed, 772 insertions(+), 285 deletions(-) create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/ConfigConstantsTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f2b2eb14..3a5d56db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g - `a365 develop get-token --device-code` — forces device code auth for Microsoft Graph scopes the Windows WAM broker rejects (e.g. Exchange `MailboxSettings.ReadWrite`, `ExchangeMessageTrace.Read.All`). ### Fixed +- Cloud-specific Graph, authority, and Agent 365 Tools overrides now apply across setup, consent, authentication, query, and create-instance flows. Arbitrary cloud names use normalized environment-scoped variables such as `A365_GRAPH_BASE_URL_GCC_HIGH`, and configured endpoints are normalized and validated - `setup all --authmode s2s` no longer prints spurious "Action Required" PowerShell steps when the agent identity already inherits its app roles from the blueprint, and now retries the grant automatically before falling back to manual steps (#460). - `a365 develop get-token` now falls back to device code when the Windows WAM broker rejects Exchange Graph scopes with `ApiContractViolation`, instead of failing with an opaque MSAL error. - `setup blueprint` now configures the blueprint's inheritable Microsoft Graph permissions even when the signed-in user is not a Global Administrator, no longer aborts with a misleading "Failed to configure inheritable permissions" error when the tenant-wide consent grant cannot be made programmatically, and ends with a setup summary whose Action Required block surfaces the admin-consent URL for non-admins to hand off (#452). diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopCommand.cs index ea02c7dc..ec07e440 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopCommand.cs @@ -146,7 +146,10 @@ private static async Task CallDiscoverToolServersAsync(bool skipAuth, ILog // Resolve az CLI login hint so WAM targets the correct account instead of // defaulting to the first cached MSAL account (which may be stale). var loginHint = await Services.Helpers.AzCliHelper.ResolveLoginHintAsync(); - authToken = await authService.GetAccessTokenAsync(audience, userId: loginHint); + authToken = await authService.GetAccessTokenAsync( + audience, + userId: loginHint, + authorityHost: ConfigConstants.GetAuthorityHost(environment)); if (string.IsNullOrWhiteSpace(authToken)) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs index 39dd414b..187fe254 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/AddPermissionsSubcommand.cs @@ -3,6 +3,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging; using System.CommandLine; @@ -74,6 +75,10 @@ public static Command CreateCommand( var setupConfig = File.Exists(configFile.FullName) ? await configService.LoadAsync(configFile.FullName) : null; + graphApiService.ConfigureCloudEndpoints(setupConfig ?? new Agent365Config + { + Environment = Environment.GetEnvironmentVariable("A365_ENVIRONMENT") ?? "prod" + }); if (setupConfig == null && string.IsNullOrWhiteSpace(appId)) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs index 8b9d21e3..fe6ad674 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs @@ -139,7 +139,7 @@ public static Command CreateCommand( } // Determine environment - var environment = setupConfig?.Environment ?? "prod"; + var environment = ResolveEnvironment(setupConfig); // Resolve resource app ID string resourceAppId; @@ -283,7 +283,10 @@ private static async Task AcquireTokenAsync( forceRefresh, clientAppId, useInteractiveBrowser: !useDeviceCode, - userId: loginHint); + userId: loginHint, + authorityHost: ConfigConstants.GetAuthorityHost( + ResolveEnvironment(setupConfig), + setupConfig?.AuthorityHost)); if (string.IsNullOrWhiteSpace(token)) { @@ -394,7 +397,7 @@ private static async Task AcquireAndDisplayManifestTokensAsync( logger.LogInformation(""); - var tokenAtgAppId = ConfigConstants.GetAgent365ToolsResourceAppId(setupConfig?.Environment ?? "prod"); + var tokenAtgAppId = ConfigConstants.GetAgent365ToolsResourceAppId(ResolveEnvironment(setupConfig)); var scopesByAudience = await ManifestHelper.GetScopesByAudienceAsync(manifestPath, resolvedAtgAppId: tokenAtgAppId); var serverNamesByAudience = await ManifestHelper.GetServerNamesByAudienceAsync(manifestPath, resolvedAtgAppId: tokenAtgAppId); @@ -455,6 +458,11 @@ private static string ResolveClientAppId(string? appId, Agent365Config? setupCon throw new InvalidOperationException("No client application ID specified. Use --app-id or ensure ClientAppId is set in config."); } + private static string ResolveEnvironment(Agent365Config? setupConfig) => + setupConfig?.Environment + ?? Environment.GetEnvironmentVariable("A365_ENVIRONMENT") + ?? "prod"; + private static async Task SaveAndReportTokenAsync( string token, Agent365Config? setupConfig, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs index 46ad66c9..282d9971 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs @@ -27,7 +27,7 @@ public static Command CreateCommand( // Add subcommands for different query types command.AddCommand(CreateBlueprintScopesSubcommand(logger, configService, executor, graphApiService, blueprintService, resolver)); - command.AddCommand(CreateInstanceScopesSubcommand(logger, configService, executor, resolver)); + command.AddCommand(CreateInstanceScopesSubcommand(logger, configService, executor, graphApiService, resolver)); command.AddCommand(CreateInheritanceSubcommand(logger, configService, graphApiService, blueprintService, resolver)); return command; @@ -82,6 +82,7 @@ private static Command CreateInheritanceSubcommand( context.ExitCode = 1; return; } + graphApiService.ConfigureCloudEndpoints(setupConfig); if (string.IsNullOrEmpty(setupConfig.AgentBlueprintId)) { @@ -258,6 +259,7 @@ private static Command CreateBlueprintScopesSubcommand( context.ExitCode = 1; return; } + graphApiService.ConfigureCloudEndpoints(setupConfig); if (string.IsNullOrEmpty(setupConfig.AgentBlueprintId)) { @@ -365,6 +367,7 @@ private static Command CreateInstanceScopesSubcommand( ILogger logger, IConfigService configService, CommandExecutor executor, + GraphApiService graphApiService, IBootstrapConfigResolver? resolver = null) { var command = new Command("instance-scopes", "List configured scopes and consent status for the agent instance"); @@ -407,6 +410,7 @@ private static Command CreateInstanceScopesSubcommand( context.ExitCode = 1; return; } + graphApiService.ConfigureCloudEndpoints(instanceConfig); // Check for agent identity (could be AgentBlueprintId or specific instance identity) string? agenticAppId = null; @@ -476,7 +480,7 @@ private static Command CreateInstanceScopesSubcommand( // Use Microsoft Graph API through Azure CLI to get OAuth2 permission grants var grantsResult = await executor.ExecuteAsync("az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId eq '{agenticAppId}'\" --output json"); + $"rest --method GET --url \"{graphApiService.GraphBaseUrl}/v1.0/oauth2PermissionGrants?$filter=clientId eq '{agenticAppId}'\" --output json"); // Distinguish "API call failed" (can't read) from "API succeeded but returned no grants". // Non-admin developers lack DelegatedPermissionGrant.Read.All and always get a failure here — @@ -501,7 +505,7 @@ private static Command CreateInstanceScopesSubcommand( // Get the resource display name using Graph API var resourceResult = await executor.ExecuteAsync("az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/servicePrincipals/{resourceId}?$select=displayName,appId\" --output json"); + $"rest --method GET --url \"{graphApiService.GraphBaseUrl}/v1.0/servicePrincipals/{resourceId}?$select=displayName,appId\" --output json"); string resourceName = "Unknown Resource"; string resourceAppId = "Unknown"; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs index 0e94cc98..551b15db 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs @@ -407,8 +407,7 @@ effectiveAuthModeForValidation is not ("obo" or "s2s" or "both")) } // Build SetupContext for non-DW blueprint and delegate to orchestrator. - if (!string.IsNullOrWhiteSpace(nonDwConfig.ClientAppId)) - graphApiService.CustomClientAppId = nonDwConfig.ClientAppId; + graphApiService.ConfigureCloudEndpoints(nonDwConfig); var nonDwGeneratedConfigPath = Path.Combine( config.DirectoryName ?? Environment.CurrentDirectory, @@ -526,12 +525,7 @@ effectiveAuthModeForValidation is not ("obo" or "s2s" or "both")) } } - // Configure GraphApiService with custom client app ID if available - // This ensures inheritable permissions operations use the validated custom app - if (!string.IsNullOrWhiteSpace(setupConfig.ClientAppId)) - { - graphApiService.CustomClientAppId = setupConfig.ClientAppId; - } + graphApiService.ConfigureCloudEndpoints(setupConfig); setupResults.PrerequisitesSkipped = skipRequirements; setupResults.InfrastructureSkipped = true; @@ -627,7 +621,7 @@ await ExecuteBatchPermissionsStepAsync( // Display verification URLs and setup summary await SetupHelpers.DisplayVerificationInfoAsync(config, logger); logger.LogInformation(""); - SetupHelpers.DisplaySetupSummary(setupResults, logger); + SetupHelpers.DisplaySetupSummary(setupResults, logger, graphApiService.GraphBaseUrl); } catch (Agent365Exception ex) { @@ -635,7 +629,7 @@ await ExecuteBatchPermissionsStepAsync( ExceptionHandler.HandleAgent365Exception(ex, logFilePath: logFilePath); setupResults.Errors.Add(ex.Message); logger.LogInformation(""); - SetupHelpers.DisplaySetupSummary(setupResults, logger); + SetupHelpers.DisplaySetupSummary(setupResults, logger, graphApiService.GraphBaseUrl); ExceptionHandler.ExitWithCleanup(1); } catch (FileNotFoundException fnfEx) @@ -643,7 +637,7 @@ await ExecuteBatchPermissionsStepAsync( logger.LogError("Setup failed: {Message}", fnfEx.Message); setupResults.Errors.Add(fnfEx.Message); logger.LogInformation(""); - SetupHelpers.DisplaySetupSummary(setupResults, logger); + SetupHelpers.DisplaySetupSummary(setupResults, logger, graphApiService.GraphBaseUrl); ExceptionHandler.ExitWithCleanup(1); } catch (OperationCanceledException) @@ -657,7 +651,7 @@ await ExecuteBatchPermissionsStepAsync( logger.LogError(ex, "Setup failed: {Message}", ex.Message); setupResults.Errors.Add(ex.Message); logger.LogInformation(""); - SetupHelpers.DisplaySetupSummary(setupResults, logger); + SetupHelpers.DisplaySetupSummary(setupResults, logger, graphApiService.GraphBaseUrl); throw; } }); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AzRestConsentRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AzRestConsentRunner.cs index 5baa5e09..88c1d66b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AzRestConsentRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AzRestConsentRunner.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging; using System.Text.Json; @@ -65,7 +66,8 @@ internal static partial class AzRestConsentRunner string blueprintSpObjectId, IReadOnlyList specs, ILogger logger, - CancellationToken ct) + CancellationToken ct, + string graphBaseUrl = GraphApiConstants.BaseUrl) { if (!GuidPattern().IsMatch(blueprintSpObjectId)) { @@ -99,6 +101,10 @@ internal static partial class AzRestConsentRunner } } + // Resolve the Graph base URL once so every az rest call targets the configured + // (sovereign / commercial) cloud endpoint rather than a hardcoded commercial host. + var baseUrl = ConfigConstants.NormalizeGraphBaseUrl(graphBaseUrl); + logger.LogInformation("Granting delegated admin consent..."); var allOk = true; @@ -107,7 +113,7 @@ internal static partial class AzRestConsentRunner ct.ThrowIfCancellationRequested(); try { - var ok = await GrantOneAsync(executor, blueprintSpObjectId, spec, logger, ct); + var ok = await GrantOneAsync(executor, blueprintSpObjectId, spec, baseUrl, logger, ct); if (!ok) allOk = false; } catch (OperationCanceledException) @@ -132,13 +138,14 @@ private static async Task GrantOneAsync( CommandExecutor executor, string blueprintSpObjectId, ResourcePermissionSpec spec, + string graphBaseUrl, ILogger logger, CancellationToken ct) { // 1. Resolve the resource SP object id. var resourceSpResult = await executor.ExecuteAsync( "az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '{spec.ResourceAppId}'&$select=id\"", + $"rest --method GET --url \"{graphBaseUrl}/v1.0/servicePrincipals?$filter=appId eq '{spec.ResourceAppId}'&$select=id\"", captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); @@ -166,7 +173,7 @@ private static async Task GrantOneAsync( // un-created. Filter on consentType to be precise. var grantQueryResult = await executor.ExecuteAsync( "az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId eq '{blueprintSpObjectId}' and resourceId eq '{resourceSpId}' and consentType eq 'AllPrincipals'\"", + $"rest --method GET --url \"{graphBaseUrl}/v1.0/oauth2PermissionGrants?$filter=clientId eq '{blueprintSpObjectId}' and resourceId eq '{resourceSpId}' and consentType eq 'AllPrincipals'\"", captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); @@ -200,7 +207,7 @@ private static async Task GrantOneAsync( var patched = await ExecuteAzRestWithBodyAsync( executor, method: "PATCH", - url: $"https://graph.microsoft.com/v1.0/oauth2PermissionGrants/{existingGrantId}", + url: $"{graphBaseUrl}/v1.0/oauth2PermissionGrants/{existingGrantId}", bodyJson: patchBody, logger: logger, ct: ct); @@ -224,7 +231,7 @@ private static async Task GrantOneAsync( var created = await ExecuteAzRestWithBodyAsync( executor, method: "POST", - url: "https://graph.microsoft.com/v1.0/oauth2PermissionGrants", + url: $"{graphBaseUrl}/v1.0/oauth2PermissionGrants", bodyJson: createBody, logger: logger, ct: ct); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AzRestS2SRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AzRestS2SRunner.cs index fe5134e9..2bc5be21 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AzRestS2SRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AzRestS2SRunner.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging; using System.Text.Json; @@ -52,7 +53,8 @@ internal static partial class AzRestS2SRunner string blueprintSpObjectId, IReadOnlyList specs, ILogger logger, - CancellationToken ct) + CancellationToken ct, + string graphBaseUrl = GraphApiConstants.BaseUrl) { if (!GuidPattern().IsMatch(blueprintSpObjectId)) { @@ -85,13 +87,17 @@ internal static partial class AzRestS2SRunner } } + // Resolve the Graph base URL once so every az rest call targets the configured + // (sovereign / commercial) cloud endpoint rather than a hardcoded commercial host. + var baseUrl = ConfigConstants.NormalizeGraphBaseUrl(graphBaseUrl); + logger.LogInformation("Assigning S2S app roles..."); var allOk = true; // Fetch the existing assignment list once at the top — every per-role idempotency // check then compares against this in-memory set, avoiding N+1 Graph round-trips. - var existingAssignments = await GetExistingAssignmentsAsync(executor, blueprintSpObjectId, logger, ct); + var existingAssignments = await GetExistingAssignmentsAsync(executor, blueprintSpObjectId, baseUrl, logger, ct); if (existingAssignments is null) { // The GET itself failed; that's a hard stop because we can't reason about @@ -104,7 +110,7 @@ internal static partial class AzRestS2SRunner ct.ThrowIfCancellationRequested(); try { - var ok = await AssignOneAsync(executor, blueprintSpObjectId, spec, existingAssignments, logger, ct); + var ok = await AssignOneAsync(executor, blueprintSpObjectId, spec, existingAssignments, baseUrl, logger, ct); if (!ok) allOk = false; } catch (OperationCanceledException) @@ -132,12 +138,13 @@ private static async Task AssignOneAsync( string blueprintSpObjectId, ResourcePermissionSpec spec, HashSet<(string ResourceId, string AppRoleId)> existingAssignments, + string graphBaseUrl, ILogger logger, CancellationToken ct) { var spResult = await executor.ExecuteAsync( "az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '{spec.ResourceAppId}'&$select=id,appRoles\"", + $"rest --method GET --url \"{graphBaseUrl}/v1.0/servicePrincipals?$filter=appId eq '{spec.ResourceAppId}'&$select=id,appRoles\"", captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); @@ -182,7 +189,7 @@ private static async Task AssignOneAsync( var created = await ExecuteAzRestWithBodyAsync( executor, method: "POST", - url: $"https://graph.microsoft.com/v1.0/servicePrincipals/{blueprintSpObjectId}/appRoleAssignments", + url: $"{graphBaseUrl}/v1.0/servicePrincipals/{blueprintSpObjectId}/appRoleAssignments", bodyJson: createBody, logger: logger, ct: ct); @@ -212,12 +219,13 @@ private static async Task AssignOneAsync( private static async Task?> GetExistingAssignmentsAsync( CommandExecutor executor, string blueprintSpObjectId, + string graphBaseUrl, ILogger logger, CancellationToken ct) { var result = await executor.ExecuteAsync( "az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/servicePrincipals/{blueprintSpObjectId}/appRoleAssignments\"", + $"rest --method GET --url \"{graphBaseUrl}/v1.0/servicePrincipals/{blueprintSpObjectId}/appRoleAssignments\"", captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs index 1d6124fb..04a1ce08 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs @@ -238,7 +238,7 @@ internal static class BatchPermissionsOrchestrator { logger.LogDebug("S2S app role assignments could not be completed via the Graph API; falling back to az rest."); var (attempted, succeeded) = await AzRestS2SRunner.TryRunAsync( - commandExecutor, phase1Result.BlueprintSpObjectId, specs, logger, ct); + commandExecutor, phase1Result.BlueprintSpObjectId, specs, logger, ct, graph.GraphBaseUrl); if (attempted && succeeded) { logger.LogInformation("Application permissions granted."); @@ -472,8 +472,12 @@ private static async Task UpdateBlueprintPermissions /// emit identical scope identifiers (e.g. https://agent365.svc.cloud.microsoft/Tools.Execute, /// not api://{appId}/Tools.Execute). /// - private static string BuildFullyQualifiedScope(string resourceAppId, string scope, bool isMcpAudience = false) - => SetupHelpers.BuildFullyQualifiedScope(resourceAppId, scope, isMcpAudience); + private static string BuildFullyQualifiedScope( + string resourceAppId, string scope, bool isMcpAudience = false, + string graphResourceUri = AuthenticationConstants.MicrosoftGraphResourceUri, + string? sharedMcpResourceAppId = null) + => SetupHelpers.BuildFullyQualifiedScope( + resourceAppId, scope, isMcpAudience, graphResourceUri, sharedMcpResourceAppId); /// /// Grants S2S app role assignments for all specs that carry . @@ -656,16 +660,19 @@ await EnsureMissingResourceSpsAsync( ? specs.Where(s => resolvedSpAppIds.Contains(s.ResourceAppId)).ToList() : specs.ToList(); + var sharedMcpResourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); var allScopes = specsForUrl .Where(s => s.Scopes is { Length: > 0 }) .SelectMany(s => s.Scopes.Select(scope => BuildFullyQualifiedScope( s.ResourceAppId, scope, - isMcpAudience: knownMcpAudienceAppIds?.Contains(s.ResourceAppId) ?? false))) + isMcpAudience: knownMcpAudienceAppIds?.Contains(s.ResourceAppId) ?? false, + graphResourceUri: graph.GraphBaseUrl, + sharedMcpResourceAppId: sharedMcpResourceAppId))) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); string? consentUrl = allScopes.Count > 0 - ? SetupHelpers.BuildAdminConsentUrl(tenantId, blueprintAppId, allScopes) + ? SetupHelpers.BuildAdminConsentUrl(tenantId, blueprintAppId, allScopes, graph.AuthorityHost) : null; // No delegated scopes to consent at all — nothing to do. The caller still surfaces @@ -728,7 +735,8 @@ await EnsureMissingResourceSpsAsync( ct, consentType: "AllPrincipals", blueprintSpObjectId: phase1Result.BlueprintSpObjectId, - resourceSpObjectId: resourceSpId); + resourceSpObjectId: resourceSpId, + graphBaseUrl: graph.GraphBaseUrl); } else { @@ -802,7 +810,8 @@ await EnsureMissingResourceSpsAsync( // longer holds since PR #409 removed that scope from the CLI client app registration. var found = await AdminConsentHelper.PollAdminConsentAsync( commandExecutor, logger, blueprintAppId, - "All permissions", timeoutSeconds: 180, intervalSeconds: 5, ct); + "All permissions", timeoutSeconds: 180, intervalSeconds: 5, ct, + graphBaseUrl: graph.GraphBaseUrl); consentVerified = found; // Browser was opened regardless — either the grant was directly observed (Verified) // or the timeout elapsed without observing it (AssumedComplete). Either way, setup @@ -877,7 +886,7 @@ await EnsureMissingResourceSpsAsync( else { var (attempted, succeeded) = await AzRestConsentRunner.TryRunAsync( - commandExecutor, p.BlueprintSpObjectId, originalSpecs, logger, ct); + commandExecutor, p.BlueprintSpObjectId, originalSpecs, logger, ct, graph.GraphBaseUrl); if (attempted && succeeded) { logger.LogInformation("Delegated admin consent granted."); @@ -1054,7 +1063,7 @@ internal static async Task EnsureMissingResourceSpsAsync( "{Count} resource(s) require service principal provisioning. Auto-provisioning is disabled; steps will be listed in the setup summary.", stillMissing.Count); foreach (var spec in stillMissing) - RecordMissingSpAction(spec, tenantId, blueprintAppId, logger, setupResults, knownMcpAudienceAppIds); + RecordMissingSpAction(spec, tenantId, blueprintAppId, logger, setupResults, knownMcpAudienceAppIds, graph.AuthorityHost); return; } @@ -1102,7 +1111,7 @@ internal static async Task EnsureMissingResourceSpsAsync( logger.LogWarning( "{Idx}. {Name} ({AppId}): skipping — resource app id is not a valid GUID.", i + 1, spec.ResourceName, spec.ResourceAppId); - RecordMissingSpAction(spec, tenantId, blueprintAppId, logger, setupResults, knownMcpAudienceAppIds); + RecordMissingSpAction(spec, tenantId, blueprintAppId, logger, setupResults, knownMcpAudienceAppIds, graph.AuthorityHost); continue; } @@ -1120,7 +1129,7 @@ internal static async Task EnsureMissingResourceSpsAsync( if (!shouldProvision) { logger.LogInformation("Skipped."); - RecordMissingSpAction(spec, tenantId, blueprintAppId, logger, setupResults, knownMcpAudienceAppIds); + RecordMissingSpAction(spec, tenantId, blueprintAppId, logger, setupResults, knownMcpAudienceAppIds, graph.AuthorityHost); continue; } @@ -1136,7 +1145,7 @@ internal static async Task EnsureMissingResourceSpsAsync( { var stderr = string.IsNullOrWhiteSpace(azResult.StandardError) ? azResult.StandardOutput : azResult.StandardError; logger.LogWarning("Failed: {Error}", (stderr ?? string.Empty).Trim()); - RecordMissingSpAction(spec, tenantId, blueprintAppId, logger, setupResults, knownMcpAudienceAppIds); + RecordMissingSpAction(spec, tenantId, blueprintAppId, logger, setupResults, knownMcpAudienceAppIds, graph.AuthorityHost); continue; } @@ -1159,7 +1168,7 @@ internal static async Task EnsureMissingResourceSpsAsync( logger.LogWarning( "az exited 0 but the output did not contain a service principal id. Output: {Output}", (azResult.StandardOutput ?? string.Empty).Trim()); - RecordMissingSpAction(spec, tenantId, blueprintAppId, logger, setupResults, knownMcpAudienceAppIds); + RecordMissingSpAction(spec, tenantId, blueprintAppId, logger, setupResults, knownMcpAudienceAppIds, graph.AuthorityHost); } } } @@ -1238,7 +1247,8 @@ private static void RecordMissingSpAction( string blueprintAppId, ILogger logger, SetupResults? setupResults, - IReadOnlyCollection? knownMcpAudienceAppIds = null) + IReadOnlyCollection? knownMcpAudienceAppIds = null, + string? authorityHost = null) { _ = logger; // intentionally unused — caller already emits a one-line inline marker // ("Skipped." / "Failed: " / "...invalid GUID...") immediately @@ -1248,7 +1258,7 @@ private static void RecordMissingSpAction( var azCommand = BuildAzAdSpCreateCommand(spec.ResourceAppId); var isMcpAudience = knownMcpAudienceAppIds?.Contains(spec.ResourceAppId) ?? false; - var perSpConsentUrl = BuildPerSpBlueprintConsentUrl(tenantId, blueprintAppId, spec, isMcpAudience); + var perSpConsentUrl = BuildPerSpBlueprintConsentUrl(tenantId, blueprintAppId, spec, isMcpAudience, authorityHost); setupResults?.MissingSpActions.Add(new MissingSpAction( ResourceName: spec.ResourceName, @@ -1270,14 +1280,16 @@ internal static string BuildPerSpBlueprintConsentUrl( string tenantId, string blueprintAppId, ResourcePermissionSpec spec, - bool isMcpAudience = false) + bool isMcpAudience = false, + string? authorityHost = null) { var scopes = spec.Scopes ?? Array.Empty(); var fullyQualified = scopes .Select(s => $"{GetResourceUriForBlueprintConsent(spec.ResourceAppId, isMcpAudience)}/{s}"); var scopeParam = string.Join("%20", fullyQualified.Select(Uri.EscapeDataString)); var redirectEncoded = Uri.EscapeDataString(AuthenticationConstants.BlueprintConsentRedirectUri); - return $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent" + + var consentBaseUrl = ConfigConstants.BuildAdminConsentEndpointUrl(authorityHost, tenantId); + return consentBaseUrl + $"?client_id={blueprintAppId}" + $"&scope={scopeParam}" + $"&redirect_uri={redirectEncoded}" + diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs index 0a9c6a2f..e84dcadf 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs @@ -111,7 +111,6 @@ internal static class BlueprintSubcommand } private const int ClientSecretValidationRetryDelayMs = 1000; private const int ClientSecretValidationTimeoutSeconds = 10; - private const string MicrosoftLoginOAuthTokenEndpoint = "https://login.microsoftonline.com/{0}/oauth2/v2.0/token"; public static Command CreateCommand( ILogger logger, @@ -354,14 +353,7 @@ public static Command CreateCommand( // Configure GraphApiService with custom client app ID if available // This ensures inheritable permissions operations use the validated custom app - if (!string.IsNullOrWhiteSpace(setupConfig.ClientAppId)) - { - graphApiService.CustomClientAppId = setupConfig.ClientAppId; - } - - // Wire the sovereign/government cloud base URL from config so all Graph calls - // target the correct national cloud endpoint (commercial by default). - graphApiService.GraphBaseUrl = setupConfig.GraphBaseUrl; + graphApiService.ConfigureCloudEndpoints(setupConfig); // Handle --update-endpoint flag (--m365 is inferred for endpoint operations). if (!string.IsNullOrWhiteSpace(updateEndpoint)) @@ -560,13 +552,12 @@ public static async Task CreateBlueprintImplementationA // Create required services. // Pass the caller's logger so consent messages appear in the correct indent scope. var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); + var delegatedGraphService = new GraphApiService( + cleanLoggerFactory.CreateLogger(), executor, + new AuthenticationService(cleanLoggerFactory.CreateLogger())); + delegatedGraphService.ConfigureCloudEndpoints(setupConfig); var delegatedConsentService = new DelegatedConsentService( - logger, - new GraphApiService( - cleanLoggerFactory.CreateLogger(), - executor, - new AuthenticationService(cleanLoggerFactory.CreateLogger()), - graphBaseUrl: setupConfig.GraphBaseUrl)); + logger, delegatedGraphService); // Use DI-provided GraphApiService which already has MicrosoftGraphTokenProvider configured var graphService = graphApiService; @@ -681,6 +672,7 @@ public static async Task CreateBlueprintImplementationA setupConfig.AgentBlueprintClientSecret, setupConfig.AgentBlueprintClientSecretProtected, setupConfig.TenantId!, + graphService, logger, cancellationToken); @@ -776,7 +768,7 @@ await PermissionsSubcommand.ConfigureCustomPermissionsAsync( GraphInheritablePermissionsError = blueprintResult.graphInheritablePermissionsError, FederatedCredentialError = blueprintResult.ficError, }; - SetupHelpers.DisplaySetupSummary(summary, logger); + SetupHelpers.DisplaySetupSummary(summary, logger, graphApiService.GraphBaseUrl); } return new BlueprintCreationResult @@ -953,7 +945,8 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( { using var spHttpClient = Services.Internal.HttpClientFactory.CreateAuthenticatedClient(spToken); var spRetryHelper = new Services.Helpers.RetryHelper(logger); - existingServicePrincipalId = await CreateServicePrincipalAsync(existingAppId, spHttpClient, spRetryHelper, logger, ct); + existingServicePrincipalId = await CreateServicePrincipalAsync( + existingAppId, spHttpClient, spRetryHelper, logger, graphApiService.GraphBaseUrl, ct); if (!string.IsNullOrWhiteSpace(existingServicePrincipalId)) { requiresPersistence = true; @@ -976,7 +969,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( { if (spAuthDenied) return true; using var checkResp = await spHttpClient.GetAsync( - $"{Constants.GraphApiConstants.BaseUrl}/v1.0/oauth2PermissionGrants?$filter=clientId eq '{existingServicePrincipalId}'", token); + $"{graphApiService.GraphBaseUrl}/v1.0/oauth2PermissionGrants?$filter=clientId eq '{existingServicePrincipalId}'", token); if (checkResp.StatusCode == System.Net.HttpStatusCode.Forbidden) { spAuthDenied = true; @@ -1090,7 +1083,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( { sponsorUserId = me.Id; logger.LogInformation("Current user: {DisplayName} <{UPN}>", me.DisplayName, me.UserPrincipalName); - logger.LogDebug("Sponsor: {BaseUrl}/v1.0/users/{UserId}", Constants.GraphApiConstants.BaseUrl, sponsorUserId); + logger.LogDebug("Sponsor: {BaseUrl}/v1.0/users/{UserId}", graphApiService.GraphBaseUrl, sponsorUserId); } } catch (Exception ex) @@ -1114,11 +1107,11 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( { appManifest["sponsors@odata.bind"] = new JsonArray { - $"{Constants.GraphApiConstants.BaseUrl}/v1.0/users/{sponsorUserId}" + $"{graphApiService.GraphBaseUrl}/v1.0/users/{sponsorUserId}" }; appManifest["owners@odata.bind"] = new JsonArray { - $"{Constants.GraphApiConstants.BaseUrl}/v1.0/users/{sponsorUserId}" + $"{graphApiService.GraphBaseUrl}/v1.0/users/{sponsorUserId}" }; } @@ -1134,7 +1127,9 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( logger.LogDebug("Acquiring blueprint httpClient token — scope: AgentIdentityBlueprintPrincipal.Create, loginHint: {LoginHint}", blueprintLoginHint ?? "(none)"); var graphToken = await AcquireMsalGraphTokenAsync(tenantId, setupConfig.ClientAppId, logger, ct, scope: AuthenticationConstants.AgentIdentityBlueprintPrincipalCreateScope, - loginHint: blueprintLoginHint); + loginHint: blueprintLoginHint, + graphBaseUrl: graphApiService.GraphBaseUrl, + authorityHost: graphApiService.AuthorityHost); if (string.IsNullOrEmpty(graphToken)) { logger.LogError("Failed to extract access token from Graph client"); @@ -1146,7 +1141,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( httpClient.DefaultRequestHeaders.Add("ConsistencyLevel", "eventual"); httpClient.DefaultRequestHeaders.Add("OData-Version", "4.0"); // Required for @odata.type - var createAppUrl = $"{Constants.GraphApiConstants.BaseUrl}/beta/applications"; + var createAppUrl = $"{graphApiService.GraphBaseUrl}/beta/applications"; logger.LogInformation("Display Name: {DisplayName}", displayName); if (!string.IsNullOrEmpty(sponsorUserId)) @@ -1239,7 +1234,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( var appAvailable = await retryHelper.ExecuteWithRetryAsync( async ct => { - var checkResp = await httpClient.GetAsync($"{Constants.GraphApiConstants.BaseUrl}/v1.0/applications/{objectId}", ct); + var checkResp = await httpClient.GetAsync($"{graphApiService.GraphBaseUrl}/v1.0/applications/{objectId}", ct); return checkResp.IsSuccessStatusCode; }, result => !result, @@ -1258,7 +1253,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( // Update application with identifier URI and expose the access_agent_as_user scope // so callers can acquire tokens scoped to this blueprint via the OBO flow. var identifierUri = $"api://{appId}"; - var patchAppUrl = $"{Constants.GraphApiConstants.BaseUrl}/v1.0/applications/{objectId}"; + var patchAppUrl = $"{graphApiService.GraphBaseUrl}/v1.0/applications/{objectId}"; var patchBody = new JsonObject { ["identifierUris"] = new JsonArray { identifierUri }, @@ -1354,7 +1349,8 @@ await retryHelper.ExecuteWithRetryAsync( // objectId. Retry with backoff until the appId index is replicated. logger.LogInformation(""); logger.LogInformation("Creating blueprint service principal..."); - string? servicePrincipalId = await CreateServicePrincipalAsync(appId, httpClient, retryHelper, logger, ct); + string? servicePrincipalId = await CreateServicePrincipalAsync( + appId, httpClient, retryHelper, logger, graphApiService.GraphBaseUrl, ct); if (string.IsNullOrWhiteSpace(servicePrincipalId)) { logger.LogError("Service principal creation failed after retries"); @@ -1465,9 +1461,10 @@ await retryHelper.ExecuteWithRetryAsync( HttpClient httpClient, Services.Helpers.RetryHelper retryHelper, ILogger logger, + string graphBaseUrl, CancellationToken ct) { - var createSpUrl = $"{Constants.GraphApiConstants.BaseUrl}/v1.0/serviceprincipals/graph.agentIdentityBlueprintPrincipal"; + var createSpUrl = $"{graphBaseUrl}/v1.0/serviceprincipals/graph.agentIdentityBlueprintPrincipal"; var spManifestJson = new JsonObject { ["appId"] = appId }.ToJsonString(); int forbiddenRetries = 0; const int maxForbiddenRetries = 3; @@ -1598,7 +1595,7 @@ await retryHelper.ExecuteWithRetryAsync( { var ownerPayload = new Dictionary { - ["@odata.id"] = $"{Constants.GraphApiConstants.BaseUrl}/v1.0/users/{currentUserObjectId}" + ["@odata.id"] = $"{graphApiService.GraphBaseUrl}/v1.0/users/{currentUserObjectId}" }; var ownerResponse = await graphApiService.GraphPostWithResponseAsync( @@ -1665,7 +1662,7 @@ await retryHelper.ExecuteWithRetryAsync( tenantId, objectId, credentialName, - $"https://login.microsoftonline.com/{tenantId}/v2.0", + $"{graphApiService.AuthorityHost}/{tenantId}/v2.0", managedIdentityPrincipalId, new List { "api://AzureADTokenExchange" }, ct); @@ -1907,7 +1904,8 @@ private static List GetApplicationScopes(Models.Agent365Config setupConf // Build the reference/handoff URL up front so it is available even if the orchestrator throws. var consentUrlGraph = SetupHelpers.BuildAdminConsentUrl( tenantId, appId, - applicationScopes.Select(s => $"{AuthenticationConstants.MicrosoftGraphResourceUri}/{s}")); + applicationScopes.Select(s => $"{graphApiService.GraphBaseUrl}/{s}"), + graphApiService.AuthorityHost); bool consentSuccess; bool inheritedConfigured; @@ -1964,7 +1962,10 @@ await BatchPermissionsOrchestrator.ConfigureAllPermissionsAsync( /// rejected by the Agent Blueprint API. Defaults to .default (all consented permissions). /// Pass loginHint so WAM targets the az-logged-in user rather than the OS default account. /// - private static async Task AcquireMsalGraphTokenAsync(string tenantId, string clientAppId, ILogger logger, CancellationToken ct = default, string? scope = null, string? loginHint = null, string[]? additionalScopes = null) + private static async Task AcquireMsalGraphTokenAsync( + string tenantId, string clientAppId, ILogger logger, CancellationToken ct = default, + string? scope = null, string? loginHint = null, string[]? additionalScopes = null, + string? graphBaseUrl = null, string? authorityHost = null) { // Guard: MSAL will fail (and block for ~30s on WAM) with empty credentials. if (string.IsNullOrWhiteSpace(clientAppId) || string.IsNullOrWhiteSpace(tenantId)) @@ -1975,19 +1976,22 @@ await BatchPermissionsOrchestrator.ConfigureAllPermissionsAsync( try { + var resolvedGraphBaseUrl = ConfigConstants.NormalizeGraphBaseUrl(graphBaseUrl); + var resolvedAuthorityHost = ConfigConstants.NormalizeAuthorityHost(authorityHost); var credential = new MsalBrowserCredential( clientAppId, tenantId, redirectUri: null, // Let MsalBrowserCredential use WAM on Windows logger, + authority: $"{resolvedAuthorityHost}/{tenantId}", loginHint: loginHint); var primaryScope = string.IsNullOrWhiteSpace(scope) - ? $"{Constants.GraphApiConstants.BaseUrl}/.default" - : $"{Constants.GraphApiConstants.BaseUrl}/{scope}"; + ? $"{resolvedGraphBaseUrl}/.default" + : $"{resolvedGraphBaseUrl}/{scope}"; var allScopes = additionalScopes?.Length > 0 - ? new[] { primaryScope }.Concat(additionalScopes.Select(s => $"{Constants.GraphApiConstants.BaseUrl}/{s}")).ToArray() + ? new[] { primaryScope }.Concat(additionalScopes.Select(s => $"{resolvedGraphBaseUrl}/{s}")).ToArray() : new[] { primaryScope }; var tokenRequestContext = new TokenRequestContext(allScopes); @@ -2039,7 +2043,9 @@ private async static Task GetAuthenticatedGraphClientAsync(I // Pass the caller's logger so messages appear in the correct indent scope. var interactiveAuth = new InteractiveGraphAuthService( logger, - setupConfig.ClientAppId); + setupConfig.ClientAppId, + graphBaseUrl: ConfigConstants.GetGraphBaseUrl(setupConfig.Environment, setupConfig.GraphBaseUrl), + authorityHost: ConfigConstants.GetAuthorityHost(setupConfig.Environment, setupConfig.AuthorityHost)); try { @@ -2103,7 +2109,9 @@ public static async Task CreateBlueprintClientSecretAsync( setupConfig.ClientAppId ?? string.Empty, logger, ct, scope: AuthenticationConstants.AgentIdentityBlueprintReadWriteAllScope, - loginHint: loginHint); + loginHint: loginHint, + graphBaseUrl: graphService.GraphBaseUrl, + authorityHost: graphService.AuthorityHost); if (string.IsNullOrWhiteSpace(graphToken)) { @@ -2122,7 +2130,7 @@ public static async Task CreateBlueprintClientSecretAsync( } }; - var addPasswordUrl = $"{Constants.GraphApiConstants.BaseUrl}/v1.0/applications/{blueprintObjectId}/addPassword"; + var addPasswordUrl = $"{graphService.GraphBaseUrl}/v1.0/applications/{blueprintObjectId}/addPassword"; var secretBodyJson = secretBody.ToJsonString(); // Retry on 404 (blueprint not yet visible on all replicas) and transient 403 (owner @@ -2232,6 +2240,7 @@ private static async Task ValidateClientSecretAsync( string clientSecret, bool isProtected, string tenantId, + GraphApiService graphService, ILogger logger, CancellationToken ct = default) { @@ -2245,7 +2254,7 @@ private static async Task ValidateClientSecretAsync( using var httpClient = new HttpClient(); httpClient.Timeout = TimeSpan.FromSeconds(ClientSecretValidationTimeoutSeconds); - var tokenUrl = string.Format(MicrosoftLoginOAuthTokenEndpoint, tenantId); + var tokenUrl = ConfigConstants.BuildTokenEndpointUrl(graphService.AuthorityHost, tenantId); for (int attempt = 1; attempt <= ClientSecretValidationMaxRetries; attempt++) { @@ -2255,7 +2264,7 @@ private static async Task ValidateClientSecretAsync( { ["client_id"] = clientId, ["client_secret"] = plaintextSecret, - ["scope"] = $"{Constants.GraphApiConstants.BaseUrl}/.default", + ["scope"] = $"{graphService.GraphBaseUrl}/.default", ["grant_type"] = "client_credentials" }); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs index 6e9a96ed..714ad3d1 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs @@ -99,10 +99,7 @@ public static Command CreateCommand( } // Configure GraphApiService with custom client app ID if available - if (!string.IsNullOrWhiteSpace(setupConfig.ClientAppId)) - { - graphApiService.CustomClientAppId = setupConfig.ClientAppId; - } + graphApiService.ConfigureCloudEndpoints(setupConfig); // Verify system requirements (PowerShell modules are required for Graph operations). // Skipped in dry-run: PowerShellModulesRequirementCheck can auto-install modules, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs index 9a6150cc..078ad139 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs @@ -217,7 +217,7 @@ private static async Task EnsureConsentWithPromptAsync(SetupContext ctx) if (roleCheck == Models.RoleCheckResult.DoesNotHaveRole) { ctx.Logger.LogWarning("Granting tenant-wide consent requires a tenant administrator. Setup will continue and may fail if these permissions are required at runtime."); - var url = Exceptions.ClientAppValidationException.BuildAdminConsentUrl(clientAppId, tenantId); + var url = Exceptions.ClientAppValidationException.BuildAdminConsentUrl(clientAppId, tenantId, ctx.GraphApiService.AuthorityHost); if (!string.IsNullOrWhiteSpace(url)) { ctx.Logger.LogInformation("Share the following URL with a tenant administrator so they can grant consent:"); @@ -416,7 +416,7 @@ await AllSubcommand.ExecuteBatchPermissionsStepAsync( // IsNonDwBlueprintFlow=true was set at the top of this method; DisplaySetupSummary reads that // flag directly to pick the non-DW step layout and action-required content. ctx.Logger.LogInformation(""); - SetupHelpers.DisplaySetupSummary(ctx.Results, ctx.Logger); + SetupHelpers.DisplaySetupSummary(ctx.Results, ctx.Logger, ctx.GraphApiService.GraphBaseUrl); return ctx.Results.HasErrors ? 1 : 0; } @@ -739,7 +739,7 @@ internal static async Task GrantOrInstructAgentIdentityAppPermissionsAsync( // Issue #460: Graph token lacks AppRoleAssignment.ReadWrite.All; retry via az rest (a GA's az token carries it) before PowerShell. ctx.Logger.LogDebug("S2S app role assignments on the agent identity could not be completed via the Graph API; falling back to az rest."); var (attempted, succeeded) = await AzRestS2SRunner.TryRunAsync( - ctx.Executor, agentIdentitySpObjectId, failedSpecs, ctx.Logger, ctx.CancellationToken); + ctx.Executor, agentIdentitySpObjectId, failedSpecs, ctx.Logger, ctx.CancellationToken, ctx.GraphApiService.GraphBaseUrl); if (attempted && succeeded) { using (ctx.Logger.Indent()) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs index c1b4aaea..ff257c3b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs @@ -196,11 +196,7 @@ private static Command CreateMcpSubcommand( return; } - // Configure GraphApiService with custom client app ID if available - if (!string.IsNullOrWhiteSpace(setupConfig.ClientAppId)) - { - graphApiService.CustomClientAppId = setupConfig.ClientAppId; - } + graphApiService.ConfigureCloudEndpoints(setupConfig); // Verify system requirements (PowerShell modules are required for Graph operations). // Skipped in dry-run: PowerShellModulesRequirementCheck can auto-install modules, @@ -329,11 +325,7 @@ private static Command CreateBotSubcommand( return; } - // Configure GraphApiService with custom client app ID if available - if (!string.IsNullOrWhiteSpace(setupConfig.ClientAppId)) - { - graphApiService.CustomClientAppId = setupConfig.ClientAppId; - } + graphApiService.ConfigureCloudEndpoints(setupConfig); // Verify system requirements (PowerShell modules are required for Graph operations). // Skipped in dry-run: PowerShellModulesRequirementCheck can auto-install modules, @@ -513,11 +505,7 @@ private static Command CreateCustomSubcommand( return; } - // Configure GraphApiService with custom client app ID if available - if (!string.IsNullOrWhiteSpace(setupConfig.ClientAppId)) - { - graphApiService.CustomClientAppId = setupConfig.ClientAppId; - } + graphApiService.ConfigureCloudEndpoints(setupConfig); // Verify system requirements (PowerShell modules are required for Graph operations). // Skipped in dry-run: PowerShellModulesRequirementCheck can auto-install modules, @@ -587,9 +575,12 @@ await SetupHelpers.EnsureResourcePermissionsAsync( StringComparison.OrdinalIgnoreCase); if (isGraph) { - var fullyQualified = scopes.Select(s => $"{AuthenticationConstants.MicrosoftGraphResourceUri}/{s}"); + var graphBaseUrl = ConfigConstants.GetGraphBaseUrl(setupConfig.Environment, setupConfig.GraphBaseUrl); + var graphResourceUri = GraphApiConstants.GetResource(graphBaseUrl).TrimEnd('/'); + var authorityHost = ConfigConstants.GetAuthorityHost(setupConfig.Environment, setupConfig.AuthorityHost); + var fullyQualified = scopes.Select(s => $"{graphResourceUri}/{s}"); var url = SetupHelpers.BuildAdminConsentUrl( - setupConfig.TenantId, setupConfig.AgentBlueprintId!, fullyQualified); + setupConfig.TenantId, setupConfig.AgentBlueprintId!, fullyQualified, authorityHost); LogAdminConsentNextSteps(logger, url); } else diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs index 9f88f255..9358ae02 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs @@ -105,6 +105,7 @@ public static Command CreateCommand( return; } + graphApiService.ConfigureCloudEndpoints(configForChecks); var configPassed = await RunRequirementChecksAsync(configChecks, configForChecks, logger, ct: ct); allPassed = allPassed && configPassed; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs index 53e85506..e8dd97fa 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs @@ -459,8 +459,9 @@ public static async Task DisplayVerificationInfoAsync(FileInfo setupConfigFile, /// The DW vs non-DW branch is determined solely by , /// which both orchestrators set explicitly — there is no separate caller-supplied flag. /// - public static void DisplaySetupSummary(SetupResults results, ILogger logger) + public static void DisplaySetupSummary(SetupResults results, ILogger logger, string? graphBaseUrl = null) { + var resolvedGraphBaseUrl = ConfigConstants.NormalizeGraphBaseUrl(graphBaseUrl); var isNonDw = results.IsNonDwBlueprintFlow; var isBlueprintOnly = results.IsBlueprintOnlyFlow; // Which row groups this run actually performs. Blueprint-only ('setup blueprint') stops after @@ -881,12 +882,12 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger) logger.LogInformation(" # Observability API"); logger.LogInformation(" $obsSp = Get-MgServicePrincipal -Filter \"appId eq '{ObsAppId}'\"", ConfigConstants.ObservabilityApiAppId); logger.LogInformation(" $body = @{{ clientId = $agentSpId; consentType = 'AllPrincipals'; resourceId = $obsSp.Id; scope = '{ObsScope}' }} | ConvertTo-Json", ConfigConstants.ObservabilityApiOtelWriteScope); - logger.LogInformation(" Invoke-MgGraphRequest -Method POST -Uri 'https://graph.microsoft.com/v1.0/oauth2PermissionGrants' -Body $body -ContentType 'application/json'"); + logger.LogInformation(" Invoke-MgGraphRequest -Method POST -Uri '{GraphBaseUrl}/v1.0/oauth2PermissionGrants' -Body $body -ContentType 'application/json'", resolvedGraphBaseUrl); logger.LogInformation(""); logger.LogInformation(" # Power Platform API"); logger.LogInformation(" $ppSp = Get-MgServicePrincipal -Filter \"appId eq '{PpAppId}'\"", PowerPlatformConstants.PowerPlatformApiResourceAppId); logger.LogInformation(" $body = @{{ clientId = $agentSpId; consentType = 'AllPrincipals'; resourceId = $ppSp.Id; scope = '{PpScope}' }} | ConvertTo-Json", PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead); - logger.LogInformation(" Invoke-MgGraphRequest -Method POST -Uri 'https://graph.microsoft.com/v1.0/oauth2PermissionGrants' -Body $body -ContentType 'application/json'"); + logger.LogInformation(" Invoke-MgGraphRequest -Method POST -Uri '{GraphBaseUrl}/v1.0/oauth2PermissionGrants' -Body $body -ContentType 'application/json'", resolvedGraphBaseUrl); } if (messagingEndpointManualRequired) { @@ -1062,7 +1063,14 @@ internal static List PopulateAdminConsentUrls( IReadOnlyDictionary? mcpScopesByAudience = null, IReadOnlyDictionary>? mcpAudienceDisplayNames = null) { - var urls = BuildAdminConsentUrls(config.TenantId, config.AgentBlueprintId!, config.AgentApplicationScopes, mcpScopes, isM365, mcpScopesByAudience, mcpAudienceDisplayNames); + var graphBaseUrl = ConfigConstants.GetGraphBaseUrl(config.Environment, config.GraphBaseUrl); + var graphResourceUri = graphBaseUrl; + var authorityHost = ConfigConstants.GetAuthorityHost(config.Environment, config.AuthorityHost); + + var urls = BuildAdminConsentUrls( + config.TenantId, config.AgentBlueprintId!, config.AgentApplicationScopes, mcpScopes, + isM365, mcpScopesByAudience, mcpAudienceDisplayNames, graphResourceUri, authorityHost, + mcpResourceAppId); // Map resource names to App IDs for upsert into ResourceConsents. The fixed-name // entries cover Graph + Bot + Obs + PP + the WorkIQ shared MCP audience. V2 @@ -1145,11 +1153,13 @@ private static bool TryExtractAudienceAppIdFromResourceName(string resourceName, /// Each scope is individually Uri.EscapeDataString-encoded and joined with %20. /// A random GUID state parameter is generated for CSRF protection. /// - internal static string BuildAdminConsentUrl(string tenantId, string clientId, IEnumerable fullyQualifiedScopes) + internal static string BuildAdminConsentUrl( + string tenantId, string clientId, IEnumerable fullyQualifiedScopes, string? authorityHost = null) { var scopeParam = string.Join("%20", fullyQualifiedScopes.Select(Uri.EscapeDataString)); var redirectEncoded = Uri.EscapeDataString(AuthenticationConstants.BlueprintConsentRedirectUri); - return $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={clientId}&scope={scopeParam}&redirect_uri={redirectEncoded}&state={Guid.NewGuid():N}"; + var normalizedAuthorityHost = ConfigConstants.NormalizeAuthorityHost(authorityHost); + return $"{normalizedAuthorityHost}/{tenantId}/v2.0/adminconsent?client_id={clientId}&scope={scopeParam}&redirect_uri={redirectEncoded}&state={Guid.NewGuid():N}"; } /// @@ -1169,10 +1179,14 @@ internal static string BuildAdminConsentUrl(string tenantId, string clientId, IE /// is a V2 MCP per-server audience (e.g. it sits in the ToolingManifest audience set /// or the call site is iterating mcpScopesByAudience). Default false preserves /// the safe api://{appId} fallback for any caller that has not been updated. - internal static string GetResourceIdentifierUri(string resourceAppId, bool isMcpAudience = false) + internal static string GetResourceIdentifierUri( + string resourceAppId, + bool isMcpAudience = false, + string graphResourceUri = AuthenticationConstants.MicrosoftGraphResourceUri, + string? sharedMcpResourceAppId = null) { if (string.Equals(resourceAppId, AuthenticationConstants.MicrosoftGraphResourceAppId, StringComparison.OrdinalIgnoreCase)) - return AuthenticationConstants.MicrosoftGraphResourceUri; + return graphResourceUri; if (string.Equals(resourceAppId, ConfigConstants.MessagingBotApiAppId, StringComparison.OrdinalIgnoreCase)) return ConfigConstants.MessagingBotApiIdentifierUri; if (string.Equals(resourceAppId, ConfigConstants.ObservabilityApiAppId, StringComparison.OrdinalIgnoreCase)) @@ -1182,7 +1196,7 @@ internal static string GetResourceIdentifierUri(string resourceAppId, bool isMcp // WorkIQ Tools shared (issue #429): match by appId, not display name. V2 per-server // audiences are also named "Agent 365 Tools" so the old name-based check collapsed // them onto WorkIQ's URI and produced AADSTS650053. - if (IsAgent365ToolsResourceAppId(resourceAppId)) + if (IsAgent365ToolsResourceAppId(resourceAppId, sharedMcpResourceAppId)) return McpConstants.Agent365ToolsIdentifierUri; // V2 MCP per-server audiences (identifierUris=null, only bare appId in @@ -1197,29 +1211,21 @@ internal static string GetResourceIdentifierUri(string resourceAppId, bool isMcp /// /// Returns true when the supplied resource appId is the WorkIQ Tools (Agent 365 Tools) - /// shared resource — either the hard-coded prod appId or an env-overridden value - /// pinned via A365_MCP_APP_ID_<env>. Used by + /// shared resource — either the hard-coded production appId or the explicitly resolved + /// cloud-specific appId. Used by /// to distinguish the WorkIQ shared audience /// (returns canonical https URI) from V2 MCP per-server audiences (returns bare appId /// GUID because per-server SPs have identifierUris = null and Entra rejects /// api://{appId} for them with AADSTS500011). /// - private static bool IsAgent365ToolsResourceAppId(string resourceAppId) + private static bool IsAgent365ToolsResourceAppId(string resourceAppId, string? sharedMcpResourceAppId = null) { if (string.IsNullOrWhiteSpace(resourceAppId)) return false; if (string.Equals(resourceAppId, McpConstants.WorkIQToolsProdAppId, StringComparison.OrdinalIgnoreCase)) return true; - // Also accept any value the environment-aware resolver returns for known env keys. - // Cheaper than walking every possible env: only check the env on the running config - // when explicitly passed via env var. ConfigConstants.GetAgent365ToolsResourceAppId - // already short-circuits to the prod appId when no override is set. - foreach (var envKey in new[] { "prod", "preprod", "test", "dev" }) - { - var resolved = ConfigConstants.GetAgent365ToolsResourceAppId(envKey); - if (string.Equals(resourceAppId, resolved, StringComparison.OrdinalIgnoreCase)) - return true; - } - return false; + + return !string.IsNullOrWhiteSpace(sharedMcpResourceAppId) + && string.Equals(resourceAppId, sharedMcpResourceAppId, StringComparison.OrdinalIgnoreCase); } /// @@ -1230,8 +1236,13 @@ private static bool IsAgent365ToolsResourceAppId(string resourceAppId) /// Forwarded to ; pass /// true when the caller knows is a V2 MCP per-server /// audience (e.g. found in the loaded ToolingManifest audience set). Default false. - internal static string BuildFullyQualifiedScope(string resourceAppId, string scope, bool isMcpAudience = false) - => $"{GetResourceIdentifierUri(resourceAppId, isMcpAudience)}/{scope}"; + internal static string BuildFullyQualifiedScope( + string resourceAppId, + string scope, + bool isMcpAudience = false, + string graphResourceUri = AuthenticationConstants.MicrosoftGraphResourceUri, + string? sharedMcpResourceAppId = null) + => $"{GetResourceIdentifierUri(resourceAppId, isMcpAudience, graphResourceUri, sharedMcpResourceAppId)}/{scope}"; /// /// Builds per-resource admin consent URLs covering every resource stamped on the blueprint @@ -1253,16 +1264,19 @@ internal static string BuildFullyQualifiedScope(string resourceAppId, string sco IEnumerable mcpScopes, bool isM365 = true, IReadOnlyDictionary? mcpScopesByAudience = null, - IReadOnlyDictionary>? mcpAudienceDisplayNames = null) + IReadOnlyDictionary>? mcpAudienceDisplayNames = null, + string graphResourceUri = AuthenticationConstants.MicrosoftGraphResourceUri, + string? authorityHost = null, + string? sharedMcpResourceAppId = null) { var urls = new List<(string, string)>(); - static string Build(string tenant, string client, string resourceUri, IEnumerable scopes) - => BuildAdminConsentUrl(tenant, client, scopes.Select(s => $"{resourceUri}/{s}")); + string Build(string tenant, string client, string resourceUri, IEnumerable scopes) + => BuildAdminConsentUrl(tenant, client, scopes.Select(s => $"{resourceUri}/{s}"), authorityHost); var graphScopeList = graphScopes.ToList(); if (graphScopeList.Count > 0) - urls.Add(("Microsoft Graph", Build(tenantId, blueprintClientId, AuthenticationConstants.MicrosoftGraphResourceUri, graphScopeList))); + urls.Add(("Microsoft Graph", Build(tenantId, blueprintClientId, graphResourceUri, graphScopeList))); // V2 per-server audiences (issue #429): when the caller passes a by-audience map, // emit one URL fragment per audience whose resource identifier is resolved by @@ -1278,7 +1292,8 @@ static string Build(string tenant, string client, string resourceUri, IEnumerabl if (scopes is null || scopes.Length == 0) continue; // The loop iterates over manifest-derived MCP audiences; every key here is // by definition an MCP per-server audience appId. - var resourceUri = GetResourceIdentifierUri(audienceAppId, isMcpAudience: true); + var resourceUri = GetResourceIdentifierUri( + audienceAppId, isMcpAudience: true, sharedMcpResourceAppId: sharedMcpResourceAppId); // Display name: WorkIQ shared audience keeps the legacy "Agent 365 Tools" // label. Per-server audiences use the manifest McpServerName when supplied // (e.g. "mcp_MailTools (16b1878d-...)") so the consent URL block matches the @@ -1288,7 +1303,7 @@ static string Build(string tenant, string client, string resourceUri, IEnumerabl // TryExtractAudienceAppIdFromResourceName for the PopulateAdminConsentUrls // upsert path. string resourceName; - if (IsAgent365ToolsResourceAppId(audienceAppId)) + if (IsAgent365ToolsResourceAppId(audienceAppId, sharedMcpResourceAppId)) { resourceName = "Agent 365 Tools"; } @@ -1338,11 +1353,14 @@ internal static string BuildCombinedConsentUrl( IEnumerable graphScopes, IEnumerable mcpScopes, bool isM365 = true, - IReadOnlyDictionary? mcpScopesByAudience = null) + IReadOnlyDictionary? mcpScopesByAudience = null, + string graphResourceUri = AuthenticationConstants.MicrosoftGraphResourceUri, + string? authorityHost = null, + string? sharedMcpResourceAppId = null) { var allScopes = new List(); foreach (var s in graphScopes) - allScopes.Add($"{AuthenticationConstants.MicrosoftGraphResourceUri}/{s}"); + allScopes.Add($"{graphResourceUri}/{s}"); // V2 per-server audiences (issue #429): when the caller passes a by-audience map, // emit per-audience scope URIs using GetResourceIdentifierUri so the WorkIQ @@ -1357,7 +1375,8 @@ internal static string BuildCombinedConsentUrl( if (scopes is null) continue; // The loop iterates over manifest-derived MCP audiences; every key here is // by definition an MCP per-server audience appId. - var resourceUri = GetResourceIdentifierUri(audienceAppId, isMcpAudience: true); + var resourceUri = GetResourceIdentifierUri( + audienceAppId, isMcpAudience: true, sharedMcpResourceAppId: sharedMcpResourceAppId); foreach (var s in scopes) allScopes.Add($"{resourceUri}/{s}"); } @@ -1372,7 +1391,7 @@ internal static string BuildCombinedConsentUrl( allScopes.Add($"{ConfigConstants.MessagingBotApiIdentifierUri}/{ConfigConstants.MessagingBotApiAdminConsentScope}"); allScopes.Add($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiOtelWriteScope}"); allScopes.Add($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}"); - return BuildAdminConsentUrl(tenantId, blueprintClientId, allScopes); + return BuildAdminConsentUrl(tenantId, blueprintClientId, allScopes, authorityHost); } /// @@ -1401,9 +1420,13 @@ internal static void ApplyConsentUrlsIfNeeded( var consentResourceNames = PopulateAdminConsentUrls(ctx.Config, mcpResourceAppId, mcpScopes, isM365, mcpScopesByAudience, mcpAudienceDisplayNames); ctx.Results.ConsentUrlsSavedToPath = ctx.GeneratedConfigPath; ctx.Results.ConsentResourceNames.AddRange(consentResourceNames); + var graphBaseUrl = ConfigConstants.GetGraphBaseUrl(ctx.Config.Environment, ctx.Config.GraphBaseUrl); + var graphResourceUri = graphBaseUrl; + var authorityHost = ConfigConstants.GetAuthorityHost(ctx.Config.Environment, ctx.Config.AuthorityHost); ctx.Results.CombinedConsentUrl = BuildCombinedConsentUrl( ctx.Config.TenantId!, ctx.Config.AgentBlueprintId!, - graphScopes, mcpScopes, isM365, mcpScopesByAudience); + graphScopes, mcpScopes, isM365, mcpScopesByAudience, graphResourceUri, authorityHost, + mcpResourceAppId); } /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs index bf2fe665..41f1ed9f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System; +using System.Text.RegularExpressions; namespace Microsoft.Agents.A365.DevTools.Cli.Constants; @@ -10,6 +11,14 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Constants; /// public static class ConfigConstants { + /// + /// Commercial-cloud OAuth authority host. Used as the fallback when no cloud-specific + /// override is configured. + /// + public const string DefaultAuthorityHost = "https://login.microsoftonline.com"; + private const string AuthorityHostEnvVar = "A365_AUTHORITY_HOST"; + private const string GraphBaseUrlEnvVar = "A365_GRAPH_BASE_URL"; + /// /// Default static configuration file name (user-managed, version-controlled) /// @@ -153,7 +162,7 @@ public static class ConfigConstants public static string GetDiscoverEndpointUrl(string environment) { // Check for custom endpoint in environment variable first - var customEndpoint = Environment.GetEnvironmentVariable($"A365_DISCOVER_ENDPOINT_{environment?.ToUpper()}"); + var customEndpoint = GetEnvironmentScopedSetting("A365_DISCOVER_ENDPOINT", environment); if (!string.IsNullOrEmpty(customEndpoint)) return customEndpoint; @@ -167,13 +176,78 @@ public static string GetDiscoverEndpointUrl(string environment) /// /// environment-aware Agent 365 Tools resource Application ID /// -public static string GetAgent365ToolsResourceAppId(string environment) -{ - // Check for custom app ID in environment variable first - var customAppId = Environment.GetEnvironmentVariable($"A365_MCP_APP_ID_{environment?.ToUpperInvariant()}"); - if (!string.IsNullOrEmpty(customAppId)) - return customAppId; + public static string GetAgent365ToolsResourceAppId(string environment) + => GetEnvironmentScopedSetting("A365_MCP_APP_ID", environment) + ?? McpConstants.WorkIQToolsProdAppId; + + /// + /// Returns the authority host for the selected cloud environment. + /// + public static string GetAuthorityHost(string environment, string? configAuthorityHost = null) + => NormalizeAuthorityHost(GetEnvironmentScopedSetting(AuthorityHostEnvVar, environment) ?? configAuthorityHost); + + /// + /// Returns the Graph base URL for the selected cloud environment. + /// + public static string GetGraphBaseUrl(string environment, string? configGraphBaseUrl = null) + => NormalizeGraphBaseUrl(GetEnvironmentScopedSetting(GraphBaseUrlEnvVar, environment) ?? configGraphBaseUrl); + + /// + /// Composes an OAuth2 admin-consent endpoint from an already-resolved authority host. + /// + public static string BuildAdminConsentEndpointUrl(string? authorityHost, string tenantId) + => $"{NormalizeAuthorityHost(authorityHost)}/{tenantId}/v2.0/adminconsent"; + + /// + /// Returns the OAuth2 token endpoint URL for the given tenant and environment. + /// + public static string GetTokenEndpointUrl(string tenantId, string environment, string? configAuthorityHost = null) + => BuildTokenEndpointUrl(GetAuthorityHost(environment, configAuthorityHost), tenantId); - return McpConstants.WorkIQToolsProdAppId; -} + /// + /// Composes an OAuth2 token endpoint from an already-resolved authority host. + /// + public static string BuildTokenEndpointUrl(string? authorityHost, string tenantId) + => $"{NormalizeAuthorityHost(authorityHost)}/{tenantId}/oauth2/v2.0/token"; + + internal static string NormalizeAuthorityHost(string? authorityHost) + => NormalizeHttpsOrigin(authorityHost, DefaultAuthorityHost, "Authority host"); + + internal static string NormalizeGraphBaseUrl(string? graphBaseUrl) + => NormalizeHttpsOrigin(graphBaseUrl, GraphApiConstants.BaseUrl, "Graph base URL"); + + /// + /// Normalizes an environment key so arbitrary cloud names can map to env vars. + /// + public static string NormalizeEnvironmentKey(string? environment) + { + if (string.IsNullOrWhiteSpace(environment)) + return "PROD"; + + var normalized = Regex.Replace(environment.Trim(), "[^A-Za-z0-9]", "_").ToUpperInvariant(); + return string.IsNullOrWhiteSpace(normalized) ? "PROD" : normalized; + } + + private static string? GetEnvironmentScopedSetting(string prefix, string? environment) + => Environment.GetEnvironmentVariable($"{prefix}_{NormalizeEnvironmentKey(environment)}") is { } value + && !string.IsNullOrWhiteSpace(value) + ? value.Trim() + : null; + + private static string NormalizeHttpsOrigin(string? value, string fallback, string settingName) + { + var candidate = string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); + if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri) || + !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || + !string.IsNullOrEmpty(uri.UserInfo) || + !string.IsNullOrEmpty(uri.Query) || + !string.IsNullOrEmpty(uri.Fragment) || + uri.AbsolutePath != "/") + { + throw new ArgumentException( + $"{settingName} must be an HTTPS origin without a path, query, fragment, or user info."); + } + + return uri.GetLeftPart(UriPartial.Authority); + } } \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs index 7dfbfed0..fb7290d0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs @@ -83,9 +83,9 @@ public static ClientAppValidationException MissingPermissions( /// Creates exception for missing admin consent. /// Includes a direct admin consent URL that a Global Administrator can open to grant consent. /// - public static ClientAppValidationException MissingAdminConsent(string clientAppId, string? tenantId = null) + public static ClientAppValidationException MissingAdminConsent(string clientAppId, string? tenantId = null, string? authorityHost = null) { - var consentUrl = BuildAdminConsentUrl(clientAppId, tenantId); + var consentUrl = BuildAdminConsentUrl(clientAppId, tenantId, authorityHost); var consentInstruction = consentUrl != null ? $"Share this URL with a Global Administrator to grant consent:\n {consentUrl}" : "Grant admin consent at: Azure Portal > App registrations > Your app > API permissions."; @@ -116,16 +116,19 @@ public static ClientAppValidationException MissingAdminConsent(string clientAppI /// Builds the admin consent URL for the given client app and tenant. /// A Global Administrator can open this URL to grant tenant-wide (AllPrincipals) consent. /// - public static string? BuildAdminConsentUrl(string clientAppId, string? tenantId) + public static string? BuildAdminConsentUrl(string clientAppId, string? tenantId, string? authorityHost = null) { if (string.IsNullOrWhiteSpace(clientAppId) || string.IsNullOrWhiteSpace(tenantId)) return null; - // Standard native-app redirect URI accepted by Entra ID for admin consent flows - const string redirectUri = "https://login.microsoftonline.com/common/oauth2/nativeclient"; + // Standard native-app redirect URI accepted by Entra ID for admin consent flows. + // Authority host defaults to commercial cloud; callers pass a cloud-resolved host + // (e.g. GraphApiService.AuthorityHost) so sovereign tenants get a matching consent URL. + var host = ConfigConstants.NormalizeAuthorityHost(authorityHost); + var redirectUri = $"{host}/common/oauth2/nativeclient"; var clientIdEncoded = Uri.EscapeDataString(clientAppId); var redirectUriEncoded = Uri.EscapeDataString(redirectUri); - return $"https://login.microsoftonline.com/{tenantId}/adminconsent?client_id={clientIdEncoded}&redirect_uri={redirectUriEncoded}"; + return $"{host}/{tenantId}/adminconsent?client_id={clientIdEncoded}&redirect_uri={redirectUriEncoded}"; } /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs index c9b69167..2cc1848f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ProjectSettingsSyncHelper.cs @@ -19,8 +19,6 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Helpers; /// public static class ProjectSettingsSyncHelper { - private const string DEFAULT_AUTHORITY_ENDPOINT = "https://login.microsoftonline.com"; - private const string DEFAULT_USER_AUTHORIZATION_SCOPE = "https://graph.microsoft.com/.default"; // Messaging Bot API Application GUID private const string DEFAULT_SERVICE_CONNECTION_SCOPE = $"{ConfigConstants.MessagingBotApiAppId}/.default"; @@ -459,7 +457,8 @@ static JsonObject RequireObj(JsonObject parent, string prop) var agenticSettings = RequireObj(agentic, "Settings"); agenticSettings["AlternateBlueprintConnectionName"] = "ServiceConnection"; - var uaScopes = new JsonArray(DEFAULT_USER_AUTHORIZATION_SCOPE); + var userAuthorizationScope = GetUserAuthorizationScope(pkgConfig); + var uaScopes = new JsonArray(userAuthorizationScope); agenticSettings["Scopes"] = uaScopes; // -- Connections -- @@ -470,7 +469,7 @@ static JsonObject RequireObj(JsonObject parent, string prop) if (!string.IsNullOrWhiteSpace(pkgConfig.TenantId)) { - var authority = $"{DEFAULT_AUTHORITY_ENDPOINT}/{pkgConfig.TenantId}"; + var authority = $"{ConfigConstants.GetAuthorityHost(pkgConfig.Environment, pkgConfig.AuthorityHost)}/{pkgConfig.TenantId}"; svcSettings["AuthorityEndpoint"] = authority; } @@ -579,7 +578,7 @@ void Set(string key, string? value) Set("AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__ALT_BLUEPRINT_NAME", "SERVICE_CONNECTION"); Set("AGENTAPPLICATION__USERAUTHORIZATION__HANDLERS__AGENTIC__SETTINGS__SCOPES", - DEFAULT_USER_AUTHORIZATION_SCOPE); + GetUserAuthorizationScope(pkgConfig)); // --- ConnectionsMap[0] --- Set("CONNECTIONSMAP__0__SERVICEURL", "*"); @@ -651,7 +650,7 @@ void Set(string key, string? value) // --- AgenticAuthentication Options --- Set("agentic_altBlueprintConnectionName", "service_connection"); - Set("agentic_scopes", DEFAULT_USER_AUTHORIZATION_SCOPE); + Set("agentic_scopes", GetUserAuthorizationScope(pkgConfig)); Set("agentic_connectionName", "AgenticAuthConnection"); // --- Agent365 Observability --- @@ -694,4 +693,10 @@ private static string EscapeEnv(string value) } return value; } + + private static string GetUserAuthorizationScope(Agent365Config pkgConfig) + { + var graphBaseUrl = ConfigConstants.GetGraphBaseUrl(pkgConfig.Environment, pkgConfig.GraphBaseUrl); + return $"{graphBaseUrl}/.default"; + } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs index b9323f71..ab36a996 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs @@ -140,15 +140,18 @@ private static void ValidateAuthMode(string? value, List errors) public string MessagingEndpoint { get; init; } = string.Empty; /// - /// Base URL for Microsoft Graph API. - /// Override this to target sovereign / government clouds: - /// GCC High / DoD : "https://graph.microsoft.us" - /// China (21Vianet): "https://microsoftgraph.chinacloudapi.cn" - /// Defaults to "https://graph.microsoft.com" when omitted. + /// Base URL for Microsoft Graph API. Defaults to the commercial cloud endpoint. /// [JsonPropertyName("graphBaseUrl")] public string GraphBaseUrl { get; init; } = Constants.GraphApiConstants.BaseUrl; + /// + /// OAuth authority host for the selected cloud. Pair this with + /// so authentication and Graph data-plane calls target the same environment. + /// + [JsonPropertyName("authorityHost")] + public string? AuthorityHost { get; init; } + #endregion #region Authentication Configuration diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index 6661d462..4bce9db0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -334,6 +334,7 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini // Default to "prod". Override with A365_ENVIRONMENT env var or a365.config.json. string environment = Environment.GetEnvironmentVariable("A365_ENVIRONMENT") ?? "prod"; + string? authorityHost = null; var configFilePath = ConfigService.GetConfigFilePath(); if (configFilePath != null) @@ -350,6 +351,8 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini environment = envValue; } } + if (doc.RootElement.TryGetProperty("authorityHost", out var authorityProp)) + authorityHost = authorityProp.GetString(); logger.LogDebug("Resolved environment from config: {Environment}", environment); } @@ -359,7 +362,7 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini } } - return new Agent365ToolingService(configService, authService, logger, environment); + return new Agent365ToolingService(configService, authService, logger, environment, authorityHost); }); // Add Azure validators (individual validators for composition) @@ -442,4 +445,3 @@ private static string DetectCommandName(string[] args) .Replace("_", "-"); } } - diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs index 960f445d..4dc70369 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs @@ -148,6 +148,21 @@ string GetConfig(string name) => _logger.LogInformation("Using environment from config: {Env}", environment); } + // Wire the sovereign/government cloud endpoints so all Graph calls and client-credential + // token acquisition target the correct national cloud (commercial by default). + var configuredGraphBaseUrl = GetConfig("graphBaseUrl"); + _graphService.GraphBaseUrl = ConfigConstants.GetGraphBaseUrl( + environment, + string.IsNullOrWhiteSpace(configuredGraphBaseUrl) ? null : configuredGraphBaseUrl); + var configuredAuthorityHost = GetConfig("authorityHost"); + _graphService.AuthorityHost = ConfigConstants.GetAuthorityHost( + environment, + string.IsNullOrWhiteSpace(configuredAuthorityHost) ? null : configuredAuthorityHost); + var configuredClientAppId = GetConfig("clientAppId"); + if (!string.IsNullOrWhiteSpace(configuredClientAppId)) + _graphService.CustomClientAppId = configuredClientAppId; + var mcpResourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(environment); + var usageLocation = GetConfig("agentUserUsageLocation"); await SaveInstanceAsync(generatedConfigPath, instance, cancellationToken); @@ -320,7 +335,7 @@ string GetConfig(string name) => [AuthenticationConstants.MicrosoftGraphResourceAppId] = ( "Microsoft Graph", new HashSet(ConfigConstants.DefaultAgentIdentityScopes, StringComparer.OrdinalIgnoreCase)), - [McpConstants.WorkIQToolsProdAppId] = ( + [mcpResourceAppId] = ( "Work IQ Tools", new HashSet(StringComparer.OrdinalIgnoreCase) { @@ -657,7 +672,7 @@ string GetConfig(string name) => : correlationId; using var httpClient = HttpClientFactory.CreateAuthenticatedClient(correlationId: effectiveCorrelationId); - var tokenEndpoint = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"; + var tokenEndpoint = ConfigConstants.BuildTokenEndpointUrl(_graphService.AuthorityHost, tenantId); var requestBody = new FormUrlEncodedContent(new[] { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs index 578b0839..0d3e65ef 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs @@ -20,6 +20,7 @@ public class Agent365ToolingService : IAgent365ToolingService private readonly AuthenticationService _authService; private readonly ILogger _logger; private readonly string _environment; + private readonly string _authorityHost; /// public string Environment => _environment; @@ -28,12 +29,14 @@ public Agent365ToolingService( IConfigService configService, AuthenticationService authService, ILogger logger, - string environment = "prod") + string environment = "prod", + string? authorityHost = null) { _configService = configService ?? throw new ArgumentNullException(nameof(configService)); _authService = authService ?? throw new ArgumentNullException(nameof(authService)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _environment = environment ?? "prod"; + _authorityHost = ConfigConstants.GetAuthorityHost(_environment, authorityHost); } /// @@ -343,7 +346,8 @@ private string BuildProvisionIdentityUrl(string environment, string serverName) _logger.LogDebug("Acquiring access token for audience: {Audience}", audience); var loginHint = await AzCliHelper.ResolveLoginHintAsync(); - var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint, ct: cancellationToken); + var authToken = await _authService.GetAccessTokenAsync( + audience, userId: loginHint, ct: cancellationToken, authorityHost: _authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -422,7 +426,8 @@ private string BuildProvisionIdentityUrl(string environment, string serverName) _logger.LogDebug("Acquiring access token for audience: {Audience}", audience); var loginHint = await AzCliHelper.ResolveLoginHintAsync(); - var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint, ct: cancellationToken); + var authToken = await _authService.GetAccessTokenAsync( + audience, userId: loginHint, ct: cancellationToken, authorityHost: _authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -496,7 +501,8 @@ private string BuildProvisionIdentityUrl(string environment, string serverName) _logger.LogDebug("Acquiring access token for audience: {Audience}", audience); var loginHint = await AzCliHelper.ResolveLoginHintAsync(); - var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint, ct: cancellationToken); + var authToken = await _authService.GetAccessTokenAsync( + audience, userId: loginHint, ct: cancellationToken, authorityHost: _authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -583,7 +589,8 @@ public async Task UnpublishServerAsync( _logger.LogDebug("Acquiring access token for audience: {Audience}", audience); var loginHint = await AzCliHelper.ResolveLoginHintAsync(); - var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint, ct: cancellationToken); + var authToken = await _authService.GetAccessTokenAsync( + audience, userId: loginHint, ct: cancellationToken, authorityHost: _authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -628,7 +635,8 @@ public async Task LogRegisterUsageAsync( var endpointUrl = BuildLogRegisterUrl(_environment); var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); var loginHint = await AzCliHelper.ResolveLoginHintAsync(); - var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint); + var authToken = await _authService.GetAccessTokenAsync( + audience, userId: loginHint, authorityHost: _authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogDebug("Skipping telemetry: failed to acquire token"); @@ -666,7 +674,8 @@ public async Task LogEvaluateUsageAsync(CancellationToken cancellationToken = de var endpointUrl = BuildLogEvaluateUrl(_environment); var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); var loginHint = await AzCliHelper.ResolveLoginHintAsync(); - var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint); + var authToken = await _authService.GetAccessTokenAsync( + audience, userId: loginHint, authorityHost: _authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogDebug("Skipping telemetry: failed to acquire token"); @@ -721,7 +730,8 @@ public async Task LogEvaluateUsageAsync(CancellationToken cancellationToken = de _logger.LogDebug("Acquiring access token for audience: {Audience}", audience); var loginHint = await AzCliHelper.ResolveLoginHintAsync(); - var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint); + var authToken = await _authService.GetAccessTokenAsync( + audience, userId: loginHint, authorityHost: _authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -801,7 +811,8 @@ public async Task LogEvaluateUsageAsync(CancellationToken cancellationToken = de _logger.LogDebug("Acquiring access token for audience: {Audience}", audience); var loginHint = await AzCliHelper.ResolveLoginHintAsync(); - var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint); + var authToken = await _authService.GetAccessTokenAsync( + audience, userId: loginHint, authorityHost: _authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -873,7 +884,8 @@ public async Task LogEvaluateUsageAsync(CancellationToken cancellationToken = de _logger.LogDebug("Acquiring access token for audience: {Audience}", audience); var loginHint = await AzCliHelper.ResolveLoginHintAsync(); - var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint); + var authToken = await _authService.GetAccessTokenAsync( + audience, userId: loginHint, authorityHost: _authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -905,4 +917,3 @@ public async Task LogEvaluateUsageAsync(CancellationToken cancellationToken = de } } } - diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs index 878d50ec..3b15a2b3 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs @@ -27,7 +27,8 @@ Task GetAccessTokenAsync( IEnumerable? scopes = null, bool useInteractiveBrowser = true, string? userId = null, - CancellationToken ct = default); + CancellationToken ct = default, + string? authorityHost = null); Task ResolveLoginHintFromCacheAsync(); @@ -118,14 +119,18 @@ public async Task GetAccessTokenAsync( IEnumerable? scopes = null, bool useInteractiveBrowser = true, string? userId = null, - CancellationToken ct = default) + CancellationToken ct = default, + string? authorityHost = null) { // Access tokens are no longer cached to disk by this service. Token persistence and // silent re-acquisition are delegated entirely to the OS-protected MSAL persistent cache // (managed by MsalBrowserCredential). When forceRefresh is requested, the underlying // credential is configured to bypass MSAL's silent cache and acquire a fresh token. _logger.LogDebug("Authentication required for Agent 365 Tools"); - var token = await AuthenticateInteractivelyAsync(resourceUrl, tenantId, clientId, scopes, useInteractiveBrowser, loginHint: userId, forceRefresh: forceRefresh, ct: ct); + var token = await AuthenticateInteractivelyAsync( + resourceUrl, tenantId, clientId, scopes, useInteractiveBrowser, + loginHint: userId, forceRefresh: forceRefresh, ct: ct, + authorityHost: authorityHost); // Self-heal: validate the tid claim in the returned JWT against the requested tenant. // WAM may silently select a cached work account from a different tenant when multiple @@ -147,7 +152,10 @@ public async Task GetAccessTokenAsync( await ClearMsalCacheAsync(); // Retry once with the same parameters — MSAL disk cache is now empty so WAM // gets a clean slate and will either pick the correct account or prompt. - token = await AuthenticateInteractivelyAsync(resourceUrl, tenantId, clientId, scopes, useInteractiveBrowser, loginHint: userId, forceRefresh: forceRefresh, ct: ct); + token = await AuthenticateInteractivelyAsync( + resourceUrl, tenantId, clientId, scopes, useInteractiveBrowser, + loginHint: userId, forceRefresh: forceRefresh, ct: ct, + authorityHost: authorityHost); var retryTid = JwtHelper.TryDecodeClaim(token.AccessToken, "tid"); if (!string.IsNullOrWhiteSpace(retryTid) && !string.Equals(retryTid, tenantId, StringComparison.OrdinalIgnoreCase)) @@ -195,7 +203,8 @@ private async Task AuthenticateInteractivelyAsync( bool useInteractiveBrowser = false, string? loginHint = null, bool forceRefresh = false, - CancellationToken ct = default) + CancellationToken ct = default, + string? authorityHost = null) { // Declare variables outside try block so they're available in catch for logging string effectiveTenantId = tenantId ?? "unknown"; @@ -274,14 +283,16 @@ private async Task AuthenticateInteractivelyAsync( // Use MsalBrowserCredential which handles WAM on Windows and browser on other platforms _logger.LogDebug("Using interactive authentication (browser/WAM)..."); - credential = CreateBrowserCredential(effectiveClientId, effectiveTenantId, loginHint: loginHint, forceRefresh: forceRefresh); + credential = CreateBrowserCredentialForAuthority( + effectiveClientId, effectiveTenantId, authorityHost, loginHint, forceRefresh); } else { // Device code flow - works in all environments including SSH/remote sessions _logger.LogDebug("Using device code authentication..."); _logger.LogDebug("Please sign in with your Microsoft account"); - credential = CreateDeviceCodeCredential(effectiveClientId, effectiveTenantId); + credential = CreateDeviceCodeCredentialForAuthority( + effectiveClientId, effectiveTenantId, authorityHost); } var tokenRequestContext = new TokenRequestContext(scopes); @@ -295,7 +306,8 @@ private async Task AuthenticateInteractivelyAsync( _logger.LogWarning("Browser authentication is not supported on this platform, falling back to device code flow..."); _logger.LogDebug("Using device code authentication..."); _logger.LogDebug("Please sign in with your Microsoft account"); - var deviceCodeCredential = CreateDeviceCodeCredential(effectiveClientId, effectiveTenantId); + var deviceCodeCredential = CreateDeviceCodeCredentialForAuthority( + effectiveClientId, effectiveTenantId, authorityHost); tokenResult = await deviceCodeCredential.GetTokenAsync(tokenRequestContext, ct); } _logger.LogDebug("Authentication successful!"); @@ -368,6 +380,7 @@ private async Task AuthenticateInteractivelyAsync( /// Optional client ID for authentication. If not provided, uses PowerShell client ID /// Optional UPN/email to pre-select the account for WAM and silent acquisition. /// When provided, WAM will target this identity instead of the first cached account. + /// Optional OAuth authority host for sovereign cloud authentication. /// Access token with the requested scopes public async Task GetAccessTokenWithScopesAsync( string resourceAppId, @@ -376,7 +389,8 @@ public async Task GetAccessTokenWithScopesAsync( bool forceRefresh = false, string? clientId = null, bool useInteractiveBrowser = true, - string? userId = null) + string? userId = null, + string? authorityHost = null) { if (string.IsNullOrWhiteSpace(resourceAppId)) throw new ArgumentException("Resource App ID cannot be empty", nameof(resourceAppId)); @@ -388,7 +402,15 @@ public async Task GetAccessTokenWithScopesAsync( resourceAppId, string.Join(", ", scopes)); // Delegate to the consolidated GetAccessTokenAsync method - return await GetAccessTokenAsync(resourceAppId, tenantId, forceRefresh, clientId, scopes, useInteractiveBrowser, userId); + return await GetAccessTokenAsync( + resourceAppId, + tenantId, + forceRefresh, + clientId, + scopes, + useInteractiveBrowser, + userId, + authorityHost: authorityHost); } /// @@ -545,6 +567,18 @@ public bool ValidateScopesForResource(string resourceUrl, string? manifestPath = protected virtual TokenCredential CreateBrowserCredential(string clientId, string tenantId, string? loginHint = null, bool forceRefresh = false) => new MsalBrowserCredential(clientId, tenantId, redirectUri: null, _logger, loginHint: loginHint, forceRefresh: forceRefresh); + private TokenCredential CreateBrowserCredentialForAuthority( + string clientId, string tenantId, string? authorityHost, string? loginHint, bool forceRefresh) + { + var host = ConfigConstants.NormalizeAuthorityHost(authorityHost); + if (string.Equals(host, ConfigConstants.DefaultAuthorityHost, StringComparison.OrdinalIgnoreCase)) + return CreateBrowserCredential(clientId, tenantId, loginHint, forceRefresh); + + return new MsalBrowserCredential( + clientId, tenantId, redirectUri: null, _logger, authority: $"{host}/{tenantId}", + loginHint: loginHint, forceRefresh: forceRefresh); + } + /// /// Creates a DeviceCodeCredential configured for interactive device code authentication. /// This flow works in all environments including SSH, remote sessions, and platforms where @@ -552,12 +586,26 @@ protected virtual TokenCredential CreateBrowserCredential(string clientId, strin /// Protected virtual to allow substitution in tests. /// protected virtual TokenCredential CreateDeviceCodeCredential(string clientId, string tenantId) + => CreateDeviceCodeCredentialCore(clientId, tenantId, AzureAuthorityHosts.AzurePublicCloud); + + private TokenCredential CreateDeviceCodeCredentialForAuthority( + string clientId, string tenantId, string? authorityHost) + { + var host = ConfigConstants.NormalizeAuthorityHost(authorityHost); + if (string.Equals(host, ConfigConstants.DefaultAuthorityHost, StringComparison.OrdinalIgnoreCase)) + return CreateDeviceCodeCredential(clientId, tenantId); + + return CreateDeviceCodeCredentialCore(clientId, tenantId, new Uri(host)); + } + + private DeviceCodeCredential CreateDeviceCodeCredentialCore( + string clientId, string tenantId, Uri authorityHost) { return new DeviceCodeCredential(new DeviceCodeCredentialOptions { TenantId = tenantId, ClientId = clientId, - AuthorityHost = AzureAuthorityHosts.AzurePublicCloud, + AuthorityHost = authorityHost, TokenCachePersistenceOptions = new TokenCachePersistenceOptions { Name = AuthenticationConstants.ApplicationName diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/BootstrapConfigResolver.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/BootstrapConfigResolver.cs index 9899fbf0..416c5b1a 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/BootstrapConfigResolver.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/BootstrapConfigResolver.cs @@ -146,6 +146,9 @@ public async Task WriteBootstrapConfigAsync(Agent365Config config, string path) { ["tenantId"] = config.TenantId, ["clientAppId"] = config.ClientAppId, + ["environment"] = config.Environment, + ["graphBaseUrl"] = config.GraphBaseUrl, + ["authorityHost"] = config.AuthorityHost, ["agentIdentityDisplayName"] = config.AgentIdentityDisplayName, ["agentBlueprintDisplayName"] = config.AgentBlueprintDisplayName, ["agentDescription"] = config.AgentDescription, @@ -236,6 +239,27 @@ public async Task CheckAndBackupStaleConfigAsync(string configPath, Cancel } } + private async Task GetBootstrapEnvironmentAsync() + { + var configuredEnvironment = Environment.GetEnvironmentVariable("A365_ENVIRONMENT"); + if (!string.IsNullOrWhiteSpace(configuredEnvironment)) + return configuredEnvironment; + + try + { + var result = await _executor.ExecuteAsync( + "az", "cloud show --query name -o tsv", + captureOutput: true, suppressErrorLogging: true); + var cloudName = result.StandardOutput?.Trim(); + return string.IsNullOrWhiteSpace(cloudName) ? "prod" : cloudName; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to resolve current Azure CLI cloud; using the default environment."); + return "prod"; + } + } + // ── Private helpers ──────────────────────────────────────────────────────── private async Task BuildBootstrapConfigAsync( @@ -247,6 +271,9 @@ public async Task CheckAndBackupStaleConfigAsync(string configPath, Cancel if (tenantId is null) return null; + var environment = await GetBootstrapEnvironmentAsync(); + _graphApiService?.ConfigureCloudEndpoints(new Agent365Config { Environment = environment }); + var clientAppId = await SetupHelpers.ResolveBootstrapClientAppIdAsync( tenantId, _graphApiService, _logger, ct); if (string.IsNullOrWhiteSpace(clientAppId)) @@ -259,6 +286,9 @@ public async Task CheckAndBackupStaleConfigAsync(string configPath, Cancel { TenantId = tenantId, ClientAppId = clientAppId, + Environment = environment, + GraphBaseUrl = _graphApiService?.GraphBaseUrl ?? ConfigConstants.GetGraphBaseUrl(environment), + AuthorityHost = _graphApiService?.AuthorityHost ?? ConfigConstants.GetAuthorityHost(environment), AgentIdentityDisplayName = $"{agentName} Identity", AgentBlueprintDisplayName = $"{agentName} Blueprint", AgentDescription = agentName, @@ -290,6 +320,9 @@ public async Task CheckAndBackupStaleConfigAsync(string configPath, Cancel return null; } + var environment = await GetBootstrapEnvironmentAsync(); + _graphApiService?.ConfigureCloudEndpoints(new Agent365Config { Environment = environment }); + // Step 2: Resolve client app ID — prefer local a365.config.json when tenant matches. var resolvedClientAppId = await SetupHelpers.ResolveBootstrapClientAppIdAsync( tenantId, _graphApiService, _logger, ct, preferLocalConfig: true); @@ -379,6 +412,9 @@ public async Task CheckAndBackupStaleConfigAsync(string configPath, Cancel { TenantId = tenantId, ClientAppId = resolvedClientAppId, + Environment = environment, + GraphBaseUrl = _graphApiService?.GraphBaseUrl ?? ConfigConstants.GetGraphBaseUrl(environment), + AuthorityHost = _graphApiService?.AuthorityHost ?? ConfigConstants.GetAuthorityHost(environment), AgentIdentityDisplayName = $"{agentName} Identity", AgentBlueprintDisplayName = blueprintDisplayName, AgentDescription = agentName, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs index 4ff9603c..9a91095b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs @@ -165,7 +165,7 @@ public async Task EnsureValidClientAppAsync( missingDetails.Add("OAuth2 consent grant must be upgraded from per-user (Principal) to tenant-wide (AllPrincipals)"); if (needsWidsClaim) missingDetails.Add("'wids' optional claim missing on access tokens — without it, role detection always returns Unknown and the AllPrincipals grant phase silently skips, leaving the agent blueprint with no permissions granted on its service principal"); - var consentUrl = ClientAppValidationException.BuildAdminConsentUrl(clientAppId, tenantId); + var consentUrl = ClientAppValidationException.BuildAdminConsentUrl(clientAppId, tenantId, _graphApiService.AuthorityHost); var steps = new List { "Next Steps — Global Administrator action required:", @@ -289,7 +289,7 @@ public async Task EnsureValidClientAppAsync( // Step 4: Verify admin consent (requires AllPrincipals grant) if (!await ValidateAdminConsentAsync(clientAppId, tenantId, ct)) { - throw ClientAppValidationException.MissingAdminConsent(clientAppId, tenantId); + throw ClientAppValidationException.MissingAdminConsent(clientAppId, tenantId, _graphApiService.AuthorityHost); } // Step 5: Verify and fix redirect URIs @@ -1570,7 +1570,7 @@ private async Task ValidateAdminConsentAsync(string clientAppId, string te } // Print the admin consent URL so the user (or their admin) can fix this immediately - var consentUrl = ClientAppValidationException.BuildAdminConsentUrl(clientAppId, tenantId); + var consentUrl = ClientAppValidationException.BuildAdminConsentUrl(clientAppId, tenantId, _graphApiService.AuthorityHost); if (consentUrl != null) { _logger.LogInformation("To grant tenant-wide admin consent, share this URL with a Global Administrator:"); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs index 8a20758a..3c0ef8c1 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs @@ -120,8 +120,10 @@ public async Task EnsureBlueprintPermissionGrantAsync( var result = await EnsureScopeOnGrantAsync(httpClient, grant, TargetScope, cancellationToken); if (result == ScopeGrantResult.NeedsAdminConsent) { - var scopeUri = Uri.EscapeDataString($"{AuthenticationConstants.MicrosoftGraphResourceUri}/{TargetScope}"); - var consentUrl = $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={callingAppId}&scope={scopeUri}"; + var graphResourceUri = GraphApiConstants.GetResource(_graphService.GraphBaseUrl).TrimEnd('/'); + var scopeUri = Uri.EscapeDataString($"{graphResourceUri}/{TargetScope}"); + var consentBaseUrl = ConfigConstants.BuildAdminConsentEndpointUrl(_graphService.AuthorityHost, tenantId); + var consentUrl = $"{consentBaseUrl}?client_id={callingAppId}&scope={scopeUri}"; _logger.LogError( "The existing permission grant could not be updated to include '{Scope}'. " + "An administrator ({Roles}) must grant admin consent. " + @@ -192,7 +194,7 @@ public async Task EnsureBlueprintPermissionGrantAsync( // Create new service principal _logger.LogDebug("Creating service principal for app {AppId}", appId); - var createSpUrl = $"{GraphApiConstants.BaseUrl}/v1.0/servicePrincipals"; + var createSpUrl = $"{_graphService.GraphBaseUrl}/v1.0/servicePrincipals"; var createBody = new { appId = appId @@ -350,7 +352,7 @@ private bool IsCaeTokenError(string errorJson) { try { - var url = $"{GraphApiConstants.BaseUrl}/v1.0/servicePrincipals?$filter=appId eq '{appId}'"; + var url = $"{_graphService.GraphBaseUrl}/v1.0/servicePrincipals?$filter=appId eq '{appId}'"; using var response = await httpClient.GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) @@ -391,7 +393,7 @@ private bool IsCaeTokenError(string errorJson) try { var filter = $"clientId eq '{clientId}' and resourceId eq '{resourceId}' and consentType eq '{AllPrincipalsConsentType}'"; - var url = $"{GraphApiConstants.BaseUrl}/v1.0/oauth2PermissionGrants?$filter={Uri.EscapeDataString(filter)}"; + var url = $"{_graphService.GraphBaseUrl}/v1.0/oauth2PermissionGrants?$filter={Uri.EscapeDataString(filter)}"; using var response = await httpClient.GetAsync(url, cancellationToken); @@ -458,7 +460,7 @@ private async Task EnsureScopeOnGrantAsync( _logger.LogDebug(" Updating grant {GrantId} to include scope: {Scope}", grantId, scopeToAdd); // Update the grant - var updateUrl = $"{GraphApiConstants.BaseUrl}/v1.0/oauth2PermissionGrants/{grantId}"; + var updateUrl = $"{_graphService.GraphBaseUrl}/v1.0/oauth2PermissionGrants/{grantId}"; var updateBody = new { scope = newScope @@ -509,7 +511,7 @@ private async Task CreateGrantAsync( { try { - var createUrl = $"{GraphApiConstants.BaseUrl}/v1.0/oauth2PermissionGrants"; + var createUrl = $"{_graphService.GraphBaseUrl}/v1.0/oauth2PermissionGrants"; var createBody = new { clientId = clientId, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index 8ed1388f..c9ce44eb 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -27,6 +27,11 @@ public class GraphApiService private readonly IAuthenticationService _authService; private readonly RetryHelper _retryHelper; private string _graphBaseUrl; + private string _authorityHost = ConfigConstants.DefaultAuthorityHost; + private string? GraphBaseUrlOverride => + string.Equals(_graphBaseUrl, GraphApiConstants.BaseUrl, StringComparison.OrdinalIgnoreCase) ? null : _graphBaseUrl; + private string? AuthorityHostOverride => + string.Equals(_authorityHost, ConfigConstants.DefaultAuthorityHost, StringComparison.OrdinalIgnoreCase) ? null : _authorityHost; // Login hint resolved once per GraphApiService instance. // Used to direct MSAL/WAM to the correct identity, preventing the Windows default @@ -62,7 +67,29 @@ public class GraphApiService public string GraphBaseUrl { get => _graphBaseUrl; - set => _graphBaseUrl = string.IsNullOrWhiteSpace(value) ? GraphApiConstants.BaseUrl : value; + set => _graphBaseUrl = ConfigConstants.NormalizeGraphBaseUrl(value); + } + + /// + /// OAuth authority host used for token acquisition. + /// + public string AuthorityHost + { + get => _authorityHost; + set => _authorityHost = ConfigConstants.NormalizeAuthorityHost(value); + } + + /// + /// Applies cloud endpoints and the custom client app from a loaded project config. + /// + public void ConfigureCloudEndpoints(Agent365Config config) + { + ArgumentNullException.ThrowIfNull(config); + + GraphBaseUrl = ConfigConstants.GetGraphBaseUrl(config.Environment, config.GraphBaseUrl); + AuthorityHost = ConfigConstants.GetAuthorityHost(config.Environment, config.AuthorityHost); + if (!string.IsNullOrWhiteSpace(config.ClientAppId)) + CustomClientAppId = config.ClientAppId; } // Lightweight wrapper to surface HTTP status, reason and body to callers @@ -88,7 +115,7 @@ public GraphApiService(ILogger logger, CommandExecutor executor _retryHelper = retryHelper ?? new RetryHelper(_logger); // Default: try az CLI first (if present), fall back to JWT cache in AuthenticationService. _loginHintResolver = loginHintResolver ?? (() => ResolveLoginHintWithFallbackAsync(authService)); - _graphBaseUrl = string.IsNullOrWhiteSpace(graphBaseUrl) ? GraphApiConstants.BaseUrl : graphBaseUrl; + _graphBaseUrl = ConfigConstants.NormalizeGraphBaseUrl(graphBaseUrl); _agentRegistryRetryDelay = agentRegistryRetryDelay ?? TimeSpan.FromSeconds(30); } @@ -146,7 +173,9 @@ public GraphApiService(ILogger logger, CommandExecutor executor { var resource = GraphApiConstants.GetResource(_graphBaseUrl); var loginHint = await _loginHintResolver(); - var token = await _authService.GetAccessTokenAsync(resource, tenantId, forceRefresh: forceRefresh, userId: loginHint, ct: ct); + var token = await _authService.GetAccessTokenAsync( + resource, tenantId, forceRefresh: forceRefresh, userId: loginHint, ct: ct, + authorityHost: AuthorityHostOverride); if (!string.IsNullOrWhiteSpace(token)) { _logger.LogDebug("Graph API access token acquired successfully"); @@ -210,7 +239,9 @@ private async Task EnsureGraphHeadersAsync(string tenantId, bool forceRefr "Acquiring Graph token via token provider (clientId: {AppId}, scopes: {Scopes})", CustomClientAppId, string.Join(", ", effectiveScopes)); var loginHint = await ResolveLoginHintAsync(); - token = await _tokenProvider.GetMgGraphAccessTokenAsync(tenantId, effectiveScopes, false, CustomClientAppId, ct, loginHint, forceRefresh); + token = await _tokenProvider.GetMgGraphAccessTokenAsync( + tenantId, effectiveScopes, false, CustomClientAppId, ct, loginHint, forceRefresh, + GraphBaseUrlOverride, AuthorityHostOverride); if (string.IsNullOrWhiteSpace(token)) { @@ -1177,7 +1208,7 @@ public async Task CreatePrincipalOauth2PermissionGrantAsync( clientAppId: CustomClientAppId, ct: ct, loginHint: loginHint, - forceRefresh: false); + forceRefresh: false, graphBaseUrl: GraphBaseUrlOverride, authorityHost: AuthorityHostOverride); if (string.IsNullOrWhiteSpace(token)) return Models.RoleCheckResult.Unknown; @@ -1377,7 +1408,7 @@ public async Task CreatePrincipalOauth2PermissionGrantAsync( // Use .default so the token includes all permissions consented on the "Agent 365 CLI" app, // including AgentRegistration.ReadWrite.All, without enumerating scopes explicitly. IEnumerable? registrationScopes = _tokenProvider != null - ? [$"{Constants.AuthenticationConstants.MicrosoftGraphResourceUri}/.default"] + ? [$"{_graphBaseUrl}/.default"] : null; var now = DateTimeOffset.UtcNow.ToString("o"); @@ -1493,10 +1524,10 @@ public virtual async Task DeleteAgentRegistrationAsync( // Use .default so the token includes all permissions consented on the "Agent 365 CLI" app, // including AgentRegistration.ReadWrite.All, without enumerating scopes explicitly. IEnumerable? scopes = _tokenProvider != null - ? [$"{Constants.AuthenticationConstants.MicrosoftGraphResourceUri}/.default"] + ? [$"{_graphBaseUrl}/.default"] : null; - _logger.LogInformation("DELETE https://graph.microsoft.com{Path}/{RegistrationId}", AgentRegistrationsPath, registrationId); + _logger.LogInformation("DELETE {GraphBaseUrl}{Path}/{RegistrationId}", _graphBaseUrl, AgentRegistrationsPath, registrationId); return await GraphDeleteAsync( tenantId, @@ -1519,11 +1550,11 @@ public virtual async Task DeleteAgentRegistrationAsync( CancellationToken ct = default) { IEnumerable? scopes = _tokenProvider != null - ? [$"{Constants.AuthenticationConstants.MicrosoftGraphResourceUri}/.default"] + ? [$"{_graphBaseUrl}/.default"] : null; var path = $"{AgentRegistrationsPath}/{Uri.EscapeDataString(registrationId)}"; - _logger.LogDebug("GET https://graph.microsoft.com{Path}", path); + _logger.LogDebug("GET {GraphBaseUrl}{Path}", _graphBaseUrl, path); try { @@ -1558,7 +1589,7 @@ public virtual async Task DeleteAgentInstanceAsync( ? [Constants.AuthenticationConstants.AgentInstanceReadWriteAllScope] : null; - _logger.LogInformation("DELETE https://graph.microsoft.com/beta/agentRegistry/agentInstances/{InstanceId}", instanceId); + _logger.LogInformation("DELETE {GraphBaseUrl}/beta/agentRegistry/agentInstances/{InstanceId}", _graphBaseUrl, instanceId); return await GraphDeleteAsync( tenantId, @@ -1588,7 +1619,7 @@ public virtual async Task DeleteAgentInstanceAsync( _logger.LogDebug("Acquiring blueprint access token via client credentials (CorrelationId: {Id})", effectiveCorrelationId); using var httpClient = HttpClientFactory.CreateAuthenticatedClient(correlationId: effectiveCorrelationId); - var tokenEndpoint = $"https://login.microsoftonline.com/{tenantId}/oauth2/v2.0/token"; + var tokenEndpoint = ConfigConstants.BuildTokenEndpointUrl(_authorityHost, tenantId); const int maxRetries = 12; const int baseDelaySeconds = 5; @@ -1600,7 +1631,7 @@ public virtual async Task DeleteAgentInstanceAsync( { new KeyValuePair("client_id", clientId), new KeyValuePair("client_secret", clientSecret), - new KeyValuePair("scope", "https://graph.microsoft.com/.default"), + new KeyValuePair("scope", $"{_graphBaseUrl}/.default"), new KeyValuePair("grant_type", "client_credentials"), }); @@ -1690,7 +1721,8 @@ public virtual async Task DeleteAgentInstanceAsync( { var loginHint = await ResolveLoginHintAsync(); var previewToken = await _tokenProvider.GetMgGraphAccessTokenAsync( - tenantId, scopes, false, CustomClientAppId, ct, loginHint); + tenantId, scopes, false, CustomClientAppId, ct, loginHint, + graphBaseUrl: GraphBaseUrlOverride, authorityHost: AuthorityHostOverride); if (!string.IsNullOrWhiteSpace(previewToken)) { var scp = TryDecodeTokenClaim(previewToken, "scp"); @@ -1717,15 +1749,15 @@ public virtual async Task DeleteAgentInstanceAsync( { body["sponsors@odata.bind"] = new JsonArray { - $"https://graph.microsoft.com/v1.0/users/{currentUserId}" + $"{_graphBaseUrl}/v1.0/users/{currentUserId}" }; body["owners@odata.bind"] = new JsonArray { - $"https://graph.microsoft.com/v1.0/users/{currentUserId}" + $"{_graphBaseUrl}/v1.0/users/{currentUserId}" }; } - _logger.LogDebug("POST https://graph.microsoft.com/beta/servicePrincipals/Microsoft.Graph.AgentIdentity (delegated)"); + _logger.LogDebug("POST {GraphBaseUrl}/beta/servicePrincipals/Microsoft.Graph.AgentIdentity (delegated)", _graphBaseUrl); _logger.LogDebug("Body: {Body}", body.ToJsonString()); // Use GraphPostWithResponseAsync so we can log the full error body on failure. @@ -1827,13 +1859,13 @@ public virtual async Task DeleteAgentInstanceAsync( { body["sponsors@odata.bind"] = new JsonArray { - $"https://graph.microsoft.com/v1.0/users/{currentUserId}" + $"{_graphBaseUrl}/v1.0/users/{currentUserId}" }; } const int maxAttempts = 5; const int baseDelaySeconds = 5; - const string agentIdentityUrl = "https://graph.microsoft.com/beta/serviceprincipals/Microsoft.Graph.AgentIdentity"; + var agentIdentityUrl = $"{_graphBaseUrl}/beta/serviceprincipals/Microsoft.Graph.AgentIdentity"; for (int attempt = 0; attempt < maxAttempts; attempt++) { 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 5746982d..d827c5cc 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/AdminConsentHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/AdminConsentHelper.cs @@ -78,12 +78,14 @@ public static async Task PollAdminConsentAsync( string scopeDescriptor, int timeoutSeconds, int intervalSeconds, - CancellationToken ct) + CancellationToken ct, + string? graphBaseUrl = null) { if (BypassConsentChecksForTests) return true; var start = DateTime.UtcNow; + var baseUrl = ConfigConstants.NormalizeGraphBaseUrl(graphBaseUrl); string? spId = null; int lastProgressReportSeconds = 0; @@ -107,7 +109,7 @@ public static async Task PollAdminConsentAsync( if (spId == null) { var spResult = await executor.ExecuteAsync("az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '{appId}'\"", + $"rest --method GET --url \"{baseUrl}/v1.0/servicePrincipals?$filter=appId eq '{appId}'\"", captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); if (spResult.Success) @@ -128,7 +130,7 @@ public static async Task PollAdminConsentAsync( if (spId != null) { var grants = await executor.ExecuteAsync("az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spId}'\"", + $"rest --method GET --url \"{baseUrl}/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spId}'\"", captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); if (grants.Success) @@ -387,7 +389,8 @@ public static async Task CheckConsentExistsAsync( CancellationToken ct, string? consentType = null, string? blueprintSpObjectId = null, - string? resourceSpObjectId = null) + string? resourceSpObjectId = null, + string? graphBaseUrl = null) { if (BypassConsentChecksForTests) return true; @@ -406,11 +409,12 @@ public static async Task CheckConsentExistsAsync( try { + var baseUrl = ConfigConstants.NormalizeGraphBaseUrl(graphBaseUrl); // Skip SP lookups when the caller already resolved them in Phase 1 — each az rest // call costs ~1.7s due to az's Python startup. The orchestrator passes pre-resolved // IDs to cut 4-resource setup pre-check from ~21s to ~7s. var blueprintSpId = blueprintSpObjectId - ?? await LookupSpObjectIdByAppIdAsync(executor, blueprintAppId, ct); + ?? await LookupSpObjectIdByAppIdAsync(executor, blueprintAppId, baseUrl, ct); if (blueprintSpId == null) { logger.LogDebug("Blueprint SP not found for appId {BlueprintAppId} via az rest", blueprintAppId); @@ -418,7 +422,7 @@ public static async Task CheckConsentExistsAsync( } var resourceSpId = resourceSpObjectId - ?? await LookupSpObjectIdByAppIdAsync(executor, resourceAppId, ct); + ?? await LookupSpObjectIdByAppIdAsync(executor, resourceAppId, baseUrl, ct); if (resourceSpId == null) { logger.LogDebug("Resource SP not found for appId {ResourceAppId} via az rest", resourceAppId); @@ -430,7 +434,7 @@ public static async Task CheckConsentExistsAsync( filter += $" and consentType eq '{consentType}'"; var grantsResult = await executor.ExecuteAsync("az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter={Uri.EscapeDataString(filter)}\"", + $"rest --method GET --url \"{baseUrl}/v1.0/oauth2PermissionGrants?$filter={Uri.EscapeDataString(filter)}\"", captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); if (!grantsResult.Success) @@ -480,10 +484,10 @@ public static async Task CheckConsentExistsAsync( } private static async Task LookupSpObjectIdByAppIdAsync( - CommandExecutor executor, string appId, CancellationToken ct) + CommandExecutor executor, string appId, string graphBaseUrl, CancellationToken ct) { var spResult = await executor.ExecuteAsync("az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '{appId}'&$select=id\"", + $"rest --method GET --url \"{graphBaseUrl}/v1.0/servicePrincipals?$filter=appId eq '{appId}'&$select=id\"", captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); if (!spResult.Success) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/EndpointHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/EndpointHelper.cs index 3dc521c0..c33ee3e9 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/EndpointHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/EndpointHelper.cs @@ -110,7 +110,8 @@ private static string ExtractBlueprintIdSuffix(string blueprintId) public static string GetCreateEndpointUrl(string environment) { // Check for custom endpoint in environment variable first - var customEndpoint = Environment.GetEnvironmentVariable($"A365_CREATE_ENDPOINT_{environment?.ToUpper()}"); + var customEndpoint = Environment.GetEnvironmentVariable( + $"A365_CREATE_ENDPOINT_{ConfigConstants.NormalizeEnvironmentKey(environment)}"); if (!string.IsNullOrEmpty(customEndpoint)) return customEndpoint; @@ -128,7 +129,8 @@ public static string GetCreateEndpointUrl(string environment) public static string GetDeleteEndpointUrl(string environment) { // Check for custom endpoint in environment variable first - var customEndpoint = Environment.GetEnvironmentVariable($"A365_DELETE_ENDPOINT_{environment?.ToUpper()}"); + var customEndpoint = Environment.GetEnvironmentVariable( + $"A365_DELETE_ENDPOINT_{ConfigConstants.NormalizeEnvironmentKey(environment)}"); if (!string.IsNullOrEmpty(customEndpoint)) return customEndpoint; @@ -146,7 +148,8 @@ public static string GetDeleteEndpointUrl(string environment) public static string GetDeploymentEnvironment(string environment) { // Check for custom deployment environment in environment variable first - var customDeploymentEnvironment = Environment.GetEnvironmentVariable($"A365_DEPLOYMENT_ENVIRONMENT_{environment?.ToUpper()}"); + var customDeploymentEnvironment = Environment.GetEnvironmentVariable( + $"A365_DEPLOYMENT_ENVIRONMENT_{ConfigConstants.NormalizeEnvironmentKey(environment)}"); if (!string.IsNullOrEmpty(customDeploymentEnvironment)) return customDeploymentEnvironment; @@ -164,7 +167,8 @@ public static string GetDeploymentEnvironment(string environment) public static string GetClusterCategory(string environment) { // Check for custom cluster category in environment variable first - var customClusterCategory = Environment.GetEnvironmentVariable($"A365_CLUSTER_CATEGORY_{environment?.ToUpper()}"); + var customClusterCategory = Environment.GetEnvironmentVariable( + $"A365_CLUSTER_CATEGORY_{ConfigConstants.NormalizeEnvironmentKey(environment)}"); if (!string.IsNullOrEmpty(customClusterCategory)) return customClusterCategory; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs index 533cd55e..1c7b8875 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs @@ -28,6 +28,9 @@ public sealed class InteractiveGraphAuthService private readonly string _clientAppId; private readonly Func? _credentialFactory; private readonly Func> _loginHintResolver; + private readonly string _graphBaseUrl; + private readonly string _authorityHost; + private readonly string[] _requiredScopes; private GraphServiceClient? _cachedClient; private string? _cachedTenantId; @@ -37,7 +40,9 @@ public InteractiveGraphAuthService( ILogger logger, string clientAppId, Func? credentialFactory = null, - Func>? loginHintResolver = null) + Func>? loginHintResolver = null, + string? graphBaseUrl = null, + string? authorityHost = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -58,6 +63,14 @@ public InteractiveGraphAuthService( _clientAppId = clientAppId; _credentialFactory = credentialFactory; _loginHintResolver = loginHintResolver ?? ResolveAzLoginHintAsync; + _graphBaseUrl = ConfigConstants.NormalizeGraphBaseUrl(graphBaseUrl); + _authorityHost = ConfigConstants.NormalizeAuthorityHost(authorityHost); + _requiredScopes = RequiredScopes + .Select(scope => scope.Replace( + AuthenticationConstants.MicrosoftGraphResourceUri, + _graphBaseUrl, + StringComparison.OrdinalIgnoreCase)) + .ToArray(); } /// @@ -84,7 +97,7 @@ public async Task GetAuthenticatedGraphClientAsync( // Eagerly acquire a token so authentication failures are detected here rather than // surfacing later from inside GraphServiceClient's lazy token acquisition. // Resolve credential inside try/catch so factory exceptions are wrapped consistently. - var tokenContext = new TokenRequestContext(RequiredScopes); + var tokenContext = new TokenRequestContext(_requiredScopes); TokenCredential? credential = null; try { @@ -93,7 +106,13 @@ public async Task GetAuthenticatedGraphClientAsync( // Resolve credential: use injected factory (for tests) or default MsalBrowserCredential credential = _credentialFactory?.Invoke(_clientAppId, tenantId) - ?? new MsalBrowserCredential(_clientAppId, tenantId, redirectUri: null, _logger, loginHint: loginHint); + ?? new MsalBrowserCredential( + _clientAppId, + tenantId, + redirectUri: null, + _logger, + authority: $"{_authorityHost}/{tenantId}", + loginHint: loginHint); await credential.GetTokenAsync(tokenContext, cancellationToken); } @@ -137,7 +156,8 @@ public async Task GetAuthenticatedGraphClientAsync( // from GraphServiceClient will hit the silent cache without re-prompting. _logger.LogInformation("Successfully authenticated to Microsoft Graph!"); - var graphClient = new GraphServiceClient(credential!, RequiredScopes); + var graphClient = new GraphServiceClient(credential!, _requiredScopes); + graphClient.RequestAdapter.BaseUrl = _graphBaseUrl; _cachedClient = graphClient; _cachedTenantId = tenantId; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/IMicrosoftGraphTokenProvider.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/IMicrosoftGraphTokenProvider.cs index 1974f184..006432b2 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/IMicrosoftGraphTokenProvider.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/IMicrosoftGraphTokenProvider.cs @@ -27,5 +27,7 @@ public interface IMicrosoftGraphTokenProvider string? clientAppId = null, CancellationToken ct = default, string? loginHint = null, - bool forceRefresh = false); + bool forceRefresh = false, + string? graphBaseUrl = null, + string? authorityHost = null); } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs index ce490f65..1da8aa52 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs @@ -93,9 +93,13 @@ public MicrosoftGraphTokenProvider( string? clientAppId = null, CancellationToken ct = default, string? loginHint = null, - bool forceRefresh = false) + bool forceRefresh = false, + string? graphBaseUrl = null, + string? authorityHost = null) { - var validatedScopes = ValidateAndPrepareScopes(scopes); + var resolvedGraphBaseUrl = ConfigConstants.NormalizeGraphBaseUrl(graphBaseUrl); + var resolvedAuthorityHost = ConfigConstants.NormalizeAuthorityHost(authorityHost); + var validatedScopes = ValidateAndPrepareScopes(scopes, resolvedGraphBaseUrl); ValidateTenantId(tenantId); if (!string.IsNullOrWhiteSpace(clientAppId)) @@ -149,12 +153,23 @@ public MicrosoftGraphTokenProvider( // and WAM on Windows authenticates via the OS broker (no browser, CAP-compliant). var token = MsalTokenAcquirerOverride != null ? await MsalTokenAcquirerOverride(tenantId, validatedScopes, clientAppId, ct) - : await AcquireGraphTokenViaMsalAsync(tenantId, validatedScopes, clientAppId, ct, loginHint, forceRefresh); + : await AcquireGraphTokenViaMsalAsync( + tenantId, validatedScopes, clientAppId, resolvedAuthorityHost, ct, loginHint, + forceRefresh); // Fall back to PowerShell Connect-MgGraph if MSAL is unavailable (e.g. no clientAppId) // or fails for any reason. if (string.IsNullOrWhiteSpace(token)) { + if (!string.Equals(resolvedAuthorityHost, ConfigConstants.DefaultAuthorityHost, StringComparison.OrdinalIgnoreCase) + || !string.Equals(resolvedGraphBaseUrl, GraphApiConstants.BaseUrl, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError( + "MSAL Graph authentication failed for the configured cloud. " + + "PowerShell fallback is available only for commercial Graph and authority endpoints."); + return null; + } + _logger.LogDebug("MSAL token acquisition failed, falling back to PowerShell Connect-MgGraph..."); var script = BuildPowerShellScript(tenantId, validatedScopes, useDeviceCode, clientAppId); @@ -171,7 +186,8 @@ public MicrosoftGraphTokenProvider( _logger.LogWarning( "PowerShell interactive browser authentication failed (Conditional Access Policy or embedded terminal). " + "Retrying with device code authentication..."); - var deviceCodeScript = BuildPowerShellScript(tenantId, validatedScopes, useDeviceCode: true, clientAppId); + var deviceCodeScript = BuildPowerShellScript( + tenantId, validatedScopes, useDeviceCode: true, clientAppId); var deviceCodeResult = await ExecuteWithFallbackAsync(deviceCodeScript, ct); token = ProcessResult(deviceCodeResult); } @@ -231,7 +247,9 @@ public MicrosoftGraphTokenProvider( // Retry once — do not recurse; use the underlying acquirer directly. var retryToken = MsalTokenAcquirerOverride != null ? await MsalTokenAcquirerOverride(tenantId, validatedScopes, clientAppId, ct) - : await AcquireGraphTokenViaMsalAsync(tenantId, validatedScopes, clientAppId, ct, loginHint, forceRefresh: true); + : await AcquireGraphTokenViaMsalAsync( + tenantId, validatedScopes, clientAppId, resolvedAuthorityHost, ct, loginHint, + forceRefresh: true); if (!string.IsNullOrWhiteSpace(retryToken)) token = retryToken; @@ -264,13 +282,16 @@ public MicrosoftGraphTokenProvider( } } - private string[] ValidateAndPrepareScopes(IEnumerable scopes) + private string[] ValidateAndPrepareScopes(IEnumerable scopes, string graphBaseUrl) { if (scopes == null) throw new ArgumentNullException(nameof(scopes)); var validScopes = scopes .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s.Contains("://", StringComparison.Ordinal) + ? s + : $"{graphBaseUrl}/{s.TrimStart('/')}") .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -307,7 +328,8 @@ private static void ValidateClientAppId(string clientAppId) nameof(clientAppId)); } - private static string BuildPowerShellScript(string tenantId, string[] scopes, bool useDeviceCode, string? clientAppId = null) + private static string BuildPowerShellScript( + string tenantId, string[] scopes, bool useDeviceCode, string? clientAppId = null) { var escapedTenantId = CommandStringHelper.EscapePowerShellString(tenantId); var scopesArray = BuildScopesArray(scopes); @@ -387,6 +409,7 @@ private async Task ExecuteWithFallbackAsync( string tenantId, string[] scopes, string? clientAppId, + string authorityHost, CancellationToken ct, string? loginHint = null, bool forceRefresh = false) @@ -399,15 +422,16 @@ private async Task ExecuteWithFallbackAsync( try { - // MSAL requires fully-qualified scope URIs; PS Connect-MgGraph handles this internally. - var fullScopes = scopes - .Select(s => s.Contains("://", StringComparison.Ordinal) ? s : $"https://graph.microsoft.com/{s}") - .ToArray(); - - _logger.LogDebug("Acquiring Graph token via MSAL for scopes: {Scopes}", string.Join(", ", fullScopes)); - - var msalCredential = new MsalBrowserCredential(clientAppId, tenantId, logger: _logger, loginHint: loginHint, forceRefresh: forceRefresh); - var tokenResult = await msalCredential.GetTokenAsync(new TokenRequestContext(fullScopes), ct); + _logger.LogDebug("Acquiring Graph token via MSAL for scopes: {Scopes}", string.Join(", ", scopes)); + + var msalCredential = new MsalBrowserCredential( + clientAppId, + tenantId, + logger: _logger, + authority: $"{authorityHost}/{tenantId}", + loginHint: loginHint, + forceRefresh: forceRefresh); + var tokenResult = await msalCredential.GetTokenAsync(new TokenRequestContext(scopes), ct); if (string.IsNullOrWhiteSpace(tokenResult.Token)) return null; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs index 40baede1..e915264d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs @@ -45,6 +45,7 @@ public sealed class MsalBrowserCredential : TokenCredential private readonly IntPtr _windowHandle; private readonly string? _loginHint; private readonly bool _forceRefresh; + private readonly string _authorityHost; // Shared persistent cache helper - initialized once and reused across all instances. // This is the key to reducing multiple WAM prompts during setup operations. @@ -89,8 +90,7 @@ public sealed class MsalBrowserCredential : TokenCredential /// The redirect URI for authentication callbacks. /// Optional logger for diagnostic output. /// Whether to use WAM on Windows. Default is true. - /// Optional authority URL. When provided, overrides the default AzurePublic authority. - /// Use this for government clouds (e.g., "https://login.microsoftonline.us/{tenantId}"). + /// Optional authority URL. When provided, overrides the default public-cloud authority. /// Optional UPN/email to pre-select the account for silent acquisition and interactive auth. /// When provided, WAM and silent auth will target this identity instead of the first cached account. public MsalBrowserCredential( @@ -119,6 +119,13 @@ public MsalBrowserCredential( _loginHint = loginHint; _forceRefresh = forceRefresh; + // Capture the login authority host so consent URLs surfaced later (BuildAdminConsentUrl) + // target the same cloud this credential authenticates against. Falls back to commercial + // when no explicit sovereign authority was supplied. + _authorityHost = Uri.TryCreate(authority, UriKind.Absolute, out var authorityUri) + ? authorityUri.GetLeftPart(UriPartial.Authority) + : ConfigConstants.DefaultAuthorityHost; + // Get window handle for WAM on Windows // Try multiple sources: console window, foreground window, or desktop window _windowHandle = IntPtr.Zero; @@ -576,7 +583,7 @@ internal static bool IsWamDeclinedScopesError(MsalException ex) /// private void LogConsentRequiredAndThrow(Exception inner) { - var consentUrl = ClientAppValidationException.BuildAdminConsentUrl(_clientAppId, _tenantId); + var consentUrl = ClientAppValidationException.BuildAdminConsentUrl(_clientAppId, _tenantId, _authorityHost); _logger?.LogWarning("Admin consent has not been granted for this application."); _logger?.LogWarning("An administrator must grant tenant-wide consent to proceed."); if (consentUrl != null) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/WidsOptionalClaimRequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/WidsOptionalClaimRequirementCheck.cs index b2dec229..8f711b3e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/WidsOptionalClaimRequirementCheck.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/WidsOptionalClaimRequirementCheck.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Extensions.Logging; @@ -86,7 +87,8 @@ private async Task CheckImplementationAsync(Agent365Conf return RequirementCheckResult.Success(details: $"'wids' is present on accessToken optionalClaims for {config.ClientAppId}"); } - var manualPatch = BuildManualPatchInstructions(config.ClientAppId, config.TenantId); + var graphBaseUrl = ConfigConstants.GetGraphBaseUrl(config.Environment, config.GraphBaseUrl); + var manualPatch = BuildManualPatchInstructions(config.ClientAppId, config.TenantId, graphBaseUrl); return RequirementCheckResult.Failure( errorMessage: $"Client app {config.ClientAppId} is missing the 'wids' optional claim on accessToken. " + @@ -99,7 +101,7 @@ private async Task CheckImplementationAsync(Agent365Conf "the orchestrator collapses Unknown to 'not GA' and skips Phase 2b."); } - private static string BuildManualPatchInstructions(string clientAppId, string tenantId) + private static string BuildManualPatchInstructions(string clientAppId, string tenantId, string graphBaseUrl) { // Two-line remediation: portal path for humans, raw `az rest` for scriptable runs. // Both add { name: "wids", essential: false } to optionalClaims.accessToken. @@ -107,7 +109,7 @@ private static string BuildManualPatchInstructions(string clientAppId, string te "Add the 'wids' optional claim on the client app's access tokens. Options:\n" + $" 1. Portal: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/TokenConfiguration/appId/{clientAppId} → 'Add optional claim' → Token type 'Access' → check 'wids' → Add.\n" + " 2. Or run as an Application Administrator / Global Administrator:\n" + - $" az rest --method PATCH --url \"https://graph.microsoft.com/v1.0/applications(appId='{clientAppId}')\" --headers \"Content-Type=application/json\" --body \"{{\\\"optionalClaims\\\":{{\\\"accessToken\\\":[{{\\\"name\\\":\\\"wids\\\",\\\"essential\\\":false,\\\"additionalProperties\\\":[]}}]}}}}\"\n" + + $" az rest --method PATCH --url \"{graphBaseUrl}/v1.0/applications(appId='{clientAppId}')\" --headers \"Content-Type=application/json\" --body \"{{\\\"optionalClaims\\\":{{\\\"accessToken\\\":[{{\\\"name\\\":\\\"wids\\\",\\\"essential\\\":false,\\\"additionalProperties\\\":[]}}]}}}}\"\n" + "After updating, sign out and back in (az logout && az login) so the next token carries the new claim, then re-run 'a365 setup requirements'."; } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/TeamsGraphBackendConfigurator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/TeamsGraphBackendConfigurator.cs index 6b2b2838..3ce8cb2e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/TeamsGraphBackendConfigurator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/TeamsGraphBackendConfigurator.cs @@ -65,6 +65,7 @@ public TeamsGraphBackendConfigurator( var createEndpointUrl = EndpointHelper.GetCreateEndpointUrl(config.Environment); var audience = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + var authorityHost = ConfigConstants.GetAuthorityHost(config.Environment, config.AuthorityHost); _logger.LogDebug("Create endpoint URL: {Url}", createEndpointUrl); @@ -79,7 +80,13 @@ public TeamsGraphBackendConfigurator( { bool forceRefresh = attempt > 0; - var authToken = await _authService.GetAccessTokenAsync(audience, tenantId, forceRefresh: forceRefresh, userId: currentUser, ct: ct); + var authToken = await _authService.GetAccessTokenAsync( + audience, + tenantId, + forceRefresh: forceRefresh, + userId: currentUser, + ct: ct, + authorityHost: authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -188,6 +195,7 @@ public async Task ClearBackendConfigurationAsync( var deleteEndpointUrl = EndpointHelper.GetDeleteEndpointUrl(config.Environment); var audience = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + var authorityHost = ConfigConstants.GetAuthorityHost(config.Environment, config.AuthorityHost); _logger.LogDebug("Delete endpoint URL: {Url}", deleteEndpointUrl); @@ -201,7 +209,13 @@ public async Task ClearBackendConfigurationAsync( { bool forceRefresh = attempt > 0; - var authToken = await _authService.GetAccessTokenAsync(audience, tenantId, forceRefresh: forceRefresh, userId: currentUser, ct: ct); + var authToken = await _authService.GetAccessTokenAsync( + audience, + tenantId, + forceRefresh: forceRefresh, + userId: currentUser, + ct: ct, + authorityHost: authorityHost); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AzRestConsentRunnerTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AzRestConsentRunnerTests.cs index 51d7ba68..26098e5f 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AzRestConsentRunnerTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AzRestConsentRunnerTests.cs @@ -156,12 +156,14 @@ public async Task NoExistingGrant_PostedWithNewGrantBody() .Returns(Task.FromResult(new CommandResult { ExitCode = 0 })); var (attempted, succeeded) = await AzRestConsentRunner.TryRunAsync( - _executor, BlueprintSpId, new[] { ObsSpec() }, _logger, ct: default); + _executor, BlueprintSpId, new[] { ObsSpec() }, _logger, + ct: default, graphBaseUrl: "https://graph.example"); attempted.Should().BeTrue(); succeeded.Should().BeTrue(); await _executor.Received().ExecuteAsync( - "az", Arg.Is(s => s.Contains("--method POST") && s.Contains("oauth2PermissionGrants") && !s.Contains($"/{ExistingGrantId}")), + "az", Arg.Is(s => s.Contains("https://graph.example/v1.0/oauth2PermissionGrants") + && s.Contains("--method POST") && !s.Contains($"/{ExistingGrantId}")), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); await _executor.DidNotReceive().ExecuteAsync( "az", Arg.Is(s => s.Contains("--method PATCH")), diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AzRestS2SRunnerTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AzRestS2SRunnerTests.cs index 468a1f93..3afcbec9 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AzRestS2SRunnerTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AzRestS2SRunnerTests.cs @@ -174,12 +174,14 @@ public async Task NoExistingAssignment_PostedWithRoleBody() .Returns(Task.FromResult(new CommandResult { ExitCode = 0 })); var (attempted, succeeded) = await AzRestS2SRunner.TryRunAsync( - _executor, BlueprintSpId, new[] { ObsSpec() }, _logger, ct: default); + _executor, BlueprintSpId, new[] { ObsSpec() }, _logger, + ct: default, graphBaseUrl: "https://graph.example"); attempted.Should().BeTrue(); succeeded.Should().BeTrue(); await _executor.Received().ExecuteAsync( - "az", Arg.Is(s => s.Contains("--method POST") && s.Contains($"/servicePrincipals/{BlueprintSpId}/appRoleAssignments")), + "az", Arg.Is(s => s.Contains("https://graph.example/v1.0") + && s.Contains("--method POST") && s.Contains($"/servicePrincipals/{BlueprintSpId}/appRoleAssignments")), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BatchPermissionsOrchestratorMissingSpTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BatchPermissionsOrchestratorMissingSpTests.cs index 62531bba..84e4f13f 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BatchPermissionsOrchestratorMissingSpTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BatchPermissionsOrchestratorMissingSpTests.cs @@ -376,9 +376,10 @@ public void BuildPerSpBlueprintConsentUrl_KeysClientIdOnBlueprintAndScopeOnResou // (first party token-to-self). This URL has the blueprint as the CLIENT and the // resource as the SCOPE target — a normal cross-app consent that Entra accepts. var spec = new ResourcePermissionSpec(TeamsMcpAppId, "Work IQ Teams MCP", new[] { "Tools.ListInvoke.All" }, SetInheritable: true); - var url = BatchPermissionsOrchestrator.BuildPerSpBlueprintConsentUrl(TenantId, BlueprintAppId, spec); + var url = BatchPermissionsOrchestrator.BuildPerSpBlueprintConsentUrl( + TenantId, BlueprintAppId, spec, authorityHost: "https://login.example"); - url.Should().StartWith($"https://login.microsoftonline.com/{TenantId}/v2.0/adminconsent", + url.Should().StartWith($"https://login.example/{TenantId}/v2.0/adminconsent", because: "the per-SP recovery URL targets the v2 admin-consent endpoint scoped to the operator's tenant"); url.Should().Contain($"client_id={BlueprintAppId}", because: "the BLUEPRINT must be the client so this is a normal cross-app consent — using the resource as client would hit AADSTS65003 token-to-self"); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/ConfigConstantsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/ConfigConstantsTests.cs new file mode 100644 index 00000000..60e1efbd --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/ConfigConstantsTests.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Constants; + +[Collection("ConfigTests")] +public class ConfigConstantsTests +{ + [Theory] + [InlineData("gcc-high", "GCC_HIGH")] + [InlineData("Gcc High", "GCC_HIGH")] + [InlineData("gcch", "GCCH")] + [InlineData("", "PROD")] + public void NormalizeEnvironmentKey_ProducesEnvironmentVariableSuffix( + string environment, + string expected) + { + ConfigConstants.NormalizeEnvironmentKey(environment).Should().Be(expected); + } + + [Fact] + public void EnvironmentScopedOverrides_UseNormalizedCloudName() + { + const string appId = "11111111-2222-3333-4444-555555555555"; + const string discoverEndpoint = "https://tools.example/discover"; + + WithEnvironmentVariable("A365_MCP_APP_ID_GCC_HIGH", appId, () => + ConfigConstants.GetAgent365ToolsResourceAppId("gcc-high").Should().Be(appId)); + WithEnvironmentVariable("A365_DISCOVER_ENDPOINT_GCC_HIGH", discoverEndpoint, () => + ConfigConstants.GetDiscoverEndpointUrl("gcc-high").Should().Be(discoverEndpoint)); + } + + [Fact] + public void GraphBaseUrl_UsesScopedOverrideThenConfigThenDefault() + { + const string key = "A365_GRAPH_BASE_URL_GCC_HIGH"; + + WithEnvironmentVariable(key, "https://scoped.example/", () => + ConfigConstants.GetGraphBaseUrl("gcc-high", "https://config.example") + .Should().Be("https://scoped.example")); + + WithEnvironmentVariable(key, null, () => + ConfigConstants.GetGraphBaseUrl("gcc-high", "https://config.example/") + .Should().Be("https://config.example")); + + } + + [Fact] + public void AuthorityHost_UsesScopedOverrideThenConfigThenDefault() + { + const string key = "A365_AUTHORITY_HOST_GCC_HIGH"; + + WithEnvironmentVariable(key, "https://login.scoped.example/", () => + ConfigConstants.GetAuthorityHost("gcc-high", "https://login.config.example") + .Should().Be("https://login.scoped.example")); + + WithEnvironmentVariable(key, null, () => + ConfigConstants.GetAuthorityHost("gcc-high", "https://login.config.example/") + .Should().Be("https://login.config.example")); + + } + + [Theory] + [InlineData("http://graph.example")] + [InlineData("https://user@graph.example")] + [InlineData("https://graph.example/path")] + [InlineData("https://graph.example?query=value")] + [InlineData("https://graph.example#fragment")] + public void GraphBaseUrl_RejectsValuesThatAreNotHttpsOrigins(string value) + { + WithEnvironmentVariable("A365_GRAPH_BASE_URL_GCC_HIGH", null, () => + FluentActions.Invoking(() => ConfigConstants.GetGraphBaseUrl("gcc-high", value)) + .Should().Throw()); + } + + private static void WithEnvironmentVariable(string name, string? value, Action assertion) + { + var previous = Environment.GetEnvironmentVariable(name); + try + { + Environment.SetEnvironmentVariable(name, value); + assertion(); + } + finally + { + Environment.SetEnvironmentVariable(name, previous); + } + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs index 3f038d63..6c60910a 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs @@ -154,16 +154,18 @@ public void MissingAdminConsent_IncludesConsentGrantInstructions() public void BuildAdminConsentUrl_EncodesRedirectUri() { // Act - var consentUrl = ClientAppValidationException.BuildAdminConsentUrl(TestClientAppId, TestTenantId); + var consentUrl = ClientAppValidationException.BuildAdminConsentUrl( + TestClientAppId, TestTenantId, "https://login.example"); // Assert consentUrl.Should().NotBeNull(); + consentUrl.Should().StartWith($"https://login.example/{TestTenantId}/adminconsent"); consentUrl.Should().Contain($"client_id={TestClientAppId}", because: "the client ID must be preserved in the admin consent URL query string"); consentUrl.Should().Contain( - $"redirect_uri={Uri.EscapeDataString("https://login.microsoftonline.com/common/oauth2/nativeclient")}", + $"redirect_uri={Uri.EscapeDataString("https://login.example/common/oauth2/nativeclient")}", because: "redirect_uri is a URL-valued query parameter and must be encoded so the consent link remains valid when copied through shells, logs, and browsers"); consentUrl.Should().NotContain( - "&redirect_uri=https://login.microsoftonline.com/common/oauth2/nativeclient", + "&redirect_uri=https://login.example/common/oauth2/nativeclient", because: "an unescaped redirect URI contains reserved characters that can corrupt the admin consent query string"); } 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 9607bbb4..4ebd82bd 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 @@ -177,10 +177,13 @@ public void BuildCombinedConsentUrl_ReturnsCorrectBaseUrlStructure() { var url = SetupHelpers.BuildCombinedConsentUrl( TenantId, BlueprintClientId, - new[] { "Mail.Send" }, new[] { "McpServers.Mail.All" }); + new[] { "Mail.Send" }, new[] { "McpServers.Mail.All" }, + graphResourceUri: "https://graph.example", + authorityHost: "https://login.example"); - url.Should().StartWith($"https://login.microsoftonline.com/{TenantId}/v2.0/adminconsent"); + url.Should().StartWith($"https://login.example/{TenantId}/v2.0/adminconsent"); url.Should().Contain($"client_id={BlueprintClientId}"); + url.Should().Contain(Uri.EscapeDataString("https://graph.example/Mail.Send")); url.Should().Contain($"redirect_uri={Uri.EscapeDataString(AuthenticationConstants.BlueprintConsentRedirectUri)}", because: "redirect_uri must be registered on the blueprint app — AADSTS500113 is returned if absent or unregistered"); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AdminConsentHelperTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AdminConsentHelperTests.cs index cd8a3393..c4eff2dc 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AdminConsentHelperTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AdminConsentHelperTests.cs @@ -30,9 +30,15 @@ public async Task PollAdminConsentAsync_ReturnsTrue_WhenGrantExists() .Returns(Task.FromResult(new Microsoft.Agents.A365.DevTools.Cli.Services.CommandResult { ExitCode = 0, StandardOutput = grantsJson })); var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - var result = await AdminConsentHelper.PollAdminConsentAsync(executor, logger, "appId-1", "Test", 10, 1, cts.Token); + var result = await AdminConsentHelper.PollAdminConsentAsync( + executor, logger, "appId-1", "Test", 10, 1, cts.Token, + graphBaseUrl: "https://graph.example"); result.Should().BeTrue(); + await executor.Received(2).ExecuteAsync( + "az", + Arg.Is(args => args.Contains("https://graph.example/v1.0", StringComparison.Ordinal)), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] @@ -464,4 +470,3 @@ public async Task CheckConsentExistsAsync_AzCli_AggregatesScopesAcrossMultipleGr } } } - diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs index 33a834d9..42fd2a0f 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs @@ -224,16 +224,24 @@ public async Task GetMgGraphAccessTokenAsync_WhenMsalSucceeds_ReturnsMsalTokenWi var clientAppId = "87654321-4321-4321-4321-cba987654321"; var msalToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzZWxsYWsifQ.signature"; + string[]? requestedScopes = null; var provider = new MicrosoftGraphTokenProvider(_executor, _logger) { - MsalTokenAcquirerOverride = (_, _, _, _) => Task.FromResult(msalToken) + MsalTokenAcquirerOverride = (_, resolvedScopes, _, _) => + { + requestedScopes = resolvedScopes; + return Task.FromResult(msalToken); + } }; // Act - var token = await provider.GetMgGraphAccessTokenAsync(tenantId, scopes, false, clientAppId); + var token = await provider.GetMgGraphAccessTokenAsync( + tenantId, scopes, false, clientAppId, graphBaseUrl: "https://graph.example", + authorityHost: "https://login.example"); // Assert token.Should().Be(msalToken); + requestedScopes.Should().Equal("https://graph.example/AgentIdentityBlueprint.DeleteRestore.All"); await _executor.DidNotReceive().ExecuteWithStreamingAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>(), From 61a566b13d64be5a5e30a4655550fb6fd179bccc Mon Sep 17 00:00:00 2001 From: Rick Brighenti <202984599+rbrighenti@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:53:00 +0000 Subject: [PATCH 02/12] Address Copilot review: trim scopes, condense comment, tidy CHANGELOG Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- .../Services/Internal/MicrosoftGraphTokenProvider.cs | 1 + .../Services/MsalBrowserCredential.cs | 4 +--- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a5d56db..0304b05c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,7 +58,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g - `a365 develop get-token --device-code` — forces device code auth for Microsoft Graph scopes the Windows WAM broker rejects (e.g. Exchange `MailboxSettings.ReadWrite`, `ExchangeMessageTrace.Read.All`). ### Fixed -- Cloud-specific Graph, authority, and Agent 365 Tools overrides now apply across setup, consent, authentication, query, and create-instance flows. Arbitrary cloud names use normalized environment-scoped variables such as `A365_GRAPH_BASE_URL_GCC_HIGH`, and configured endpoints are normalized and validated +- Cloud-specific Graph, authority, and Agent 365 Tools endpoint overrides now apply consistently across setup, consent, authentication, query, and create-instance flows for sovereign and custom clouds. (#478) - `setup all --authmode s2s` no longer prints spurious "Action Required" PowerShell steps when the agent identity already inherits its app roles from the blueprint, and now retries the grant automatically before falling back to manual steps (#460). - `a365 develop get-token` now falls back to device code when the Windows WAM broker rejects Exchange Graph scopes with `ApiContractViolation`, instead of failing with an opaque MSAL error. - `setup blueprint` now configures the blueprint's inheritable Microsoft Graph permissions even when the signed-in user is not a Global Administrator, no longer aborts with a misleading "Failed to configure inheritable permissions" error when the tenant-wide consent grant cannot be made programmatically, and ends with a setup summary whose Action Required block surfaces the admin-consent URL for non-admins to hand off (#452). diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs index 1da8aa52..e9fed441 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs @@ -289,6 +289,7 @@ private string[] ValidateAndPrepareScopes(IEnumerable scopes, string gra var validScopes = scopes .Where(s => !string.IsNullOrWhiteSpace(s)) + .Select(s => s.Trim()) .Select(s => s.Contains("://", StringComparison.Ordinal) ? s : $"{graphBaseUrl}/{s.TrimStart('/')}") diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs index e915264d..1280e0c0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs @@ -119,9 +119,7 @@ public MsalBrowserCredential( _loginHint = loginHint; _forceRefresh = forceRefresh; - // Capture the login authority host so consent URLs surfaced later (BuildAdminConsentUrl) - // target the same cloud this credential authenticates against. Falls back to commercial - // when no explicit sovereign authority was supplied. + // Pin consent URLs (BuildAdminConsentUrl) to the cloud we authenticate against; default commercial. _authorityHost = Uri.TryCreate(authority, UriKind.Absolute, out var authorityUri) ? authorityUri.GetLeftPart(UriPartial.Authority) : ConfigConstants.DefaultAuthorityHost; From 526eaaaa6ec6ac54767e5387c0e540fabfa1d294 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Jul 2026 10:05:55 +0000 Subject: [PATCH 03/12] test: add because: clauses to non-obvious test assertions --- .../Exceptions/ClientAppValidationExceptionTests.cs | 3 ++- .../Helpers/SetupHelpersConsentUrlTests.cs | 3 ++- .../Services/MicrosoftGraphTokenProviderTests.cs | 4 +++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs index 6c60910a..a63e496c 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs @@ -159,7 +159,8 @@ public void BuildAdminConsentUrl_EncodesRedirectUri() // Assert consentUrl.Should().NotBeNull(); - consentUrl.Should().StartWith($"https://login.example/{TestTenantId}/adminconsent"); + consentUrl.Should().StartWith($"https://login.example/{TestTenantId}/adminconsent", + because: "the admin consent URL must be rooted at the cloud-specific authority host with the tenant ID in the path — using the wrong authority host produces an AADSTS error for sovereign/government clouds"); consentUrl.Should().Contain($"client_id={TestClientAppId}", because: "the client ID must be preserved in the admin consent URL query string"); consentUrl.Should().Contain( $"redirect_uri={Uri.EscapeDataString("https://login.example/common/oauth2/nativeclient")}", 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 4ebd82bd..fe42ea5e 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 @@ -183,7 +183,8 @@ public void BuildCombinedConsentUrl_ReturnsCorrectBaseUrlStructure() url.Should().StartWith($"https://login.example/{TenantId}/v2.0/adminconsent"); url.Should().Contain($"client_id={BlueprintClientId}"); - url.Should().Contain(Uri.EscapeDataString("https://graph.example/Mail.Send")); + url.Should().Contain(Uri.EscapeDataString("https://graph.example/Mail.Send"), + because: "Graph scopes in the consent URL must be fully-qualified resource URIs and URI-encoded — AAD rejects bare scope names or unencoded URIs in the adminconsent query string"); url.Should().Contain($"redirect_uri={Uri.EscapeDataString(AuthenticationConstants.BlueprintConsentRedirectUri)}", because: "redirect_uri must be registered on the blueprint app — AADSTS500113 is returned if absent or unregistered"); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs index 42fd2a0f..6af002e5 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs @@ -241,7 +241,9 @@ public async Task GetMgGraphAccessTokenAsync_WhenMsalSucceeds_ReturnsMsalTokenWi // Assert token.Should().Be(msalToken); - requestedScopes.Should().Equal("https://graph.example/AgentIdentityBlueprint.DeleteRestore.All"); + requestedScopes.Should().Equal( + new[] { "https://graph.example/AgentIdentityBlueprint.DeleteRestore.All" }, + because: "short scope names must be normalized to fully-qualified URIs by prepending the configured Graph base URL before being passed to MSAL"); await _executor.DidNotReceive().ExecuteWithStreamingAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>(), From add44a171bc592748356749de8ff91468c14f157 Mon Sep 17 00:00:00 2001 From: Rick Brighenti <202984599+rbrighenti@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:39:55 +0000 Subject: [PATCH 04/12] Fix Graph BaseUrl version segment and thread cancellation token - InteractiveGraphAuthService: append /v1.0 to the cloud-specific Graph BaseUrl so overriding RequestAdapter.BaseUrl doesn't drop the API version segment and 404 every request; add regression test. - BootstrapConfigResolver: pass the caller's CancellationToken into the 'az cloud show' invocation so bootstrap can be cancelled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/BootstrapConfigResolver.cs | 8 +++--- .../Services/InteractiveGraphAuthService.cs | 2 +- .../InteractiveGraphAuthServiceTests.cs | 25 +++++++++++++++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/BootstrapConfigResolver.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/BootstrapConfigResolver.cs index 416c5b1a..86e6741c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/BootstrapConfigResolver.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/BootstrapConfigResolver.cs @@ -239,7 +239,7 @@ public async Task CheckAndBackupStaleConfigAsync(string configPath, Cancel } } - private async Task GetBootstrapEnvironmentAsync() + private async Task GetBootstrapEnvironmentAsync(CancellationToken ct) { var configuredEnvironment = Environment.GetEnvironmentVariable("A365_ENVIRONMENT"); if (!string.IsNullOrWhiteSpace(configuredEnvironment)) @@ -249,7 +249,7 @@ private async Task GetBootstrapEnvironmentAsync() { var result = await _executor.ExecuteAsync( "az", "cloud show --query name -o tsv", - captureOutput: true, suppressErrorLogging: true); + captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); var cloudName = result.StandardOutput?.Trim(); return string.IsNullOrWhiteSpace(cloudName) ? "prod" : cloudName; } @@ -271,7 +271,7 @@ private async Task GetBootstrapEnvironmentAsync() if (tenantId is null) return null; - var environment = await GetBootstrapEnvironmentAsync(); + var environment = await GetBootstrapEnvironmentAsync(ct); _graphApiService?.ConfigureCloudEndpoints(new Agent365Config { Environment = environment }); var clientAppId = await SetupHelpers.ResolveBootstrapClientAppIdAsync( @@ -320,7 +320,7 @@ private async Task GetBootstrapEnvironmentAsync() return null; } - var environment = await GetBootstrapEnvironmentAsync(); + var environment = await GetBootstrapEnvironmentAsync(ct); _graphApiService?.ConfigureCloudEndpoints(new Agent365Config { Environment = environment }); // Step 2: Resolve client app ID — prefer local a365.config.json when tenant matches. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs index 1c7b8875..8dd3a270 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs @@ -157,7 +157,7 @@ public async Task GetAuthenticatedGraphClientAsync( _logger.LogInformation("Successfully authenticated to Microsoft Graph!"); var graphClient = new GraphServiceClient(credential!, _requiredScopes); - graphClient.RequestAdapter.BaseUrl = _graphBaseUrl; + graphClient.RequestAdapter.BaseUrl = $"{_graphBaseUrl}/{GraphApiConstants.Versions.V1}"; _cachedClient = graphClient; _cachedTenantId = tenantId; diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/InteractiveGraphAuthServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/InteractiveGraphAuthServiceTests.cs index 7551106f..e129ed31 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/InteractiveGraphAuthServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/InteractiveGraphAuthServiceTests.cs @@ -264,6 +264,31 @@ public async Task GetAuthenticatedGraphClientAsync_WhenCredentialSucceeds_Return client.Should().NotBeNull(); } + /// + /// Verifies that the returned GraphServiceClient targets the configured (sovereign) Graph + /// base URL with the API version segment appended. Overriding RequestAdapter.BaseUrl with the + /// origin alone drops the SDK default "/v1.0" segment and sends every request to a 404. + /// + [Fact] + public async Task GetAuthenticatedGraphClientAsync_UsesConfiguredGraphBaseUrlWithVersionSegment() + { + // Arrange + var workingCredential = new StubTokenCredential("token-value", DateTimeOffset.UtcNow.AddHours(1)); + var logger = Substitute.For>(); + var sut = new InteractiveGraphAuthService(logger, ValidGuid, + credentialFactory: (_, _) => workingCredential, + loginHintResolver: NoOpLoginHint, + graphBaseUrl: "https://graph.microsoft.us"); + + // Act + var client = await sut.GetAuthenticatedGraphClientAsync(ValidTenantId); + + // Assert + client.RequestAdapter.BaseUrl.Should().Be( + "https://graph.microsoft.us/v1.0", + because: "the Graph SDK routes requests relative to BaseUrl, so the cloud-specific host must retain the /v1.0 API version segment or all requests 404"); + } + /// /// Verifies that the service returns the same cached GraphServiceClient for the same tenant /// on repeated calls, avoiding redundant authentication prompts. From 112a9297a24c288a967e8d52129c8da008b5b964 Mon Sep 17 00:00:00 2001 From: Rick Brighenti <202984599+rbrighenti@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:48:22 +0000 Subject: [PATCH 05/12] Fix custom CLI app validation authentication Use the ambient administrator identity when validating and repairing tenant-owned fallback client apps, while preserving resolved-app token checks for issued claims. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + .../Services/ClientAppValidator.cs | 173 +++++-- .../Services/GraphApiService.cs | 71 ++- .../Services/GraphAuthenticationMode.cs | 4 +- .../Services/ClientAppValidatorTests.cs | 438 +++++++++++++++++- .../Services/GraphApiServiceTests.cs | 100 ++++ 6 files changed, 738 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8798e43b..c5c1bafc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g - `a365 develop get-token --device-code` — forces device code auth for Microsoft Graph scopes the Windows WAM broker rejects (e.g. Exchange `MailboxSettings.ReadWrite`, `ExchangeMessageTrace.Read.All`). ### Fixed +- `setup requirements` now validates and repairs tenant-owned fallback CLI apps with the administrator bootstrap identity, preventing false "app not found" failures when the first-party CLI app is unavailable. - Cloud-specific Graph, authority, and Agent 365 Tools endpoint overrides now apply consistently across setup, consent, authentication, query, and create-instance flows for sovereign and custom clouds. (#478) - Setup no longer fails to detect the Agent 365 CLI application in tenants where it is not yet provisioned, and reports lookup errors instead of silently switching your configured client app (#489). - The first-party Agent 365 CLI app now uses device code authentication when Windows Account Manager is unavailable, avoiding unsupported browser-response errors in WSL, macOS, and Linux (#489). diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs index e6a26245..a81c0aeb 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs @@ -465,7 +465,10 @@ public async Task EnsureRedirectUrisAsync( _logger.LogDebug("Checking redirect URIs for client app {ClientAppId}", clientAppId); using var appDoc = await _graphApiService.GraphGetAsync(tenantId, - $"/v1.0/applications?$filter=appId eq '{clientAppId}'&$select=id,publicClient", ct); + $"/v1.0/applications?$filter=appId eq '{clientAppId}'&$select=id,publicClient", + ct, + scopes: null, + authenticationMode: GraphAuthenticationMode.Ambient); if (appDoc == null) { @@ -522,7 +525,9 @@ public async Task EnsureRedirectUrisAsync( var patchSuccess = await _graphApiService.GraphPatchAsync(tenantId, $"/v1.0/applications/{objectId}", new JsonObject { ["publicClient"] = new JsonObject { ["redirectUris"] = urisArray } }, - ct); + ct, + scopes: null, + authenticationMode: GraphAuthenticationMode.Ambient); if (!patchSuccess) { @@ -614,7 +619,9 @@ private async Task EnsureWidsOptionalClaimAsync( var patchSuccess = await _graphApiService.GraphPatchAsync(tenantId, $"/v1.0/applications/{objectId}", patchPayload, - ct); + ct, + scopes: null, + authenticationMode: GraphAuthenticationMode.Ambient); if (!patchSuccess) { @@ -698,7 +705,10 @@ private async Task EnsurePublicClientFlowsEnabledAsync( _logger.LogDebug("Checking 'Allow public client flows' for client app {ClientAppId}", clientAppId); using var appDoc = await _graphApiService.GraphGetAsync(tenantId, - $"/v1.0/applications?$filter=appId eq '{clientAppId}'&$select=id,isFallbackPublicClient", ct); + $"/v1.0/applications?$filter=appId eq '{clientAppId}'&$select=id,isFallbackPublicClient", + ct, + scopes: null, + authenticationMode: GraphAuthenticationMode.Ambient); if (appDoc == null) { @@ -740,7 +750,9 @@ private async Task EnsurePublicClientFlowsEnabledAsync( var patchSuccess = await _graphApiService.GraphPatchAsync(tenantId, $"/v1.0/applications/{objectId}", new { isFallbackPublicClient = true }, - ct); + ct, + scopes: null, + authenticationMode: GraphAuthenticationMode.Ambient); if (!patchSuccess) { @@ -861,7 +873,9 @@ private async Task EnsurePermissionsConfiguredAsync( var patchSuccess = await _graphApiService.GraphPatchAsync(tenantId, $"/v1.0/applications/{appInfo.ObjectId}", new JsonObject { ["requiredResourceAccess"] = updatedResourceAccess }, - ct); + ct, + scopes: null, + authenticationMode: GraphAuthenticationMode.Ambient); if (!patchSuccess) { @@ -899,7 +913,10 @@ private async Task TryExtendConsentGrantScopesAsync( { // Look up the service principal for the client app using var spDoc = await _graphApiService.GraphGetAsync(tenantId, - $"/v1.0/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id", ct); + $"/v1.0/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id", + ct, + scopes: null, + authenticationMode: GraphAuthenticationMode.Ambient); if (spDoc == null) return; @@ -909,7 +926,10 @@ private async Task TryExtendConsentGrantScopesAsync( // Find the oauth2PermissionGrant that targets Microsoft Graph using var grantsDoc = await _graphApiService.GraphGetAsync(tenantId, - $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", ct); + $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", + ct, + scopes: null, + authenticationMode: GraphAuthenticationMode.Ambient); if (grantsDoc == null) return; @@ -920,7 +940,10 @@ private async Task TryExtendConsentGrantScopesAsync( // Look up the Microsoft Graph service principal ID to match against resourceId string? graphSpObjectId = null; using var graphSpDoc = await _graphApiService.GraphGetAsync(tenantId, - $"/v1.0/servicePrincipals?$filter=appId eq '{AuthenticationConstants.MicrosoftGraphResourceAppId}'&$select=id", ct); + $"/v1.0/servicePrincipals?$filter=appId eq '{AuthenticationConstants.MicrosoftGraphResourceAppId}'&$select=id", + ct, + scopes: null, + authenticationMode: GraphAuthenticationMode.Ambient); if (graphSpDoc != null) { @@ -961,7 +984,9 @@ private async Task TryExtendConsentGrantScopesAsync( ["consentType"] = "AllPrincipals", ["principalId"] = null }, - ct); + ct, + scopes: null, + authenticationMode: GraphAuthenticationMode.Ambient); if (patchSuccess) { @@ -1072,7 +1097,10 @@ private async Task> CollectMissingRedirectUrisAsync( try { using var appDoc = await _graphApiService.GraphGetAsync(tenantId, - $"/v1.0/applications?$filter=appId eq '{clientAppId}'&$select=id,publicClient", ct); + $"/v1.0/applications?$filter=appId eq '{clientAppId}'&$select=id,publicClient", + ct, + scopes: null, + authenticationMode: GraphAuthenticationMode.Ambient); if (appDoc == null) return new List(); @@ -1112,7 +1140,10 @@ private async Task IsPublicClientFlowsDisabledAsync( try { using var appDoc = await _graphApiService.GraphGetAsync(tenantId, - $"/v1.0/applications?$filter=appId eq '{clientAppId}'&$select=id,isFallbackPublicClient", ct); + $"/v1.0/applications?$filter=appId eq '{clientAppId}'&$select=id,isFallbackPublicClient", + ct, + scopes: null, + authenticationMode: GraphAuthenticationMode.Ambient); if (appDoc == null) return false; @@ -1149,7 +1180,10 @@ private async Task IsPublicClientFlowsDisabledAsync( try { using var appDoc = await _graphApiService.GraphGetAsync(tenantId, - $"/v1.0/applications?$filter=appId eq '{clientAppId}'&$select=id,optionalClaims", ct); + $"/v1.0/applications?$filter=appId eq '{clientAppId}'&$select=id,optionalClaims", + ct, + scopes: null, + authenticationMode: GraphAuthenticationMode.Ambient); if (appDoc == null) return (false, null, null); @@ -1204,7 +1238,10 @@ private async Task HasPrincipalOnlyConsentGrantAsync(string clientAppId, s try { using var spDoc = await _graphApiService.GraphGetAsync(tenantId, - $"/v1.0/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id", ct); + $"/v1.0/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id", + ct, + scopes: null, + authenticationMode: GraphAuthenticationMode.Ambient); if (spDoc == null) return false; var spJson = JsonNode.Parse(spDoc.RootElement.GetRawText()); @@ -1212,7 +1249,10 @@ private async Task HasPrincipalOnlyConsentGrantAsync(string clientAppId, s if (string.IsNullOrWhiteSpace(spObjectId)) return false; using var grantsDoc = await _graphApiService.GraphGetAsync(tenantId, - $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", ct); + $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", + ct, + scopes: null, + authenticationMode: GraphAuthenticationMode.Ambient); if (grantsDoc == null) return false; var grantsJson = JsonNode.Parse(grantsDoc.RootElement.GetRawText()); @@ -1259,7 +1299,10 @@ private async Task UpgradeConsentGrantToAllPrincipalsAsync(string clientAppId, s try { using var spDoc = await _graphApiService.GraphGetAsync(tenantId, - $"/v1.0/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id", ct); + $"/v1.0/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id", + ct, + scopes: null, + authenticationMode: GraphAuthenticationMode.Ambient); if (spDoc == null) return; var spJson = JsonNode.Parse(spDoc.RootElement.GetRawText()); @@ -1267,7 +1310,10 @@ private async Task UpgradeConsentGrantToAllPrincipalsAsync(string clientAppId, s if (string.IsNullOrWhiteSpace(spObjectId)) return; using var grantsDoc = await _graphApiService.GraphGetAsync(tenantId, - $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", ct); + $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", + ct, + scopes: null, + authenticationMode: GraphAuthenticationMode.Ambient); if (grantsDoc == null) return; var grantsJson = JsonNode.Parse(grantsDoc.RootElement.GetRawText()); @@ -1302,7 +1348,9 @@ private async Task UpgradeConsentGrantToAllPrincipalsAsync(string clientAppId, s ["principalId"] = null, ["scope"] = scope }, - ct); + ct, + scopes: null, + authenticationMode: GraphAuthenticationMode.Ambient); if (patchSuccess) _logger.LogInformation("Consent grant upgraded to AllPrincipals — all tenant users can now authenticate without individual consent prompts."); @@ -1324,7 +1372,9 @@ private async Task UpgradeConsentGrantToAllPrincipalsAsync(string clientAppId, s const string path = "/v1.0/applications?$filter=appId eq '{0}'&$select=id,appId,displayName,requiredResourceAccess"; var graphResponse = await _graphApiService.GraphGetWithResponseAsync(tenantId, - string.Format(path, clientAppId), ct: ct); + string.Format(path, clientAppId), + ct: ct, + authenticationMode: GraphAuthenticationMode.Ambient); if (graphResponse == null || !graphResponse.IsSuccess) { @@ -1334,27 +1384,72 @@ private async Task UpgradeConsentGrantToAllPrincipalsAsync(string clientAppId, s if (graphResponse?.StatusCode != 401) { _logger.LogDebug("Graph app query failed with {StatusCode} — not retrying", graphResponse?.StatusCode); - return null; + throw ClientAppValidationException.ValidationFailed( + "Unable to verify the client app registration", + [$"Microsoft Graph application lookup failed: HTTP {graphResponse?.StatusCode ?? 0} {graphResponse?.ReasonPhrase ?? "Unknown"}."], + clientAppId); } _logger.LogDebug("Graph app query returned 401 — retrying with fresh token (possible CAE revocation)"); graphResponse = await _graphApiService.GraphGetWithResponseAsync(tenantId, - string.Format(path, clientAppId), forceRefresh: true, ct: ct); + string.Format(path, clientAppId), + forceRefresh: true, + ct: ct, + authenticationMode: GraphAuthenticationMode.Ambient); if (!graphResponse.IsSuccess) - throw ClientAppValidationException.TokenRevoked(clientAppId); + { + if (graphResponse.StatusCode == 401) + throw ClientAppValidationException.TokenRevoked(clientAppId); + + throw ClientAppValidationException.ValidationFailed( + "Unable to verify the client app registration", + [$"Microsoft Graph application lookup failed after token refresh: HTTP {graphResponse.StatusCode} {graphResponse.ReasonPhrase}."], + clientAppId); + } } using var doc = graphResponse.Json; - if (doc == null) return null; + if (doc is null) + { + throw ClientAppValidationException.ValidationFailed( + "Unable to verify the client app registration", + ["Microsoft Graph application lookup returned an empty response body."], + clientAppId); + } + + if (doc.RootElement.ValueKind != JsonValueKind.Object || + !doc.RootElement.TryGetProperty("value", out var appsElement) || + appsElement.ValueKind != JsonValueKind.Array) + { + throw ClientAppValidationException.ValidationFailed( + "Unable to verify the client app registration", + ["Microsoft Graph application lookup returned an invalid response."], + clientAppId); + } - var response = JsonNode.Parse(doc.RootElement.GetRawText()); - var apps = response?["value"]?.AsArray(); - if (apps == null || apps.Count == 0) return null; + if (appsElement.GetArrayLength() == 0) return null; + + var firstApp = appsElement[0]; + if (firstApp.ValueKind != JsonValueKind.Object || + !firstApp.TryGetProperty("id", out var objectIdElement) || + objectIdElement.ValueKind != JsonValueKind.String || + !Guid.TryParse(objectIdElement.GetString(), out var objectId) || + !firstApp.TryGetProperty("appId", out var appIdElement) || + appIdElement.ValueKind != JsonValueKind.String || + !Guid.TryParse(appIdElement.GetString(), out var returnedAppId) || + !Guid.TryParse(clientAppId, out var expectedAppId) || + returnedAppId != expectedAppId) + { + throw ClientAppValidationException.ValidationFailed( + "Unable to verify the client app registration", + ["Microsoft Graph application lookup returned an invalid application record."], + clientAppId); + } - var app = apps[0]!.AsObject(); + var app = JsonNode.Parse(firstApp.GetRawText())!.AsObject(); return new ClientAppInfo( - app["id"]?.GetValue() ?? string.Empty, + objectId.ToString("D"), app["displayName"]?.GetValue() ?? string.Empty, app["requiredResourceAccess"]?.AsArray()); } @@ -1440,7 +1535,9 @@ private async Task> ResolvePermissionIdsAsync(string { using var doc = await _graphApiService.GraphGetAsync(tenantId, $"/v1.0/servicePrincipals?$filter=appId eq '{AuthenticationConstants.MicrosoftGraphResourceAppId}'&$select=id,oauth2PermissionScopes", - ct); + ct, + scopes: null, + authenticationMode: GraphAuthenticationMode.Ambient); if (doc == null) { @@ -1499,7 +1596,10 @@ private async Task> GetConsentedPermissionsAsync(string clientAp { // Get service principal for the app using var spDoc = await _graphApiService.GraphGetAsync(tenantId, - $"/v1.0/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id", ct); + $"/v1.0/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id", + ct, + scopes: null, + authenticationMode: GraphAuthenticationMode.Ambient); if (spDoc == null) { @@ -1530,7 +1630,9 @@ private async Task> GetConsentedPermissionsAsync(string clientAp // "permissions not consented" prompt for non-admin developers who can never read // the grants table by design). var grantsResp = await _graphApiService.GraphGetWithResponseAsync(tenantId, - $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", ct: ct); + $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", + ct: ct, + authenticationMode: GraphAuthenticationMode.Ambient); using var grantsDoc = grantsResp.Json; if (grantsResp.StatusCode == 403) @@ -1587,7 +1689,10 @@ private async Task ValidateAdminConsentAsync(string clientAppId, string te // Get service principal for the app using var spDoc = await _graphApiService.GraphGetAsync(tenantId, - $"/v1.0/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id,appId", ct); + $"/v1.0/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id,appId", + ct, + scopes: null, + authenticationMode: GraphAuthenticationMode.Ambient); if (spDoc == null) { @@ -1618,7 +1723,9 @@ private async Task ValidateAdminConsentAsync(string clientAppId, string te // (token acquisition, network, 5xx) — the user-facing message differs and lumping them // together would either misattribute the cause or hide real failures. var grantsResp = await _graphApiService.GraphGetWithResponseAsync(tenantId, - $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", ct: ct); + $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", + ct: ct, + authenticationMode: GraphAuthenticationMode.Ambient); using var grantsDoc = grantsResp.Json; if (grantsResp.StatusCode == 403) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index aad7777a..078b9add 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -275,9 +275,9 @@ private async Task EnsureGraphHeadersAsync( // that need claims from the custom-app JWT (e.g. CheckDirectoryRoleAsync) must guard // for both `_tokenProvider == null` and an empty `CustomClientAppId` themselves. // - // 4. GraphAuthenticationMode.Ambient: the caller is probing whether a client app exists. - // Authenticating as that app would make its own absence unverifiable, so the resolved - // client app and requested scopes are ignored and the bootstrap path below is used. + // 4. GraphAuthenticationMode.Ambient: the caller is diagnosing or repairing a client app. + // Authenticating as that app would make an underconfigured registration unable to + // authorize its own repair, so the resolved app and requested scopes are ignored. // // All paths go through MSAL — no az CLI subprocess involved. @@ -395,9 +395,36 @@ public virtual async Task ServicePrincipalExistsAsync(string tenantId, str /// Executes a GET request to Microsoft Graph API. /// Virtual to allow mocking in unit tests using Moq. /// - public virtual async Task GraphGetAsync(string tenantId, string relativePath, CancellationToken ct = default, IEnumerable? scopes = null) + public virtual Task GraphGetAsync( + string tenantId, + string relativePath, + CancellationToken ct = default, + IEnumerable? scopes = null) { - if (!await EnsureGraphHeadersAsync(tenantId, scopes: scopes, ct: ct)) return null; + return GraphGetAsync( + tenantId, + relativePath, + ct, + scopes, + GraphAuthenticationMode.ResolvedClientApp); + } + + /// + /// Executes a GET request to Microsoft Graph API using the selected authentication identity. + /// + public virtual async Task GraphGetAsync( + string tenantId, + string relativePath, + CancellationToken ct, + IEnumerable? scopes, + GraphAuthenticationMode authenticationMode) + { + if (!await EnsureGraphHeadersAsync( + tenantId, + scopes: scopes, + ct: ct, + authenticationMode: authenticationMode)) + return null; var url = GraphApiConstants.BuildUrl(_graphBaseUrl, relativePath); try { @@ -585,9 +612,39 @@ public virtual async Task GraphPostWithResponseAsync(string tenan /// Executes a PATCH request to Microsoft Graph API. /// Virtual to allow mocking in unit tests using Moq. /// - public virtual async Task GraphPatchAsync(string tenantId, string relativePath, object payload, CancellationToken ct = default, IEnumerable? scopes = null) + public virtual Task GraphPatchAsync( + string tenantId, + string relativePath, + object payload, + CancellationToken ct = default, + IEnumerable? scopes = null) { - if (!await EnsureGraphHeadersAsync(tenantId, scopes: scopes, ct: ct)) return false; + return GraphPatchAsync( + tenantId, + relativePath, + payload, + ct, + scopes, + GraphAuthenticationMode.ResolvedClientApp); + } + + /// + /// Executes a PATCH request to Microsoft Graph API using the selected authentication identity. + /// + public virtual async Task GraphPatchAsync( + string tenantId, + string relativePath, + object payload, + CancellationToken ct, + IEnumerable? scopes, + GraphAuthenticationMode authenticationMode) + { + if (!await EnsureGraphHeadersAsync( + tenantId, + scopes: scopes, + ct: ct, + authenticationMode: authenticationMode)) + return false; var url = GraphApiConstants.BuildUrl(_graphBaseUrl, relativePath); var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); try diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphAuthenticationMode.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphAuthenticationMode.cs index 2c07aa97..5834b7b4 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphAuthenticationMode.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphAuthenticationMode.cs @@ -16,8 +16,8 @@ public enum GraphAuthenticationMode /// /// Force the ambient bootstrap identity, ignoring the resolved client app and any requested - /// scopes. Required when probing whether a client app exists: authenticating as the app being - /// probed makes its own absence unverifiable. + /// scopes. Required when reading or repairing a client app registration so an underconfigured + /// app does not need to authorize its own diagnosis. /// Ambient = 1 } 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 c516b006..5b4185c6 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 @@ -31,7 +31,7 @@ public class ClientAppValidatorTests private const string FirstPartyClientAppId = AuthenticationConstants.WellKnownClientAppId; private const string ValidTenantId = "12345678-1234-1234-1234-123456789012"; private const string InvalidGuid = "not-a-guid"; - private const string AppObjId = "object-id-123"; + private const string AppObjId = "11111111-2222-3333-4444-555555555555"; private const string SpObjId = "sp-object-id-123"; // Stable test GUIDs for required permissions — must match between SetupPermissionResolution @@ -57,10 +57,55 @@ public ClientAppValidatorTests() var executor = Substitute.For(executorLogger); var graphServiceLogger = Substitute.For>(); _graphApiService = Substitute.For(graphServiceLogger, executor); + ForwardAmbientCallsToExistingSubstitutions(_graphApiService); _validator = new ClientAppValidator(_logger, _graphApiService); } + private static void ForwardAmbientCallsToExistingSubstitutions(GraphApiService graphApiService) + { + graphApiService.GraphGetAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + GraphAuthenticationMode.Ambient) + .Returns(callInfo => graphApiService.GraphGetAsync( + callInfo.ArgAt(0), + callInfo.ArgAt(1), + callInfo.ArgAt(2), + callInfo.ArgAt?>(3))); + + graphApiService.GraphPatchAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + GraphAuthenticationMode.Ambient) + .Returns(callInfo => graphApiService.GraphPatchAsync( + callInfo.ArgAt(0), + callInfo.ArgAt(1), + callInfo.ArgAt(2), + callInfo.ArgAt(3), + callInfo.ArgAt?>(4))); + + graphApiService.GraphGetWithResponseAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(callInfo => graphApiService.GraphGetWithResponseAsync( + callInfo.ArgAt(0), + callInfo.ArgAt(1), + callInfo.ArgAt(2), + callInfo.ArgAt?>(3), + callInfo.ArgAt(4), + GraphAuthenticationMode.ResolvedClientApp)); + } + #region Constructor Tests [Fact] @@ -130,8 +175,7 @@ await Assert.ThrowsAsync(async () => public async Task EnsureValidClientAppAsync_WhenGraphQueryFails_ThrowsClientAppValidationException() { // Simulate a 401 on both the first attempt and the retry after cache invalidation. - // TokenRevoked is only thrown when the failure is specifically a 401 (auth error), - // not for transient failures like 503 — which would produce AppNotFound instead. + // Only a repeated 401 is diagnosed as revocation; operational failures stay inconclusive. _graphApiService.GraphGetWithResponseAsync( Arg.Any(), Arg.Is(p => p.Contains("displayName")), @@ -579,17 +623,370 @@ await _validator.EnsureRedirectUrisAsync( } [Fact] - public async Task EnsureValidClientAppAsync_CustomApp_PreservesExistingMutationBehavior() + public async Task EnsureValidClientAppAsync_CustomApp_PreservesValidationAndRepairPath() { - // Regression guard: a tenant-owned client app ID must keep the full custom-app validation - // path (existence via /applications, permission/consent self-healing), unchanged. SetupAppInfoWithAllPermissions(ValidClientAppId); SetupPermissionResolution(); await _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId); await _graphApiService.Received().GraphGetWithResponseAsync( - Arg.Any(), Arg.Is(p => p.Contains("displayName")), Arg.Any(), Arg.Any?>(), Arg.Any()); + Arg.Any(), + Arg.Is(p => p.Contains("displayName")), + Arg.Any(), + Arg.Is?>(scopes => scopes == null), + Arg.Any(), + GraphAuthenticationMode.Ambient); + } + + [Fact] + public async Task EnsureValidClientAppAsync_CustomAppMetadataLookup_UsesAmbientWithoutCustomApplicationReadAll() + { + SetupAppInfoGetEmpty(); + + await Assert.ThrowsAsync( + () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId)); + + await _graphApiService.Received(1).GraphGetWithResponseAsync( + ValidTenantId, + Arg.Is(path => path.Contains("/applications", StringComparison.Ordinal)), + false, + Arg.Is?>(scopes => scopes == null), + Arg.Any(), + GraphAuthenticationMode.Ambient); + await _graphApiService.DidNotReceive().GraphGetWithResponseAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Is?>(scopes => + scopes != null && + scopes.Contains(AuthenticationConstants.ApplicationReadAllScope)), + Arg.Any(), + GraphAuthenticationMode.ResolvedClientApp); + } + + [Fact] + public async Task EnsureValidClientAppAsync_WhenAmbientMetadataLookupIsForbidden_DoesNotReportAppAbsent() + { + _graphApiService.GraphGetWithResponseAsync( + ValidTenantId, + Arg.Is(path => path.Contains("/applications", StringComparison.Ordinal)), + false, + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = false, + StatusCode = 403, + ReasonPhrase = "Forbidden" + }); + + var exception = await Assert.ThrowsAsync( + () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId)); + + exception.IssueDescription.Should().Contain("Unable to verify", + because: "an authorization failure is inconclusive and must not be misreported as confirmed application absence"); + exception.IssueDescription.Should().NotContain("not found", + because: "HTTP 403 proves only that metadata could not be read, not that the application is absent"); + exception.ErrorDetails.Should().Contain( + detail => detail.Contains("HTTP 403", StringComparison.Ordinal), + because: "operators need the Graph status to diagnose ambient identity authorization"); + } + + [Fact] + public async Task EnsureValidClientAppAsync_WhenRefreshRetryHasOperationalFailure_DoesNotReportTokenRevoked() + { + _graphApiService.GraphGetWithResponseAsync( + ValidTenantId, + Arg.Is(path => path.Contains("/applications", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns( + new GraphApiService.GraphResponse + { + IsSuccess = false, + StatusCode = 401, + ReasonPhrase = "Unauthorized" + }, + new GraphApiService.GraphResponse + { + IsSuccess = false, + StatusCode = 503, + ReasonPhrase = "Service Unavailable" + }); + + var exception = await Assert.ThrowsAsync( + () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId)); + + exception.IssueDescription.Should().Contain("Unable to verify", + because: "a service failure after refresh is operationally inconclusive rather than proof of token revocation"); + exception.IssueDescription.Should().NotContain("revoked", + because: "only a repeated HTTP 401 can establish the token-revocation diagnosis"); + exception.ErrorDetails.Should().Contain( + detail => detail.Contains("HTTP 503", StringComparison.Ordinal), + because: "the final Graph status must be preserved for incident diagnosis"); + } + + [Fact] + public async Task EnsureValidClientAppAsync_WhenSuccessfulMetadataLookupHasNoJson_DoesNotReportAppAbsent() + { + _graphApiService.GraphGetWithResponseAsync( + ValidTenantId, + Arg.Is(path => path.Contains("/applications", StringComparison.Ordinal)), + false, + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = null + }); + + var exception = await Assert.ThrowsAsync( + () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId)); + + exception.IssueDescription.Should().Contain("Unable to verify", + because: "an empty successful response is inconclusive rather than proof that the application is absent"); + exception.IssueDescription.Should().NotContain("not found", + because: "application absence requires a valid empty Graph value array"); + exception.ErrorDetails.Should().Contain( + detail => detail.Contains("empty response body", StringComparison.Ordinal), + because: "the protocol failure should remain actionable for operators"); + } + + [Fact] + public async Task EnsureValidClientAppAsync_WhenSuccessfulMetadataLookupHasInvalidSchema_DoesNotReportAppAbsent() + { + _graphApiService.GraphGetWithResponseAsync( + ValidTenantId, + Arg.Is(path => path.Contains("/applications", StringComparison.Ordinal)), + false, + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDocument.Parse("""{"unexpected":[]}""") + }); + + var exception = await Assert.ThrowsAsync( + () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId)); + + exception.IssueDescription.Should().Contain("Unable to verify", + because: "a malformed Graph payload cannot establish whether the application exists"); + exception.IssueDescription.Should().NotContain("not found", + because: "application absence requires a valid empty Graph value array"); + exception.ErrorDetails.Should().Contain( + detail => detail.Contains("invalid response", StringComparison.Ordinal), + because: "the unexpected Graph schema should be explicit in diagnostics"); + } + + [Theory] + [InlineData("""{"value":[{}]}""")] + [InlineData("""{"value":[{"id":null,"appId":"a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6"}]}""")] + [InlineData("""{"value":[{"id":"11111111-2222-3333-4444-555555555555","appId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"}]}""")] + public async Task EnsureValidClientAppAsync_WhenMetadataApplicationRecordIsInvalid_DoesNotProceed( + string responseJson) + { + _graphApiService.GraphGetWithResponseAsync( + ValidTenantId, + Arg.Is(path => path.Contains("/applications", StringComparison.Ordinal)), + false, + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDocument.Parse(responseJson) + }); + + var exception = await Assert.ThrowsAsync( + () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId)); + + exception.IssueDescription.Should().Contain("Unable to verify", + because: "an invalid application record cannot establish the identity of the registration being validated"); + exception.ErrorDetails.Should().Contain( + detail => detail.Contains("invalid application record", StringComparison.Ordinal), + because: "malformed or mismatched Graph records must be diagnosed before validation or repair continues"); + await _graphApiService.DidNotReceiveWithAnyArgs().GraphPatchAsync( + default!, default!, default!, default, default, default); + } + + [Fact] + public async Task EnsureValidClientAppAsync_CustomRegistrationReadsAndRepairs_UseAmbientAuthentication() + { + SetupAppInfoWithAllPermissions(ValidClientAppId); + SetupPermissionResolution(); + SetupRedirectUrisGet($$""" + { + "value": [{ + "id": "{{AppObjId}}", + "publicClient": { "redirectUris": [] } + }] + } + """); + SetupPublicClientFlowsGet(enabled: false); + + var noWidsJson = $$"""{"value":[{"id":"{{AppObjId}}","optionalClaims":null}]}"""; + _graphApiService.GraphGetAsync( + Arg.Any(), + Arg.Is(path => path.Contains("optionalClaims", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>()) + .Returns(_ => Task.FromResult(JsonDocument.Parse(noWidsJson))); + _graphApiService.GraphPatchAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>()) + .Returns(true); + + await _validator.EnsureValidClientAppAsync( + ValidClientAppId, + ValidTenantId, + skipConfirmation: true); + + await _graphApiService.Received().GraphGetAsync( + ValidTenantId, + Arg.Is(path => path.Contains("publicClient", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + GraphAuthenticationMode.Ambient); + await _graphApiService.Received().GraphGetAsync( + ValidTenantId, + Arg.Is(path => path.Contains("isFallbackPublicClient", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + GraphAuthenticationMode.Ambient); + await _graphApiService.Received().GraphGetAsync( + ValidTenantId, + Arg.Is(path => path.Contains("optionalClaims", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + GraphAuthenticationMode.Ambient); + + await _graphApiService.Received().GraphPatchAsync( + ValidTenantId, + Arg.Is(path => path.Contains($"/applications/{AppObjId}", StringComparison.Ordinal)), + Arg.Is(payload => JsonSerializer.Serialize(payload).Contains("\"publicClient\"", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + GraphAuthenticationMode.Ambient); + await _graphApiService.Received().GraphPatchAsync( + ValidTenantId, + Arg.Is(path => path.Contains($"/applications/{AppObjId}", StringComparison.Ordinal)), + Arg.Is(payload => JsonSerializer.Serialize(payload).Contains("\"isFallbackPublicClient\"", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + GraphAuthenticationMode.Ambient); + await _graphApiService.Received().GraphPatchAsync( + ValidTenantId, + Arg.Is(path => path.Contains($"/applications/{AppObjId}", StringComparison.Ordinal)), + Arg.Is(payload => JsonSerializer.Serialize(payload).Contains("\"optionalClaims\"", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + GraphAuthenticationMode.Ambient); + } + + [Fact] + public async Task EnsureValidClientAppAsync_ConsentGrantReadAndUpgrade_UseAmbientAuthentication() + { + SetupAppInfoWithAllPermissions(ValidClientAppId); + SetupPermissionResolution(); + + var servicePrincipalJson = $$"""{"value":[{"id":"{{SpObjId}}","appId":"{{ValidClientAppId}}"}]}"""; + _graphApiService.GraphGetAsync( + Arg.Any(), + Arg.Is(path => path.Contains("servicePrincipals", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>()) + .Returns(_ => Task.FromResult(JsonDocument.Parse(servicePrincipalJson))); + + var allRequiredScopes = string.Join(' ', AuthenticationConstants.RequiredClientAppPermissions); + var principalGrantJson = $$""" + {"value":[{"id":"grant-id-123","clientId":"{{SpObjId}}","consentType":"Principal","scope":"{{allRequiredScopes}}"}]} + """; + var allPrincipalsGrantJson = $$""" + {"value":[{"id":"grant-id-123","clientId":"{{SpObjId}}","consentType":"AllPrincipals","scope":"{{allRequiredScopes}}"}]} + """; + _graphApiService.GraphGetAsync( + Arg.Any(), + Arg.Is(path => path.Contains("oauth2PermissionGrants", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>()) + .Returns(_ => Task.FromResult(JsonDocument.Parse(principalGrantJson))); + _graphApiService.GraphGetWithResponseAsync( + Arg.Any(), + Arg.Is(path => path.Contains("oauth2PermissionGrants", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + Arg.Any()) + .Returns( + new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDocument.Parse(principalGrantJson) + }, + new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDocument.Parse(allPrincipalsGrantJson) + }); + _graphApiService.GraphPatchAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>()) + .Returns(true); + + var withWidsJson = $$$""" + {"value":[{"id":"{{{AppObjId}}}","optionalClaims":{"accessToken":[{"name":"wids"}]}}]} + """; + _graphApiService.GraphGetAsync( + Arg.Any(), + Arg.Is(path => path.Contains("optionalClaims", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>()) + .Returns(_ => Task.FromResult(JsonDocument.Parse(withWidsJson))); + + await _validator.EnsureValidClientAppAsync( + ValidClientAppId, + ValidTenantId, + skipConfirmation: true); + + await _graphApiService.Received().GraphGetAsync( + ValidTenantId, + Arg.Is(path => path.Contains("servicePrincipals", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + GraphAuthenticationMode.Ambient); + await _graphApiService.Received().GraphGetWithResponseAsync( + ValidTenantId, + Arg.Is(path => path.Contains("oauth2PermissionGrants", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient); + await _graphApiService.Received().GraphPatchAsync( + ValidTenantId, + "/v1.0/oauth2PermissionGrants/grant-id-123", + Arg.Is(payload => JsonSerializer.Serialize(payload).Contains("\"AllPrincipals\"", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + GraphAuthenticationMode.Ambient); } #endregion @@ -613,6 +1010,31 @@ public async Task HasWidsClaimOnIssuedAccessTokenAsync_WhenTokenCarriesWids_Retu because: "the claim on a token actually issued to the app is the only evidence available for an app registration the tenant cannot read"); } + [Fact] + public async Task HasWidsClaimOnIssuedAccessTokenAsync_CustomApp_UsesResolvedClientAppToken() + { + _graphApiService.GetClientAppAccessTokenAsync( + ValidTenantId, + ValidClientAppId, + Arg.Is>(scopes => + scopes.SequenceEqual(new[] { AuthenticationConstants.UserReadScope })), + Arg.Any()) + .Returns(BuildTokenWithPayload("""{"wids":["62e90394-69f5-4237-9190-012177145e10"]}""")); + + var result = await _validator.HasWidsClaimOnIssuedAccessTokenAsync( + ValidClientAppId, + ValidTenantId); + + result.Should().BeTrue( + because: "issued-token inspection must authenticate as the resolved custom app whose optional claims are being verified"); + await _graphApiService.Received(1).GetClientAppAccessTokenAsync( + ValidTenantId, + ValidClientAppId, + Arg.Is>(scopes => + scopes.SequenceEqual(new[] { AuthenticationConstants.UserReadScope })), + Arg.Any()); + } + [Fact] public async Task HasWidsClaimOnIssuedAccessTokenAsync_WhenTokenOmitsWids_ReturnsFalse() { @@ -797,6 +1219,7 @@ private ClientAppValidator CreateValidatorWithConfirmation(IConfirmationProvider var executor = Substitute.For(executorLogger); var graphServiceLogger = Substitute.For>(); var graphApiService = Substitute.For(graphServiceLogger, executor); + ForwardAmbientCallsToExistingSubstitutions(graphApiService); // Wire up the same app/permission mocks used by the happy-path tests var requiredResourceAccess = $$""" @@ -872,6 +1295,7 @@ public async Task EnsureValidClientAppAsync_WhenUserDeclinesWithMissingPermissio var executor = Substitute.For(executorLogger); var graphServiceLogger = Substitute.For>(); var graphApiService = Substitute.For(graphServiceLogger, executor); + ForwardAmbientCallsToExistingSubstitutions(graphApiService); // App exists but has no permissions → triggers missing permissions mutation var appJson = $$""" diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs index 2e6a8c76..e81829bc 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs @@ -285,6 +285,106 @@ public async Task LookupServicePrincipalByAppIdWithResponseAsync_WhenGraphFails_ because: "presence discovery must authenticate ambiently, never as the client app whose existence is in question"); } + [Fact] + public async Task GraphGetAsync_AmbientMode_IgnoresResolvedClientAppAndRequestedScopes() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"value":[]}""") + }); + + string? requestedClientAppId = null; + string[]? requestedScopes = null; + var tokenProvider = Substitute.For(); + tokenProvider.GetMgGraphAccessTokenAsync( + Arg.Any(), + Arg.Do>(scopes => requestedScopes = scopes.ToArray()), + Arg.Any(), + Arg.Do(clientAppId => requestedClientAppId = clientAppId), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns("custom-app-token"); + var authService = FakeAuth(); + var service = new GraphApiService( + _mockLogger, + _mockExecutor, + authService, + handler, + tokenProvider, + loginHintResolver: () => Task.FromResult(null), + retryHelper: new RetryHelper(NullLogger.Instance, maxRetries: 1, baseDelaySeconds: 0)) + { + CustomClientAppId = "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6" + }; + + using var result = await service.GraphGetAsync( + "tenant-123", + "/v1.0/applications?$select=id", + CancellationToken.None, + [AuthenticationConstants.ApplicationReadAllScope], + GraphAuthenticationMode.Ambient); + + result.Should().NotBeNull( + because: "the ambient bootstrap identity must be able to diagnose an underconfigured custom app"); + requestedClientAppId.Should().BeNull( + because: "ambient metadata reads must never request a token from the app being diagnosed"); + requestedScopes.Should().BeNull( + because: "Application.Read.All must not be requested through the underconfigured custom app"); + await authService.ReceivedWithAnyArgs(1).GetAccessTokenAsync( + default!, default, default, default, default, default, default, default); + } + + [Fact] + public async Task GraphPatchAsync_AmbientMode_IgnoresResolvedClientAppAndRequestedScopes() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.NoContent)); + + string? requestedClientAppId = null; + string[]? requestedScopes = null; + var tokenProvider = Substitute.For(); + tokenProvider.GetMgGraphAccessTokenAsync( + Arg.Any(), + Arg.Do>(scopes => requestedScopes = scopes.ToArray()), + Arg.Any(), + Arg.Do(clientAppId => requestedClientAppId = clientAppId), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns("custom-app-token"); + var authService = FakeAuth(); + var service = new GraphApiService( + _mockLogger, + _mockExecutor, + authService, + handler, + tokenProvider, + loginHintResolver: () => Task.FromResult(null), + retryHelper: new RetryHelper(NullLogger.Instance, maxRetries: 1, baseDelaySeconds: 0)) + { + CustomClientAppId = "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6" + }; + + var result = await service.GraphPatchAsync( + "tenant-123", + "/v1.0/applications/application-object-id", + new { isFallbackPublicClient = true }, + CancellationToken.None, + [AuthenticationConstants.ApplicationReadAllScope], + GraphAuthenticationMode.Ambient); + + result.Should().BeTrue( + because: "repair mutations must use the ambient identity rather than depend on the app being repaired"); + requestedClientAppId.Should().BeNull( + because: "ambient repairs must never acquire a token from the underconfigured custom app"); + requestedScopes.Should().BeNull( + because: "requested custom-app scopes are intentionally ignored for ambient repairs"); + await authService.ReceivedWithAnyArgs(1).GetAccessTokenAsync( + default!, default, default, default, default, default, default, default); + } + [Fact] public async Task LookupServicePrincipalByAppIdWithResponseAsync_UsesAmbientAuthEvenWhenProbingTheResolvedClientApp() { From 432c7d0c35eeacf19603cce605e697b0209d463d Mon Sep 17 00:00:00 2001 From: Rick Brighenti <202984599+rbrighenti@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:05:22 +0000 Subject: [PATCH 06/12] Fix GCC permission and setup validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 + .../Commands/PublishCommand.cs | 32 +- .../SetupSubcommands/BlueprintSubcommand.cs | 44 + .../Services/A365CreateInstanceRunner.cs | 18 +- .../Services/AgentBlueprintService.cs | 153 ++- .../Services/GraphApiService.cs | 59 +- .../Commands/BlueprintSubcommandTests.cs | 119 +++ .../Commands/CreateInstanceCommandTests.cs | 117 +++ .../Commands/PublishCommandTests.cs | 70 ++ ...ueryEntraCommandInheritanceHandlerTests.cs | 94 ++ .../Services/A365CreateInstanceRunnerTests.cs | 102 ++ .../Services/AgentBlueprintServiceTests.cs | 986 ++++++++++++++++-- .../Services/GraphApiServiceTests.cs | 187 ++++ 13 files changed, 1879 insertions(+), 105 deletions(-) create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/A365CreateInstanceRunnerTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c5c1bafc..b9f7240e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,9 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g - `a365 develop get-token --device-code` — forces device code auth for Microsoft Graph scopes the Windows WAM broker rejects (e.g. Exchange `MailboxSettings.ReadWrite`, `ExchangeMessageTrace.Read.All`). ### Fixed +- Repeated `publish --aiteammate` runs now preserve customized manifest names instead of restoring an overlong blueprint name. +- Repeated `setup blueprint --agent-name` runs now reuse the stored valid client secret instead of creating duplicate credentials. +- `a365 query-entra blueprint-scopes` and `inheritance` now report permission-grant read failures, and `a365 create-instance` now stops safely instead of continuing when existing grants cannot be read. - `setup requirements` now validates and repairs tenant-owned fallback CLI apps with the administrator bootstrap identity, preventing false "app not found" failures when the first-party CLI app is unavailable. - Cloud-specific Graph, authority, and Agent 365 Tools endpoint overrides now apply consistently across setup, consent, authentication, query, and create-instance flows for sovereign and custom clouds. (#478) - Setup no longer fails to detect the Agent 365 CLI application in tenants where it is not yet provisioned, and reports lookup errors instead of silently switching your configured client app (#489). diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs index 5422159b..3c230043 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs @@ -221,6 +221,8 @@ public static Command CreateCommand( var updatedManifest = await UpdateManifestFileAsync(displayName, blueprintId, manifestPath); var updatedAgenticUserManifest = await UpdateAgenticUserManifestTemplateFileAsync(blueprintId, agenticUserManifestPath); + var updatedManifestNode = JsonNode.Parse(updatedManifest); + var shortName = updatedManifestNode?["name"]?["short"]?.GetValue(); if (dryRun) { @@ -238,12 +240,12 @@ public static Command CreateCommand( logger.LogInformation("Customize before packaging:"); logger.LogInformation(" version - increment for republishing (e.g., 1.0.1), must be higher than previous"); - if (string.IsNullOrWhiteSpace(displayName)) + if (string.IsNullOrWhiteSpace(shortName)) logger.LogWarning(" name.short - not set; edit manifest.json to provide a short name (30 chars max) before packaging"); - else if (displayName.Length > 30) - logger.LogWarning(" name.short - EXCEEDS 30 chars ({Length}), currently: \"{Name}\" -- shorten before packaging", displayName.Length, displayName); + else if (shortName.Length > 30) + logger.LogWarning(" name.short - EXCEEDS 30 chars ({Length}), currently: \"{Name}\" -- shorten before packaging", shortName.Length, shortName); else - logger.LogInformation(" name.short - 30 chars max, currently: \"{Name}\"", displayName); + logger.LogInformation(" name.short - 30 chars max, currently: \"{Name}\"", shortName); logger.LogInformation(" name.full - displayed in Microsoft 365"); logger.LogInformation(" description.short - 1-2 sentences"); @@ -340,8 +342,8 @@ private static async Task UpdateManifestFileAsync(string? displayName, s node["name"] = nameObj; } - nameObj["short"] = displayName; - nameObj["full"] = displayName; + SetManifestNameDefault(nameObj, "short", "Your Agent Name", displayName); + SetManifestNameDefault(nameObj, "full", "Your Agent Full Name", displayName); } if (node["bots"] is JsonArray bots && bots.Count > 0 && bots[0] is JsonObject botObj) @@ -359,6 +361,24 @@ private static async Task UpdateManifestFileAsync(string? displayName, s return node.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); } + private static void SetManifestNameDefault( + JsonObject name, + string propertyName, + string templateValue, + string displayName) + { + var currentValue = name[propertyName] is JsonValue valueNode && + valueNode.TryGetValue(out var value) + ? value + : null; + + if (string.IsNullOrWhiteSpace(currentValue) || + string.Equals(currentValue, templateValue, StringComparison.Ordinal)) + { + name[propertyName] = displayName; + } + } + private static async Task UpdateAgenticUserManifestTemplateFileAsync(string blueprintId, string agenticUserManifestPath) { var contents = await File.ReadAllTextAsync(agenticUserManifestPath); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs index e84dcadf..70c8cc47 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs @@ -638,6 +638,8 @@ public static async Task CreateBlueprintImplementationA logger.LogDebug("Blueprint created: {Name} (Object ID: {ObjectId}, App ID: {AppId})", setupConfig.AgentBlueprintDisplayName, blueprintObjectId, blueprintAppId); + RestoreExistingBlueprintSecret(setupConfig, generatedConfig, blueprintAppId, logger); + // Update generated config with blueprint details, preserving all existing fields generatedConfig["agentBlueprintId"] = blueprintAppId; generatedConfig["agentBlueprintObjectId"] = blueprintObjectId; @@ -789,6 +791,48 @@ await PermissionsSubcommand.ConfigureCustomPermissionsAsync( }; } + internal static void RestoreExistingBlueprintSecret( + Agent365Config setupConfig, + JsonObject generatedConfig, + string? resolvedBlueprintId, + ILogger logger) + { + if (!string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintClientSecret) || + string.IsNullOrWhiteSpace(resolvedBlueprintId)) + { + return; + } + + var storedBlueprintId = generatedConfig["agentBlueprintId"] is JsonValue blueprintIdNode && + blueprintIdNode.TryGetValue(out var blueprintId) + ? blueprintId + : null; + + if (!string.Equals(storedBlueprintId, resolvedBlueprintId, StringComparison.OrdinalIgnoreCase)) + { + logger.LogDebug( + "Stored blueprint ID does not match the resolved blueprint; the stored client secret will not be reused."); + return; + } + + var storedSecret = generatedConfig["agentBlueprintClientSecret"] is JsonValue secretNode && + secretNode.TryGetValue(out var secret) + ? secret + : null; + + if (string.IsNullOrWhiteSpace(storedSecret)) + { + return; + } + + setupConfig.AgentBlueprintClientSecret = storedSecret; + setupConfig.AgentBlueprintClientSecretProtected = + generatedConfig["agentBlueprintClientSecretProtected"] is JsonValue protectedNode && + protectedNode.TryGetValue(out var isProtected) && + isProtected; + logger.LogDebug("Loaded the existing blueprint client secret from generated configuration."); + } + /// /// Ensures AgentApplication.Create permission with retry logic /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs index 4dc70369..6ff60507 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs @@ -399,8 +399,22 @@ string GetConfig(string name) => _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); + List<(string resourceId, string scope, string consentType)> existingGrants; + try + { + existingGrants = await _graphService.GetOauth2PermissionGrantsAsync( + tenantId, + agenticSpObjectId, + cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError( + ex, + "Failed to read existing OAuth2 permission grants for agent identity {ServicePrincipalId}; no grant changes were attempted.", + agenticSpObjectId); + return false; + } // Build a lookup: resourceSpObjectId -> set of already-granted scopes var existingScopesByResource = new Dictionary>(StringComparer.OrdinalIgnoreCase); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs index 80db28d5..3ecf9ddd 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs @@ -547,7 +547,18 @@ public virtual async Task DeleteAgentUserAsync( ?? new List(); if (appIds.Count == 0) return result; - var blueprintSpObjectId = await _graphApiService.LookupServicePrincipalByAppIdAsync(tenantId, blueprintAppId, ct, requiredScopes); + var blueprintLookup = await _graphApiService.LookupServicePrincipalByAppIdWithResponseAsync( + tenantId, + blueprintAppId, + ct, + GraphAuthenticationMode.Ambient); + if (!blueprintLookup.IsSuccess) + { + throw new InvalidOperationException( + $"Microsoft Graph could not look up the blueprint service principal for app ID '{blueprintAppId}': {blueprintLookup.FailureReason}"); + } + + var blueprintSpObjectId = blueprintLookup.ServicePrincipalId; if (string.IsNullOrWhiteSpace(blueprintSpObjectId)) { _logger.LogWarning("Blueprint service principal not found for app ID {BlueprintAppId} — cannot enumerate granted permissions.", blueprintAppId); @@ -556,10 +567,12 @@ public virtual async Task DeleteAgentUserAsync( // One bulk fetch each — by resource SP object ID, not by app ID, so we'll need a resolution table. var delegatedByResourceSpId = new Dictionary>(StringComparer.OrdinalIgnoreCase); - var allGrants = await _graphApiService.GetOauth2PermissionGrantsAsync(tenantId, blueprintSpObjectId, ct); + var allGrants = await _graphApiService.GetOauth2PermissionGrantsAsync( + tenantId, + blueprintSpObjectId, + ct); foreach (var (resourceSpId, scope, _) in allGrants) { - if (string.IsNullOrWhiteSpace(resourceSpId)) continue; if (!delegatedByResourceSpId.TryGetValue(resourceSpId, out var list)) { list = new List(); @@ -571,31 +584,75 @@ public virtual async Task DeleteAgentUserAsync( } var appRoleIdsByResourceSpId = new Dictionary>(StringComparer.OrdinalIgnoreCase); - using (var assignmentsDoc = await _graphApiService.GraphGetAsync( - tenantId, $"/v1.0/servicePrincipals/{blueprintSpObjectId}/appRoleAssignments", ct, scopes: requiredScopes)) + var assignmentsResponse = await _graphApiService.GraphGetWithResponseAsync( + tenantId, + $"/v1.0/servicePrincipals/{blueprintSpObjectId}/appRoleAssignments", + ct: ct, + authenticationMode: GraphAuthenticationMode.Ambient); + using (var assignmentsDoc = assignmentsResponse.Json) { - if (assignmentsDoc != null && - assignmentsDoc.RootElement.TryGetProperty("value", out var assignmentsArr) && - assignmentsArr.ValueKind == JsonValueKind.Array) + if (!assignmentsResponse.IsSuccess) + { + var status = assignmentsResponse.StatusCode > 0 + ? $"HTTP {assignmentsResponse.StatusCode} {assignmentsResponse.ReasonPhrase}".TrimEnd() + : assignmentsResponse.ReasonPhrase; + throw new InvalidOperationException( + $"Microsoft Graph could not read app role assignments for blueprint service principal '{blueprintSpObjectId}': {status}."); + } + + if (assignmentsDoc is null || + assignmentsDoc.RootElement.ValueKind != JsonValueKind.Object || + !assignmentsDoc.RootElement.TryGetProperty("value", out var assignmentsArr) || + assignmentsArr.ValueKind != JsonValueKind.Array) { - foreach (var assignment in assignmentsArr.EnumerateArray()) + throw new InvalidOperationException( + $"Microsoft Graph returned an invalid app role assignments response for blueprint service principal '{blueprintSpObjectId}'."); + } + + foreach (var assignment in assignmentsArr.EnumerateArray()) + { + if (assignment.ValueKind != JsonValueKind.Object || + !assignment.TryGetProperty("resourceId", out var resourceIdElement) || + resourceIdElement.ValueKind != JsonValueKind.String || + !assignment.TryGetProperty("appRoleId", out var appRoleIdElement) || + appRoleIdElement.ValueKind != JsonValueKind.String) { - var resId = assignment.TryGetProperty("resourceId", out var r) ? r.GetString() : null; - var roleId = assignment.TryGetProperty("appRoleId", out var ar) ? ar.GetString() : null; - if (string.IsNullOrWhiteSpace(resId) || string.IsNullOrWhiteSpace(roleId)) continue; - if (!appRoleIdsByResourceSpId.TryGetValue(resId, out var list)) - { - list = new List(); - appRoleIdsByResourceSpId[resId] = list; - } - list.Add(roleId); + throw new InvalidOperationException( + $"Microsoft Graph returned an invalid app role assignment for blueprint service principal '{blueprintSpObjectId}'."); } + + var resourceId = resourceIdElement.GetString()!; + var appRoleId = appRoleIdElement.GetString()!; + if (!Guid.TryParse(resourceId, out _) || + !Guid.TryParse(appRoleId, out _)) + { + throw new InvalidOperationException( + $"Microsoft Graph returned an invalid app role assignment for blueprint service principal '{blueprintSpObjectId}'."); + } + + if (!appRoleIdsByResourceSpId.TryGetValue(resourceId, out var list)) + { + list = new List(); + appRoleIdsByResourceSpId[resourceId] = list; + } + list.Add(appRoleId); } } foreach (var resourceAppId in appIds) { - var resourceSpId = await _graphApiService.LookupServicePrincipalByAppIdAsync(tenantId, resourceAppId, ct, requiredScopes); + var resourceLookup = await _graphApiService.LookupServicePrincipalByAppIdWithResponseAsync( + tenantId, + resourceAppId, + ct, + GraphAuthenticationMode.Ambient); + if (!resourceLookup.IsSuccess) + { + throw new InvalidOperationException( + $"Microsoft Graph could not look up the resource service principal for app ID '{resourceAppId}': {resourceLookup.FailureReason}"); + } + + var resourceSpId = resourceLookup.ServicePrincipalId; if (string.IsNullOrWhiteSpace(resourceSpId)) { _logger.LogDebug("Resource SP not found for app ID {ResourceAppId} — granted permissions cannot be enumerated.", resourceAppId); @@ -613,18 +670,56 @@ public virtual async Task DeleteAgentUserAsync( // role IDs fall back to a "" placeholder so the operator can still see them. var roleIdSet = new HashSet(roleIds, StringComparer.OrdinalIgnoreCase); var nameById = new Dictionary(StringComparer.OrdinalIgnoreCase); - using var resourceSpDoc = await _graphApiService.GraphGetAsync( - tenantId, $"/v1.0/servicePrincipals/{resourceSpId}?$select=appRoles", ct, scopes: requiredScopes); - if (resourceSpDoc != null && - resourceSpDoc.RootElement.TryGetProperty("appRoles", out var rolesEl) && - rolesEl.ValueKind == JsonValueKind.Array) + var roleMetadataResponse = await _graphApiService.GraphGetWithResponseAsync( + tenantId, + $"/v1.0/servicePrincipals/{resourceSpId}?$select=appRoles", + ct: ct, + authenticationMode: GraphAuthenticationMode.Ambient); + using var resourceSpDoc = roleMetadataResponse.Json; + if (!roleMetadataResponse.IsSuccess) + { + var status = roleMetadataResponse.StatusCode > 0 + ? $"HTTP {roleMetadataResponse.StatusCode} {roleMetadataResponse.ReasonPhrase}".TrimEnd() + : roleMetadataResponse.ReasonPhrase; + throw new InvalidOperationException( + $"Microsoft Graph could not read app role metadata for resource service principal '{resourceSpId}': {status}."); + } + + if (resourceSpDoc is null || + resourceSpDoc.RootElement.ValueKind != JsonValueKind.Object || + !resourceSpDoc.RootElement.TryGetProperty("appRoles", out var rolesEl) || + rolesEl.ValueKind != JsonValueKind.Array) { - foreach (var role in rolesEl.EnumerateArray()) + throw new InvalidOperationException( + $"Microsoft Graph returned invalid app role metadata for resource service principal '{resourceSpId}'."); + } + + foreach (var role in rolesEl.EnumerateArray()) + { + if (role.ValueKind != JsonValueKind.Object || + !role.TryGetProperty("id", out var idElement) || + idElement.ValueKind != JsonValueKind.String || + !role.TryGetProperty("value", out var valueElement) || + (valueElement.ValueKind != JsonValueKind.String && + valueElement.ValueKind != JsonValueKind.Null)) + { + throw new InvalidOperationException( + $"Microsoft Graph returned invalid app role metadata for resource service principal '{resourceSpId}'."); + } + + var roleId = idElement.GetString()!; + if (!Guid.TryParse(roleId, out _)) + { + throw new InvalidOperationException( + $"Microsoft Graph returned invalid app role metadata for resource service principal '{resourceSpId}'."); + } + + var roleValue = valueElement.ValueKind == JsonValueKind.String + ? valueElement.GetString() + : null; + if (!string.IsNullOrWhiteSpace(roleValue)) { - var id = role.TryGetProperty("id", out var idEl) ? idEl.GetString() : null; - var name = role.TryGetProperty("value", out var valEl) ? valEl.GetString() : null; - if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(name)) - nameById[id] = name; + nameById[roleId] = roleValue; } } appRoleNames = roleIdSet diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index 078b9add..db3b2cea 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -1321,7 +1321,10 @@ public async Task CreatePrincipalOauth2PermissionGrantAsync( /// 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 + /// List of grants with their scope strings and consent types. + /// + /// Microsoft Graph could not return a valid permission-grants response. + /// public virtual async Task> GetOauth2PermissionGrantsAsync( string tenantId, string clientSpObjectId, @@ -1329,22 +1332,58 @@ public async Task CreatePrincipalOauth2PermissionGrantAsync( { var grants = new List<(string resourceId, string scope, string consentType)>(); - using var doc = await GraphGetAsync( + var response = await GraphGetWithResponseAsync( tenantId, $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{clientSpObjectId}'", - ct); + ct: ct, + authenticationMode: GraphAuthenticationMode.Ambient); + using var doc = response.Json; + + if (!response.IsSuccess) + { + var status = response.StatusCode > 0 + ? $"HTTP {response.StatusCode} {response.ReasonPhrase}".TrimEnd() + : response.ReasonPhrase; + throw new InvalidOperationException( + string.IsNullOrWhiteSpace(status) + ? $"Microsoft Graph could not read OAuth2 permission grants for service principal '{clientSpObjectId}'." + : $"Microsoft Graph could not read OAuth2 permission grants for service principal '{clientSpObjectId}': {status}."); + } - if (doc == null) return grants; + if (doc is null || + doc.RootElement.ValueKind != JsonValueKind.Object || + !doc.RootElement.TryGetProperty("value", out var arr) || + arr.ValueKind != JsonValueKind.Array) + { + throw new InvalidOperationException( + $"Microsoft Graph returned an invalid OAuth2 permission grants response for service principal '{clientSpObjectId}'."); + } - if (doc.RootElement.TryGetProperty("value", out var arr)) + foreach (var grant in arr.EnumerateArray()) { - foreach (var grant in arr.EnumerateArray()) + if (grant.ValueKind != JsonValueKind.Object || + !grant.TryGetProperty("resourceId", out var rid) || + rid.ValueKind != JsonValueKind.String || + !grant.TryGetProperty("scope", out var scopeElement) || + scopeElement.ValueKind != JsonValueKind.String || + !grant.TryGetProperty("consentType", out var consentTypeElement) || + consentTypeElement.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(consentTypeElement.GetString())) { - 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)); + throw new InvalidOperationException( + $"Microsoft Graph returned an invalid OAuth2 permission grant for service principal '{clientSpObjectId}'."); } + + var resourceId = rid.GetString()!; + if (!Guid.TryParse(resourceId, out _)) + { + throw new InvalidOperationException( + $"Microsoft Graph returned an invalid OAuth2 permission grant for service principal '{clientSpObjectId}'."); + } + + var scope = scopeElement.GetString()!; + var consentType = consentTypeElement.GetString()!; + grants.Add((resourceId, scope, consentType)); } return grants; diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs index 71f31779..3e455926 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs @@ -1858,6 +1858,125 @@ await File.WriteAllTextAsync(generatedConfigPath, generatedConfig.ToJsonString( } } + [Fact] + public void RestoreExistingBlueprintSecret_WhenResolvedBlueprintMatches_ReusesStoredSecret() + { + var setupConfig = new Agent365Config(); + var generatedConfig = new JsonObject + { + ["agentBlueprintId"] = "BLUEPRINT-ID", + ["agentBlueprintClientSecret"] = "stored-secret", + ["agentBlueprintClientSecretProtected"] = true + }; + + BlueprintSubcommand.RestoreExistingBlueprintSecret( + setupConfig, + generatedConfig, + "blueprint-id", + _mockLogger); + + setupConfig.AgentBlueprintClientSecret.Should().Be( + "stored-secret", + because: "an idempotent setup run must validate and reuse the credential stored for the existing blueprint"); + setupConfig.AgentBlueprintClientSecretProtected.Should().BeTrue( + because: "the stored protection mode is required to validate the existing credential"); + } + + [Fact] + public void RestoreExistingBlueprintSecret_WhenResolvedBlueprintDiffers_DoesNotReuseStoredSecret() + { + var setupConfig = new Agent365Config(); + var generatedConfig = new JsonObject + { + ["agentBlueprintId"] = "different-blueprint-id", + ["agentBlueprintClientSecret"] = "stored-secret", + ["agentBlueprintClientSecretProtected"] = false + }; + + BlueprintSubcommand.RestoreExistingBlueprintSecret( + setupConfig, + generatedConfig, + "resolved-blueprint-id", + _mockLogger); + + setupConfig.AgentBlueprintClientSecret.Should().BeNull( + because: "a credential must never be reused for a different blueprint"); + } + + [Fact] + public void RestoreExistingBlueprintSecret_WhenSetupConfigAlreadyHasSecret_PreservesResolvedValue() + { + var setupConfig = new Agent365Config + { + AgentBlueprintClientSecret = "resolved-secret", + AgentBlueprintClientSecretProtected = false + }; + var generatedConfig = new JsonObject + { + ["agentBlueprintId"] = "blueprint-id", + ["agentBlueprintClientSecret"] = "stored-secret", + ["agentBlueprintClientSecretProtected"] = true + }; + + BlueprintSubcommand.RestoreExistingBlueprintSecret( + setupConfig, + generatedConfig, + "blueprint-id", + _mockLogger); + + setupConfig.AgentBlueprintClientSecret.Should().Be( + "resolved-secret", + because: "an explicitly resolved credential takes precedence over generated state"); + setupConfig.AgentBlueprintClientSecretProtected.Should().BeFalse( + because: "the protection flag must remain paired with the resolved credential"); + } + + [Fact] + public void RestoreExistingBlueprintSecret_WhenProtectionFlagIsMissing_TreatsStoredSecretAsPlaintext() + { + var setupConfig = new Agent365Config(); + var generatedConfig = new JsonObject + { + ["agentBlueprintId"] = "blueprint-id", + ["agentBlueprintClientSecret"] = "stored-secret" + }; + + BlueprintSubcommand.RestoreExistingBlueprintSecret( + setupConfig, + generatedConfig, + "blueprint-id", + _mockLogger); + + setupConfig.AgentBlueprintClientSecret.Should().Be( + "stored-secret", + because: "generated configurations created before the protection flag was added may contain plaintext secrets"); + setupConfig.AgentBlueprintClientSecretProtected.Should().BeFalse( + because: "a missing protection flag represents the legacy plaintext storage format"); + } + + [Fact] + public void RestoreExistingBlueprintSecret_WhenStoredBlueprintIdIsNotAString_DoesNotReuseStoredSecret() + { + var setupConfig = new Agent365Config(); + var generatedConfig = new JsonObject + { + ["agentBlueprintId"] = 42, + ["agentBlueprintClientSecret"] = "stored-secret", + ["agentBlueprintClientSecretProtected"] = false + }; + + var act = () => BlueprintSubcommand.RestoreExistingBlueprintSecret( + setupConfig, + generatedConfig, + "blueprint-id", + _mockLogger); + + act.Should().NotThrow( + because: "malformed generated state must not crash an idempotent setup run"); + setupConfig.AgentBlueprintClientSecret.Should().BeNull( + because: "a credential cannot be safely associated with a malformed blueprint identifier"); + } + #endregion #region Ownership Check Tests 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 d785dcf0..9f420549 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 @@ -2,6 +2,10 @@ // Licensed under the MIT License. using System.CommandLine; +using System.CommandLine.Builder; +using System.CommandLine.IO; +using System.CommandLine.Parsing; +using FluentAssertions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Agents.A365.DevTools.Cli.Commands; @@ -16,8 +20,14 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; /// /// Tests for CreateInstanceCommand functionality /// +[Collection("ConfigTests")] public class CreateInstanceCommandTests { + private const string TenantId = "11111111-1111-1111-1111-111111111111"; + private const string AgentBlueprintId = "22222222-2222-2222-2222-222222222222"; + private const string AgenticAppId = "33333333-3333-3333-3333-333333333333"; + private const string AgenticUserId = "44444444-4444-4444-4444-444444444444"; + private readonly ILogger _mockLogger; private readonly ConfigService _mockConfigService; private readonly CommandExecutor _mockExecutor; @@ -115,4 +125,111 @@ public async Task CreateInstance_WhenConfigFileNotFound_ShouldReturnExitCode2() // Assert Assert.Equal(2, result); } + + [Theory] + [InlineData("", "Instance creation failed")] + [InlineData("identity", "Identity creation failed")] + public async Task CreateInstance_WhenGrantPreReadFails_ExitsNonzeroShowsErrorAndDoesNotMutate( + string subcommand, + string expectedCommandError) + { + var originalDirectory = Environment.CurrentDirectory; + var testDirectory = Path.Combine(Path.GetTempPath(), $"create-instance-command-{Guid.NewGuid():N}"); + Directory.CreateDirectory(testDirectory); + + try + { + await File.WriteAllTextAsync( + Path.Combine(testDirectory, "a365.config.json"), + $$""" + { + "tenantId": "{{TenantId}}", + "environment": "prod" + } + """); + await File.WriteAllTextAsync( + Path.Combine(testDirectory, "a365.generated.config.json"), + $$""" + { + "agentBlueprintId": "{{AgentBlueprintId}}", + "agentBlueprintClientSecret": "test-secret", + "AgenticAppId": "{{AgenticAppId}}", + "AgenticUserId": "{{AgenticUserId}}" + } + """); + Environment.CurrentDirectory = testDirectory; + + var resolver = Substitute.For(); + resolver.ResolveAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Is(false), + Arg.Any()) + .Returns(new Agent365Config + { + TenantId = TenantId, + AgentBlueprintId = AgentBlueprintId, + AgenticAppId = AgenticAppId, + AgenticUserId = AgenticUserId + }); + _mockGraphApiService.LookupServicePrincipalByAppIdAsync( + TenantId, + AgenticAppId, + Arg.Any(), + Arg.Any?>()) + .Returns("agent-sp-object-id"); + _mockGraphApiService.GetOauth2PermissionGrantsAsync( + TenantId, + "agent-sp-object-id", + Arg.Any()) + .Returns>>(_ => + throw new InvalidOperationException("connection reset")); + + var command = CreateInstanceCommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService, + resolver); + var parser = new CommandLineBuilder(command).UseDefaults().Build(); + + var exitCode = await parser.InvokeAsync(subcommand, new TestConsole()); + + exitCode.Should().NotBe(0, + because: "a failed idempotency pre-read must fail both the root all-steps handler and the identity handler"); + LoggerReceivedContaining(LogLevel.Error, "A365CreateInstanceRunner failed").Should().BeTrue( + because: "the command must visibly report that instance execution stopped"); + LoggerReceivedContaining(LogLevel.Error, expectedCommandError).Should().BeTrue( + because: "each materially distinct command handler must surface its own operation-level failure"); + await _mockGraphApiService.DidNotReceive().EnsureServicePrincipalForAppIdAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any()); + await _mockGraphApiService.DidNotReceive().CreateOrUpdateOauth2PermissionGrantAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any?>()); + } + finally + { + Environment.CurrentDirectory = originalDirectory; + Directory.Delete(testDirectory, recursive: true); + } + } + + private bool LoggerReceivedContaining(LogLevel level, string fragment) + { + return _mockLogger.ReceivedCalls() + .Where(call => call.GetMethodInfo().Name == nameof(ILogger.Log)) + .Select(call => call.GetArguments()) + .Where(args => args.Length >= 3 && args[0] is LogLevel loggedLevel && loggedLevel == level) + .Select(args => args[2]?.ToString() ?? string.Empty) + .Any(message => message.Contains(fragment, StringComparison.Ordinal)); + } } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandTests.cs index f33ae1db..22da5d7c 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.CommandLine; +using System.Text.Json.Nodes; using FluentAssertions; using Microsoft.Agents.A365.DevTools.Cli.Commands; using Microsoft.Agents.A365.DevTools.Cli.Models; @@ -181,6 +182,75 @@ public async Task PublishCommand_WithDisplayNameExceeding30Chars_LogsWarning() } } + [Fact] + public async Task PublishCommand_WithCustomizedManifestNames_PreservesNamesAndPackagesThem() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var manifestDir = Path.Combine(tempDir, "manifest"); + Directory.CreateDirectory(manifestDir); + + try + { + await File.WriteAllTextAsync( + Path.Combine(manifestDir, "manifest.json"), + """ + { + "id": "old-id", + "name": { + "short": "Custom Short Name", + "full": "Custom Full Name" + } + } + """); + await File.WriteAllTextAsync( + Path.Combine(manifestDir, "agenticUserTemplateManifest.json"), + "{\"agentIdentityBlueprintId\":\"old-id\"}"); + + _configService.LoadAsync(Arg.Any(), Arg.Any()).Returns(new Agent365Config + { + AgentBlueprintId = "test-blueprint-id", + AgentBlueprintDisplayName = "This Blueprint Display Name Is Longer Than Thirty Characters", + TenantId = "test-tenant", + DeploymentProjectPath = tempDir + }); + + var root = new RootCommand(); + root.AddCommand(PublishCommand.CreateCommand(_logger, _configService, _manifestTemplateService)); + + var exitCode = await root.InvokeAsync("publish"); + + exitCode.Should().Be(0, because: "a customized manifest should remain publishable on subsequent runs"); + var savedManifest = JsonNode.Parse( + await File.ReadAllTextAsync(Path.Combine(manifestDir, "manifest.json")))!; + savedManifest["name"]!["short"]!.GetValue().Should().Be( + "Custom Short Name", + because: "publish must not overwrite a user-customized short name with an invalid blueprint display name"); + savedManifest["name"]!["full"]!.GetValue().Should().Be( + "Custom Full Name", + because: "publish must preserve the user-facing full name customized in the manifest"); + + using var archive = System.IO.Compression.ZipFile.OpenRead(Path.Combine(manifestDir, "manifest.zip")); + var manifestEntry = archive.GetEntry("manifest.json"); + manifestEntry.Should().NotBeNull(because: "the published package must contain the customized manifest"); + using var reader = new StreamReader(manifestEntry!.Open()); + var packagedManifest = JsonNode.Parse(await reader.ReadToEndAsync())!; + packagedManifest["name"]!["short"]!.GetValue().Should().Be( + "Custom Short Name", + because: "the package must contain the preserved valid short name"); + + _logger.DidNotReceive().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("EXCEEDS 30 chars")), + Arg.Any(), + Arg.Any>()); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + [Fact] public async Task PublishCommand_WithException_ShouldReturnExitCode1() { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/QueryEntraCommandInheritanceHandlerTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/QueryEntraCommandInheritanceHandlerTests.cs index 2dacef58..9fc2ffa4 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/QueryEntraCommandInheritanceHandlerTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/QueryEntraCommandInheritanceHandlerTests.cs @@ -300,4 +300,98 @@ public async Task InheritanceSubcommand_WhenBlueprintServiceThrows_LogsError_And LoggerReceivedContaining(LogLevel.Error, "Failed to query inheritable permissions").Should().BeTrue( because: "the error message must clearly identify the operation that failed so operators can correlate with logs"); } + + [Fact] + public async Task InheritanceSubcommand_WhenGrantEnumerationIsForbidden_LogsError_AndExitsOne() + { + SetupResolver(new Agent365Config { TenantId = ValidTenantId, AgentBlueprintId = ValidBlueprintId }); + _mockBlueprintService.ListInheritablePermissionsAsync( + ValidTenantId, ValidBlueprintId, Arg.Any?>(), Arg.Any()) + .Returns(Task.FromResult(new List<(string, bool, bool)> + { + (GraphAppId, true, true) + })); + _mockBlueprintService.GetBlueprintSpGrantsAsync( + ValidTenantId, + ValidBlueprintId, + Arg.Any>(), + Arg.Any?>(), + Arg.Any()) + .Returns>>(_ => + throw new InvalidOperationException("Microsoft Graph could not read OAuth2 permission grants: HTTP 403 Forbidden.")); + + var parser = new CommandLineBuilder(BuildRootCommand()).Build(); + var exitCode = await parser.InvokeAsync("inheritance --agent-name test-agent", new TestConsole()); + + exitCode.Should().Be(1, + because: "an unreadable grants table is not evidence of zero grants and must fail scripted inheritance checks"); + LoggerReceivedContaining(LogLevel.Error, "HTTP 403 Forbidden").Should().BeTrue( + because: "operators need the authorization failure surfaced instead of a misleading no-permissions result"); + } + + [Fact] + public async Task BlueprintScopesSubcommand_WhenGrantEnumerationIsForbidden_LogsError_AndExitsOne() + { + SetupResolver(new Agent365Config { TenantId = ValidTenantId, AgentBlueprintId = ValidBlueprintId }); + _mockBlueprintService.ListInheritablePermissionsAsync( + ValidTenantId, ValidBlueprintId, Arg.Any?>(), Arg.Any()) + .Returns(Task.FromResult(new List<(string, bool, bool)> + { + (GraphAppId, true, true) + })); + _mockBlueprintService.GetBlueprintSpGrantsAsync( + ValidTenantId, + ValidBlueprintId, + Arg.Any>(), + Arg.Any?>(), + Arg.Any()) + .Returns>>(_ => + throw new InvalidOperationException("Microsoft Graph could not read OAuth2 permission grants: HTTP 403 Forbidden.")); + + var parser = new CommandLineBuilder(BuildRootCommand()).Build(); + var exitCode = await parser.InvokeAsync("blueprint-scopes --agent-name test-agent", new TestConsole()); + + exitCode.Should().Be(1, + because: "blueprint-scopes must not report zero permissions when Graph denied access to the grants table"); + LoggerReceivedContaining(LogLevel.Error, "HTTP 403 Forbidden").Should().BeTrue( + because: "the command must make the denied administrative read visible to the operator"); + } + + [Fact] + public async Task BlueprintScopesSubcommand_WhenKnownResourcesHaveNoGrants_ReportsEmptyPermissions_AndExitsZero() + { + SetupResolver(new Agent365Config { TenantId = ValidTenantId, AgentBlueprintId = ValidBlueprintId }); + _mockBlueprintService.ListInheritablePermissionsAsync( + ValidTenantId, ValidBlueprintId, Arg.Any?>(), Arg.Any()) + .Returns(Task.FromResult(new List<(string, bool, bool)> + { + (GraphAppId, true, true), + (ObservabilityAppId, true, true) + })); + _mockBlueprintService.GetBlueprintSpGrantsAsync( + ValidTenantId, + ValidBlueprintId, + Arg.Any>(), + Arg.Any?>(), + Arg.Any()) + .Returns(Task.FromResult(new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [GraphAppId] = (Array.Empty(), Array.Empty()), + [ObservabilityAppId] = (Array.Empty(), Array.Empty()) + })); + + var parser = new CommandLineBuilder(BuildRootCommand()).Build(); + var exitCode = await parser.InvokeAsync("blueprint-scopes --agent-name test-agent", new TestConsole()); + + exitCode.Should().Be(0, + because: "a successful administrative read proving that known resources have zero grants is a valid query result"); + LoggerReceivedContaining(LogLevel.Information, "Delegated permissions (0): (none)").Should().BeTrue( + because: "the command must visibly report empty delegated permissions rather than conflating them with an unreadable grants table"); + LoggerReceivedContaining(LogLevel.Information, "Application permissions (0): (none)").Should().BeTrue( + because: "the command must visibly report empty application permissions for successfully read resources"); + LoggerReceivedContaining(LogLevel.Information, "0 of 2 resource(s) have at least one granted permission").Should().BeTrue( + because: "the summary must preserve the known-resource versus zero-grants distinction for scripted diagnostics"); + LoggerReceivedContaining(LogLevel.Error, "Failed to query blueprint granted permissions").Should().BeFalse( + because: "a successful empty grants response is not an error"); + } } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/A365CreateInstanceRunnerTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/A365CreateInstanceRunnerTests.cs new file mode 100644 index 00000000..78ff814c --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/A365CreateInstanceRunnerTests.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; + +public sealed class A365CreateInstanceRunnerTests : IDisposable +{ + private readonly string _testDirectory; + + public A365CreateInstanceRunnerTests() + { + _testDirectory = Path.Combine(Path.GetTempPath(), $"a365-create-instance-{Guid.NewGuid():N}"); + Directory.CreateDirectory(_testDirectory); + } + + [Fact] + public async Task RunAsync_WhenExistingGrantReadFails_LogsErrorReturnsFalseAndDoesNotWriteGrants() + { + var configPath = Path.Combine(_testDirectory, "a365.config.json"); + var generatedConfigPath = Path.Combine(_testDirectory, "a365.generated.config.json"); + await File.WriteAllTextAsync( + configPath, + """ + { + "tenantId": "11111111-1111-1111-1111-111111111111", + "environment": "prod" + } + """); + await File.WriteAllTextAsync( + generatedConfigPath, + """ + { + "agentBlueprintId": "22222222-2222-2222-2222-222222222222", + "agentBlueprintClientSecret": "test-secret", + "AgenticAppId": "33333333-3333-3333-3333-333333333333", + "AgenticUserId": "44444444-4444-4444-4444-444444444444" + } + """); + + var logger = Substitute.For>(); + var executor = Substitute.For(NullLogger.Instance); + var graph = Substitute.ForPartsOf( + NullLogger.Instance, + executor, + (Func>?)(() => Task.FromResult(null))); + graph.LookupServicePrincipalByAppIdAsync( + "11111111-1111-1111-1111-111111111111", + "33333333-3333-3333-3333-333333333333", + Arg.Any(), + Arg.Any?>()) + .Returns("agent-sp-object-id"); + graph.GetOauth2PermissionGrantsAsync( + "11111111-1111-1111-1111-111111111111", + "agent-sp-object-id", + Arg.Any()) + .Returns>>(_ => + throw new InvalidOperationException("Microsoft Graph could not read OAuth2 permission grants: HTTP 403 Forbidden.")); + + var runner = new A365CreateInstanceRunner(logger, executor, graph); + + var succeeded = await runner.RunAsync( + configPath, + generatedConfigPath, + step: "identity"); + + succeeded.Should().BeFalse( + because: "grant creation cannot safely continue when the idempotency pre-read is unreadable"); + logger.Received().Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(state => state.ToString()!.Contains("no grant changes were attempted", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any>()); + await graph.DidNotReceive().EnsureServicePrincipalForAppIdAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any()); + await graph.DidNotReceive().CreateOrUpdateOauth2PermissionGrantAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), + Arg.Any?>()); + } + + public void Dispose() + { + if (Directory.Exists(_testDirectory)) + { + Directory.Delete(_testDirectory, recursive: true); + } + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs index a2ee275f..b2004d5b 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs @@ -243,6 +243,9 @@ public async Task SetInheritablePermissionsAsync_WhenLegacyEnumeratedEntryExists [Fact] public async Task GetBlueprintSpGrantsAsync_ReturnsScopesAndResolvedRoleNames() { + const string resourceSpId = "33333333-3333-3333-3333-333333333333"; + const string appRoleId = "44444444-4444-4444-4444-444444444444"; + // Arrange — single resource with one delegated scope grant and one app role assignment on // the blueprint SP. The role assignment's appRoleId must be resolved to a human-readable // name via a lookup of the resource SP's appRoles array (the same shape Graph returns). @@ -268,40 +271,125 @@ public async Task GetBlueprintSpGrantsAsync_ReturnsScopesAndResolvedRoleNames() // 2) GetOauth2PermissionGrantsAsync — one delegated grant for the resource handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(JsonSerializer.Serialize(new { value = new[] { new { resourceId = "obs-sp-id", scope = "otel-write extra-scope", consentType = "AllPrincipals" } } })) + Content = new StringContent(JsonSerializer.Serialize(new { value = new[] { new { resourceId = resourceSpId, scope = "otel-write extra-scope", consentType = "AllPrincipals" } } })) }); // 3) appRoleAssignments on the blueprint SP — one assignment for the resource handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(JsonSerializer.Serialize(new { value = new[] { new { resourceId = "obs-sp-id", appRoleId = "role-guid-1" } } })) + Content = new StringContent(JsonSerializer.Serialize(new { value = new[] { new { resourceId = resourceSpId, appRoleId } } })) }); // 4) LookupServicePrincipalByAppIdAsync(resourceAppId) -> resource SP id handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) { - Content = new StringContent(JsonSerializer.Serialize(new { value = new[] { new { id = "obs-sp-id" } } })) + Content = new StringContent(JsonSerializer.Serialize(new { value = new[] { new { id = resourceSpId } } })) }); // 5) GET resource SP appRoles to resolve the role id to a human-readable name handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(JsonSerializer.Serialize(new { - appRoles = new[] { new { id = "role-guid-1", value = "Agent365.Observability.OtelWrite" } } + appRoles = new[] { new { id = appRoleId, value = "Agent365.Observability.OtelWrite" } } })) }); // Act + const string blueprintAppId = "11111111-1111-1111-1111-111111111111"; + const string resourceAppId = "22222222-2222-2222-2222-222222222222"; + var grants = await service.GetBlueprintSpGrantsAsync( - "tid", "blueprint-app-id", new[] { "observability-app-id" }); + "tid", blueprintAppId, new[] { resourceAppId }); // Assert - grants.Should().ContainKey("observability-app-id"); - var (delegatedScopes, appRoleNames) = grants["observability-app-id"]; + grants.Should().ContainKey(resourceAppId); + var (delegatedScopes, appRoleNames) = grants[resourceAppId]; delegatedScopes.Should().BeEquivalentTo(new[] { "extra-scope", "otel-write" }, because: "the space-delimited Graph scope string must be split into individual scopes and returned sorted"); appRoleNames.Should().BeEquivalentTo(new[] { "Agent365.Observability.OtelWrite" }, because: "app role IDs must be resolved to their human-readable values via the resource SP's appRoles array"); } + [Fact] + public async Task GetBlueprintSpGrantsAsync_WithUnrelatedNullValuedRole_ResolvesAssignedRole() + { + const string resourceSpId = "33333333-3333-3333-3333-333333333333"; + const string assignedRoleId = "44444444-4444-4444-4444-444444444444"; + const string unrelatedRoleId = "55555555-5555-5555-5555-555555555555"; + var (service, graph) = BuildServiceWithMockedGraph(); + + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "blueprint-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = "bp-sp-id", + StatusCode = 200 + }); + graph.GetOauth2PermissionGrantsAsync( + "tenant-id", + "bp-sp-id", + Arg.Any()) + .Returns(new List<(string resourceId, string scope, string consentType)>()); + graph.GraphGetWithResponseAsync( + "tenant-id", + Arg.Is(path => path.Contains("/servicePrincipals/bp-sp-id/appRoleAssignments", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDoc($$""" + { + "value": [ + { "resourceId": "{{resourceSpId}}", "appRoleId": "{{assignedRoleId}}" } + ] + } + """) + }); + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "resource-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = resourceSpId, + StatusCode = 200 + }); + graph.GraphGetWithResponseAsync( + "tenant-id", + Arg.Is(path => path.Contains($"/servicePrincipals/{resourceSpId}?$select=appRoles", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDoc($$""" + { + "appRoles": [ + { "id": "{{assignedRoleId}}", "value": "Assigned.Role" }, + { "id": "{{unrelatedRoleId}}", "value": null } + ] + } + """) + }); + + var result = await service.GetBlueprintSpGrantsAsync( + "tenant-id", "blueprint-app-id", new[] { "resource-app-id" }); + + result["resource-app-id"].AppRoleNames.Should().Equal(new[] { "Assigned.Role" }, + because: "an unrelated app role with no value must not invalidate otherwise usable role metadata"); + } + [Fact] public async Task SetInheritablePermissionsAsync_IsIdempotent_WhenAlreadyAllAllowed() { @@ -1149,10 +1237,15 @@ public async Task GetBlueprintSpGrantsAsync_WithEmptyResourceAppIds_ReturnsEmpty result.Should().BeEmpty( because: "no resource app IDs means there's nothing to enumerate — the method must short-circuit to an empty dict"); - await graph.DidNotReceive().LookupServicePrincipalByAppIdAsync( - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any?>()); + await graph.DidNotReceive().LookupServicePrincipalByAppIdWithResponseAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); await graph.DidNotReceive().GetOauth2PermissionGrantsAsync( - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), + Arg.Any(), + Arg.Any()); } [Fact] @@ -1163,9 +1256,16 @@ public async Task GetBlueprintSpGrantsAsync_WhenBlueprintSpNotFound_ReturnsEmpty // answer would otherwise come back empty without explanation. var (service, graph) = BuildServiceWithMockedGraph(); - graph.LookupServicePrincipalByAppIdAsync( - "tenant-id", "blueprint-app-id", Arg.Any(), Arg.Any?>()) - .Returns((string?)null); + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "blueprint-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + StatusCode = 200 + }); var result = await service.GetBlueprintSpGrantsAsync( "tenant-id", "blueprint-app-id", new[] { "resource-app-id" }); @@ -1175,7 +1275,9 @@ public async Task GetBlueprintSpGrantsAsync_WhenBlueprintSpNotFound_ReturnsEmpty // No grant enumeration must occur after the SP lookup failed. await graph.DidNotReceive().GetOauth2PermissionGrantsAsync( - Arg.Any(), Arg.Any(), Arg.Any()); + Arg.Any(), + Arg.Any(), + Arg.Any()); _mockLogger.Received().Log( LogLevel.Warning, @@ -1185,6 +1287,235 @@ await graph.DidNotReceive().GetOauth2PermissionGrantsAsync( Arg.Any>()); } + [Fact] + public async Task GetBlueprintSpGrantsAsync_WhenBlueprintSpLookupFails_Throws() + { + var (service, graph) = BuildServiceWithMockedGraph(); + + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "blueprint-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = false, + StatusCode = 403, + FailureReason = "Microsoft Graph service-principal lookup failed: HTTP 403 Forbidden." + }); + + Func act = async () => await service.GetBlueprintSpGrantsAsync( + "tenant-id", "blueprint-app-id", new[] { "resource-app-id" }); + + await act.Should().ThrowAsync() + .WithMessage("*HTTP 403 Forbidden*", + because: "an unreadable blueprint lookup must not masquerade as a successfully absent service principal"); + await graph.DidNotReceive().GetOauth2PermissionGrantsAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public async Task GetBlueprintSpGrantsAsync_WhenAppRoleAssignmentsReadFails_Throws() + { + var (service, graph) = BuildServiceWithMockedGraph(); + + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "blueprint-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = "bp-sp-id", + StatusCode = 200 + }); + graph.GetOauth2PermissionGrantsAsync( + "tenant-id", + "bp-sp-id", + Arg.Any()) + .Returns(new List<(string resourceId, string scope, string consentType)>()); + graph.GraphGetWithResponseAsync( + "tenant-id", + Arg.Is(path => path.Contains("/servicePrincipals/bp-sp-id/appRoleAssignments", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = false, + StatusCode = 403, + ReasonPhrase = "Forbidden" + }); + + Func act = async () => await service.GetBlueprintSpGrantsAsync( + "tenant-id", "blueprint-app-id", new[] { "resource-app-id" }); + + await act.Should().ThrowAsync() + .WithMessage("*app role assignments*HTTP 403 Forbidden*", + because: "a failed assignments read must remain distinct from a successful empty value array"); + await graph.DidNotReceive().LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "resource-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient); + } + + [Fact] + public async Task GetBlueprintSpGrantsAsync_WhenAppRoleAssignmentsTransportFails_ThrowsWithFailureReason() + { + var (service, graph) = BuildServiceWithMockedGraph(); + + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "blueprint-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = "bp-sp-id", + StatusCode = 200 + }); + graph.GetOauth2PermissionGrantsAsync( + "tenant-id", + "bp-sp-id", + Arg.Any()) + .Returns(new List<(string resourceId, string scope, string consentType)>()); + graph.GraphGetWithResponseAsync( + "tenant-id", + Arg.Is(path => path.Contains("/servicePrincipals/bp-sp-id/appRoleAssignments", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = false, + StatusCode = 0, + ReasonPhrase = "connection reset" + }); + + Func act = async () => await service.GetBlueprintSpGrantsAsync( + "tenant-id", "blueprint-app-id", new[] { "resource-app-id" }); + + var exception = await act.Should().ThrowAsync( + because: "a transport failure while reading assignments must not be treated as a successful empty assignment list"); + exception.Which.Message.Should().Contain("connection reset", + because: "the status-zero response reason is the only visible transport diagnostic"); + exception.Which.Message.Should().NotContain("HTTP 0", + because: "no HTTP response exists when Graph reports status zero"); + await graph.DidNotReceive().LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "resource-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient); + } + + [Fact] + public async Task GetBlueprintSpGrantsAsync_WhenAppRoleAssignmentsShapeIsMalformed_Throws() + { + var (service, graph) = BuildServiceWithMockedGraph(); + + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "blueprint-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = "bp-sp-id", + StatusCode = 200 + }); + graph.GetOauth2PermissionGrantsAsync( + "tenant-id", + "bp-sp-id", + Arg.Any()) + .Returns(new List<(string resourceId, string scope, string consentType)>()); + graph.GraphGetWithResponseAsync( + "tenant-id", + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDoc(@"{ ""value"": {} }") + }); + + Func act = async () => await service.GetBlueprintSpGrantsAsync( + "tenant-id", "blueprint-app-id", new[] { "resource-app-id" }); + + await act.Should().ThrowAsync() + .WithMessage("*invalid app role assignments response*", + because: "an unexpected successful response shape must not be treated as an empty assignments collection"); + } + + [Theory] + [InlineData("not-a-guid", "44444444-4444-4444-4444-444444444444")] + [InlineData("33333333-3333-3333-3333-333333333333", "not-a-guid")] + public async Task GetBlueprintSpGrantsAsync_WhenAppRoleAssignmentIdentifierIsNotGuid_Throws( + string resourceId, + string appRoleId) + { + var (service, graph) = BuildServiceWithMockedGraph(); + + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "blueprint-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = "bp-sp-id", + StatusCode = 200 + }); + graph.GetOauth2PermissionGrantsAsync( + "tenant-id", + "bp-sp-id", + Arg.Any()) + .Returns(new List<(string resourceId, string scope, string consentType)>()); + graph.GraphGetWithResponseAsync( + "tenant-id", + Arg.Is(path => path.Contains("/servicePrincipals/bp-sp-id/appRoleAssignments", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDoc($$""" + { + "value": [ + { "resourceId": "{{resourceId}}", "appRoleId": "{{appRoleId}}" } + ] + } + """) + }); + + Func act = async () => await service.GetBlueprintSpGrantsAsync( + "tenant-id", "blueprint-app-id", new[] { "resource-app-id" }); + + await act.Should().ThrowAsync() + .WithMessage("*invalid app role assignment*", + because: "Graph app role assignment identifiers must be GUIDs rather than arbitrary non-empty strings"); + await graph.DidNotReceive().LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "resource-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient); + } + [Fact] public async Task GetBlueprintSpGrantsAsync_WhenResourceSpMissing_OmitsResourceFromDictionary() { @@ -1194,23 +1525,47 @@ public async Task GetBlueprintSpGrantsAsync_WhenResourceSpMissing_OmitsResourceF // and the inheritance command surfaces the two cases differently. var (service, graph) = BuildServiceWithMockedGraph(); - graph.LookupServicePrincipalByAppIdAsync( - "tenant-id", "blueprint-app-id", Arg.Any(), Arg.Any?>()) - .Returns("bp-sp-id"); + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "blueprint-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = "bp-sp-id", + StatusCode = 200 + }); graph.GetOauth2PermissionGrantsAsync( - "tenant-id", "bp-sp-id", Arg.Any()) + "tenant-id", + "bp-sp-id", + Arg.Any()) .Returns(new List<(string resourceId, string scope, string consentType)>()); // appRoleAssignments — empty - graph.GraphGetAsync( + graph.GraphGetWithResponseAsync( "tenant-id", Arg.Is(s => s.Contains("/servicePrincipals/bp-sp-id/appRoleAssignments", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), Arg.Any(), - Arg.Any?>()) - .Returns(JsonDoc(@"{ ""value"": [] }")); + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDoc(@"{ ""value"": [] }") + }); // Resource SP lookup returns null — this resource is unprovisioned. - graph.LookupServicePrincipalByAppIdAsync( - "tenant-id", "missing-resource-app-id", Arg.Any(), Arg.Any?>()) - .Returns((string?)null); + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "missing-resource-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + StatusCode = 200 + }); var result = await service.GetBlueprintSpGrantsAsync( "tenant-id", "blueprint-app-id", new[] { "missing-resource-app-id" }); @@ -1221,6 +1576,60 @@ public async Task GetBlueprintSpGrantsAsync_WhenResourceSpMissing_OmitsResourceF because: "the only requested resource was unresolvable, so the dictionary must be empty"); } + [Fact] + public async Task GetBlueprintSpGrantsAsync_WhenResourceSpLookupFails_Throws() + { + var (service, graph) = BuildServiceWithMockedGraph(); + + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "blueprint-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = "bp-sp-id", + StatusCode = 200 + }); + graph.GetOauth2PermissionGrantsAsync( + "tenant-id", + "bp-sp-id", + Arg.Any()) + .Returns(new List<(string resourceId, string scope, string consentType)>()); + graph.GraphGetWithResponseAsync( + "tenant-id", + Arg.Any(), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDoc(@"{ ""value"": [] }") + }); + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "resource-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = false, + StatusCode = 503, + FailureReason = "Microsoft Graph service-principal lookup failed: HTTP 503 Service Unavailable." + }); + + Func act = async () => await service.GetBlueprintSpGrantsAsync( + "tenant-id", "blueprint-app-id", new[] { "resource-app-id" }); + + await act.Should().ThrowAsync() + .WithMessage("*HTTP 503 Service Unavailable*", + because: "a failed resource lookup must not be omitted as though Graph successfully found no service principal"); + } + [Fact] public async Task GetBlueprintSpGrantsAsync_WhenResourceHasZeroGrants_IncludesResourceWithEmptyArrays() { @@ -1230,22 +1639,47 @@ public async Task GetBlueprintSpGrantsAsync_WhenResourceHasZeroGrants_IncludesRe // tell "this resource has no grants" apart from "we couldn't look this resource up". var (service, graph) = BuildServiceWithMockedGraph(); - graph.LookupServicePrincipalByAppIdAsync( - "tenant-id", "blueprint-app-id", Arg.Any(), Arg.Any?>()) - .Returns("bp-sp-id"); + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "blueprint-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = "bp-sp-id", + StatusCode = 200 + }); graph.GetOauth2PermissionGrantsAsync( - "tenant-id", "bp-sp-id", Arg.Any()) + "tenant-id", + "bp-sp-id", + Arg.Any()) .Returns(new List<(string resourceId, string scope, string consentType)>()); - graph.GraphGetAsync( + graph.GraphGetWithResponseAsync( "tenant-id", Arg.Is(s => s.Contains("/servicePrincipals/bp-sp-id/appRoleAssignments", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), Arg.Any(), - Arg.Any?>()) - .Returns(JsonDoc(@"{ ""value"": [] }")); + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDoc(@"{ ""value"": [] }") + }); // Resource SP exists but has no delegated grants or app role assignments. - graph.LookupServicePrincipalByAppIdAsync( - "tenant-id", "zero-grants-app-id", Arg.Any(), Arg.Any?>()) - .Returns("zero-grants-sp-id"); + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "zero-grants-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = "zero-grants-sp-id", + StatusCode = 200 + }); var result = await service.GetBlueprintSpGrantsAsync( "tenant-id", "blueprint-app-id", new[] { "zero-grants-app-id" }); @@ -1257,11 +1691,19 @@ public async Task GetBlueprintSpGrantsAsync_WhenResourceHasZeroGrants_IncludesRe because: "no delegated grants were issued on the blueprint SP for this resource"); appRoleNames.Should().BeEmpty( because: "no app role assignments were issued on the blueprint SP for this resource"); + await graph.Received(1).GetOauth2PermissionGrantsAsync( + "tenant-id", + "bp-sp-id", + Arg.Any()); } [Fact] public async Task GetBlueprintSpGrantsAsync_WhenAppRoleIdIsUnknown_FallsBackToAngleBracketPlaceholder() { + const string resourceSpId = "33333333-3333-3333-3333-333333333333"; + const string unknownRoleId = "44444444-4444-4444-4444-444444444444"; + const string otherRoleId = "55555555-5555-5555-5555-555555555555"; + // When the blueprint SP has an app role assignment but the resource SP's appRoles array // does not contain a matching entry (e.g. the role was removed from the resource after // assignment, or the resource SP doc was fetched with a $select that elided it), the @@ -1270,45 +1712,473 @@ public async Task GetBlueprintSpGrantsAsync_WhenAppRoleIdIsUnknown_FallsBackToAn // to flag the entry as unresolved. var (service, graph) = BuildServiceWithMockedGraph(); - graph.LookupServicePrincipalByAppIdAsync( - "tenant-id", "blueprint-app-id", Arg.Any(), Arg.Any?>()) - .Returns("bp-sp-id"); + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "blueprint-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = "bp-sp-id", + StatusCode = 200 + }); graph.GetOauth2PermissionGrantsAsync( - "tenant-id", "bp-sp-id", Arg.Any()) + "tenant-id", + "bp-sp-id", + Arg.Any()) .Returns(new List<(string resourceId, string scope, string consentType)>()); // App role assignment exists on the blueprint SP for our resource. - graph.GraphGetAsync( + graph.GraphGetWithResponseAsync( "tenant-id", Arg.Is(s => s.Contains("/servicePrincipals/bp-sp-id/appRoleAssignments", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDoc($$""" + { + "value": [ + { "resourceId": "{{resourceSpId}}", "appRoleId": "{{unknownRoleId}}" } + ] + } + """) + }); + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "resource-app-id", Arg.Any(), - Arg.Any?>()) - .Returns(JsonDoc(@"{ - ""value"": [ - { ""resourceId"": ""resource-sp-id"", ""appRoleId"": ""unknown-role-guid"" } - ] - }")); - graph.LookupServicePrincipalByAppIdAsync( - "tenant-id", "resource-app-id", Arg.Any(), Arg.Any?>()) - .Returns("resource-sp-id"); - // Resource SP appRoles does NOT contain "unknown-role-guid". - graph.GraphGetAsync( + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = resourceSpId, + StatusCode = 200 + }); + // Resource SP appRoles does not contain the assigned role ID. + graph.GraphGetWithResponseAsync( "tenant-id", - Arg.Is(s => s.Contains("/servicePrincipals/resource-sp-id?$select=appRoles", StringComparison.Ordinal)), + Arg.Is(s => s.Contains($"/servicePrincipals/{resourceSpId}?$select=appRoles", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), Arg.Any(), - Arg.Any?>()) - .Returns(JsonDoc(@"{ - ""appRoles"": [ - { ""id"": ""some-other-role-id"", ""value"": ""Some.Other.Role"" } - ] - }")); + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDoc($$""" + { + "appRoles": [ + { "id": "{{otherRoleId}}", "value": "Some.Other.Role" } + ] + } + """) + }); var result = await service.GetBlueprintSpGrantsAsync( "tenant-id", "blueprint-app-id", new[] { "resource-app-id" }); result.Should().ContainKey("resource-app-id"); var (_, appRoleNames) = result["resource-app-id"]; - appRoleNames.Should().Equal(new[] { "" }, + appRoleNames.Should().Equal(new[] { $"<{unknownRoleId}>" }, because: "an unresolvable role ID must surface as '' so operators can still see and investigate the assignment — silently dropping it would hide a real grant"); + await graph.Received(1).GraphGetWithResponseAsync( + "tenant-id", + Arg.Is(path => path.Contains($"/servicePrincipals/{resourceSpId}?$select=appRoles", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient); + } + + [Fact] + public async Task GetBlueprintSpGrantsAsync_WhenAssignedRoleMetadataIsEmpty_ReturnsAngleBracketPlaceholder() + { + const string resourceSpId = "33333333-3333-3333-3333-333333333333"; + const string assignedRoleId = "44444444-4444-4444-4444-444444444444"; + var (service, graph) = BuildServiceWithMockedGraph(); + + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "blueprint-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = "bp-sp-id", + StatusCode = 200 + }); + graph.GetOauth2PermissionGrantsAsync( + "tenant-id", + "bp-sp-id", + Arg.Any()) + .Returns(new List<(string resourceId, string scope, string consentType)>()); + graph.GraphGetWithResponseAsync( + "tenant-id", + Arg.Is(path => path.Contains("/servicePrincipals/bp-sp-id/appRoleAssignments", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDoc($$""" + { + "value": [ + { "resourceId": "{{resourceSpId}}", "appRoleId": "{{assignedRoleId}}" } + ] + } + """) + }); + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "resource-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = resourceSpId, + StatusCode = 200 + }); + graph.GraphGetWithResponseAsync( + "tenant-id", + Arg.Is(path => path.Contains($"/servicePrincipals/{resourceSpId}?$select=appRoles", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDoc("""{ "appRoles": [] }""") + }); + + var result = await service.GetBlueprintSpGrantsAsync( + "tenant-id", "blueprint-app-id", new[] { "resource-app-id" }); + + result["resource-app-id"].AppRoleNames.Should().Equal(new[] { $"<{assignedRoleId}>" }, + because: "a successful empty metadata array cannot erase a real assignment and must use the documented unresolved-role placeholder"); + } + + [Theory] + [InlineData("{}")] + [InlineData("{\"appRoles\":{}}")] + public async Task GetBlueprintSpGrantsAsync_WhenRoleMetadataTopLevelPayloadIsMalformed_Throws(string responseBody) + { + const string resourceSpId = "33333333-3333-3333-3333-333333333333"; + const string assignedRoleId = "44444444-4444-4444-4444-444444444444"; + var (service, graph) = BuildServiceWithMockedGraph(); + + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "blueprint-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = "bp-sp-id", + StatusCode = 200 + }); + graph.GetOauth2PermissionGrantsAsync( + "tenant-id", + "bp-sp-id", + Arg.Any()) + .Returns(new List<(string resourceId, string scope, string consentType)>()); + graph.GraphGetWithResponseAsync( + "tenant-id", + Arg.Is(path => path.Contains("/servicePrincipals/bp-sp-id/appRoleAssignments", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDoc($$""" + { + "value": [ + { "resourceId": "{{resourceSpId}}", "appRoleId": "{{assignedRoleId}}" } + ] + } + """) + }); + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "resource-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = resourceSpId, + StatusCode = 200 + }); + graph.GraphGetWithResponseAsync( + "tenant-id", + Arg.Is(path => path.Contains($"/servicePrincipals/{resourceSpId}?$select=appRoles", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDoc(responseBody) + }); + + Func act = async () => await service.GetBlueprintSpGrantsAsync( + "tenant-id", "blueprint-app-id", new[] { "resource-app-id" }); + + await act.Should().ThrowAsync() + .WithMessage("*invalid app role metadata*", + because: "a successful response without an array-valued 'appRoles' member cannot resolve a real assignment safely"); + } + + [Fact] + public async Task GetBlueprintSpGrantsAsync_WhenAppRoleMetadataIdIsNotGuid_Throws() + { + const string resourceSpId = "33333333-3333-3333-3333-333333333333"; + const string assignedRoleId = "44444444-4444-4444-4444-444444444444"; + var (service, graph) = BuildServiceWithMockedGraph(); + + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "blueprint-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = "bp-sp-id", + StatusCode = 200 + }); + graph.GetOauth2PermissionGrantsAsync( + "tenant-id", + "bp-sp-id", + Arg.Any()) + .Returns(new List<(string resourceId, string scope, string consentType)>()); + graph.GraphGetWithResponseAsync( + "tenant-id", + Arg.Is(path => path.Contains("/servicePrincipals/bp-sp-id/appRoleAssignments", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDoc($$""" + { + "value": [ + { "resourceId": "{{resourceSpId}}", "appRoleId": "{{assignedRoleId}}" } + ] + } + """) + }); + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "resource-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = resourceSpId, + StatusCode = 200 + }); + graph.GraphGetWithResponseAsync( + "tenant-id", + Arg.Is(path => path.Contains($"/servicePrincipals/{resourceSpId}?$select=appRoles", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDoc(""" + { + "appRoles": [ + { "id": "not-a-guid", "value": "Malformed.Role" } + ] + } + """) + }); + + Func act = async () => await service.GetBlueprintSpGrantsAsync( + "tenant-id", "blueprint-app-id", new[] { "resource-app-id" }); + + await act.Should().ThrowAsync() + .WithMessage("*invalid app role metadata*", + because: "Graph app role metadata identifiers must be GUIDs rather than arbitrary non-empty strings"); + } + + [Fact] + public async Task GetBlueprintSpGrantsAsync_WhenRoleMetadataReadFails_Throws() + { + const string resourceSpId = "33333333-3333-3333-3333-333333333333"; + const string appRoleId = "44444444-4444-4444-4444-444444444444"; + var (service, graph) = BuildServiceWithMockedGraph(); + + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "blueprint-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = "bp-sp-id", + StatusCode = 200 + }); + graph.GetOauth2PermissionGrantsAsync( + "tenant-id", + "bp-sp-id", + Arg.Any()) + .Returns(new List<(string resourceId, string scope, string consentType)>()); + graph.GraphGetWithResponseAsync( + "tenant-id", + Arg.Is(path => path.Contains("/servicePrincipals/bp-sp-id/appRoleAssignments", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDoc($$""" + { + "value": [ + { "resourceId": "{{resourceSpId}}", "appRoleId": "{{appRoleId}}" } + ] + } + """) + }); + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "resource-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = resourceSpId, + StatusCode = 200 + }); + graph.GraphGetWithResponseAsync( + "tenant-id", + Arg.Is(path => path.Contains($"/servicePrincipals/{resourceSpId}?$select=appRoles", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = false, + StatusCode = 503, + ReasonPhrase = "Service Unavailable" + }); + + Func act = async () => await service.GetBlueprintSpGrantsAsync( + "tenant-id", "blueprint-app-id", new[] { "resource-app-id" }); + + await act.Should().ThrowAsync() + .WithMessage("*app role metadata*HTTP 503 Service Unavailable*", + because: "unreadable role metadata must not turn known assignments into unresolved placeholders"); + } + + [Fact] + public async Task GetBlueprintSpGrantsAsync_WhenRoleMetadataTransportFails_ThrowsWithFailureReason() + { + const string resourceSpId = "33333333-3333-3333-3333-333333333333"; + const string appRoleId = "44444444-4444-4444-4444-444444444444"; + var (service, graph) = BuildServiceWithMockedGraph(); + + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "blueprint-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = "bp-sp-id", + StatusCode = 200 + }); + graph.GetOauth2PermissionGrantsAsync( + "tenant-id", + "bp-sp-id", + Arg.Any()) + .Returns(new List<(string resourceId, string scope, string consentType)>()); + graph.GraphGetWithResponseAsync( + "tenant-id", + Arg.Is(path => path.Contains("/servicePrincipals/bp-sp-id/appRoleAssignments", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDoc($$""" + { + "value": [ + { "resourceId": "{{resourceSpId}}", "appRoleId": "{{appRoleId}}" } + ] + } + """) + }); + graph.LookupServicePrincipalByAppIdWithResponseAsync( + "tenant-id", + "resource-app-id", + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.ServicePrincipalLookupResult + { + IsSuccess = true, + ServicePrincipalId = resourceSpId, + StatusCode = 200 + }); + graph.GraphGetWithResponseAsync( + "tenant-id", + Arg.Is(path => path.Contains($"/servicePrincipals/{resourceSpId}?$select=appRoles", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any?>(), + Arg.Any(), + GraphAuthenticationMode.Ambient) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = false, + StatusCode = 0, + ReasonPhrase = "connection reset" + }); + + Func act = async () => await service.GetBlueprintSpGrantsAsync( + "tenant-id", "blueprint-app-id", new[] { "resource-app-id" }); + + var exception = await act.Should().ThrowAsync( + because: "a role metadata transport failure must remain distinct from a successful empty metadata array"); + exception.Which.Message.Should().Contain("connection reset", + because: "the status-zero Graph response reason must remain visible to the operator"); + exception.Which.Message.Should().NotContain("HTTP 0", + because: "status zero represents absence of an HTTP response rather than an HTTP protocol status"); } } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs index e81829bc..02dcb202 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs @@ -336,6 +336,193 @@ await authService.ReceivedWithAnyArgs(1).GetAccessTokenAsync( default!, default, default, default, default, default, default, default); } + [Fact] + public async Task GetOauth2PermissionGrantsAsync_WhenGraphReturnsForbidden_UsesAmbientAuthAndThrowsWithStatus() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.Forbidden) + { + Content = new StringContent( + """{"error":{"code":"Authorization_RequestDenied","message":"Insufficient privileges"}}""") + }); + var tokenProvider = Substitute.For(); + var authService = FakeAuth(); + var service = new GraphApiService( + _mockLogger, + _mockExecutor, + authService, + handler, + tokenProvider, + loginHintResolver: () => Task.FromResult(null), + retryHelper: new RetryHelper(NullLogger.Instance, maxRetries: 1, baseDelaySeconds: 0)) + { + CustomClientAppId = AuthenticationConstants.WellKnownClientAppId + }; + + Func act = async () => await service.GetOauth2PermissionGrantsAsync( + "tenant-123", + "blueprint-sp-object-id"); + + await act.Should().ThrowAsync() + .WithMessage("*HTTP 403 Forbidden*", + because: "a denied administrative read must remain distinguishable from a successful empty grants response"); + await tokenProvider.DidNotReceiveWithAnyArgs().GetMgGraphAccessTokenAsync( + default!, default!, default, default, default, default, default); + await authService.ReceivedWithAnyArgs(1).GetAccessTokenAsync( + default!, default, default, default, default, default, default, default); + } + + [Fact] + public async Task GetOauth2PermissionGrantsAsync_WhenTransportFails_ThrowsWithFailureReason() + { + using var handler = new ExceptionThrowingHttpMessageHandler( + () => new HttpRequestException("connection reset")); + var service = new GraphApiService( + _mockLogger, + _mockExecutor, + FakeAuth(), + handler, + loginHintResolver: () => Task.FromResult(null), + retryHelper: new RetryHelper(NullLogger.Instance, maxRetries: 1, baseDelaySeconds: 0)); + + Func act = async () => await service.GetOauth2PermissionGrantsAsync( + "tenant-123", + "blueprint-sp-object-id"); + + var exception = await act.Should().ThrowAsync( + because: "a transport failure must remain distinguishable from a successful empty grants response"); + exception.Which.Message.Should().Contain("connection reset", + because: "the status-zero Graph response reason is the only actionable transport diagnostic available to the operator"); + exception.Which.Message.Should().NotContain("HTTP 0", + because: "status zero means no HTTP response was received and must not be rendered as a real protocol status"); + } + + [Theory] + [InlineData("{}")] + [InlineData("{\"value\":{}}")] + public async Task GetOauth2PermissionGrantsAsync_WhenTopLevelPayloadIsMalformed_Throws(string responseBody) + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseBody) + }); + var service = new GraphApiService( + _mockLogger, + _mockExecutor, + FakeAuth(), + handler, + loginHintResolver: () => Task.FromResult(null), + retryHelper: new RetryHelper(NullLogger.Instance, maxRetries: 1, baseDelaySeconds: 0)); + + Func act = async () => await service.GetOauth2PermissionGrantsAsync( + "tenant-123", + "blueprint-sp-object-id"); + + await act.Should().ThrowAsync() + .WithMessage("*invalid OAuth2 permission grants response*", + because: "a successful response without an array-valued 'value' member is not authoritative evidence of zero grants"); + } + + [Fact] + public async Task GetOauth2PermissionGrantsAsync_WhenGraphReturnsEmptyValue_UsesAmbientAuthAndReturnsEmptyList() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"value":[]}""") + }); + var tokenProvider = Substitute.For(); + var authService = FakeAuth(); + var service = new GraphApiService( + _mockLogger, + _mockExecutor, + authService, + handler, + tokenProvider, + loginHintResolver: () => Task.FromResult(null), + retryHelper: new RetryHelper(NullLogger.Instance, maxRetries: 1, baseDelaySeconds: 0)) + { + CustomClientAppId = AuthenticationConstants.WellKnownClientAppId + }; + + var grants = await service.GetOauth2PermissionGrantsAsync( + "tenant-123", + "blueprint-sp-object-id"); + + grants.Should().BeEmpty( + because: "a successful Graph response with an empty value array is authoritative evidence that no grants exist"); + await tokenProvider.DidNotReceiveWithAnyArgs().GetMgGraphAccessTokenAsync( + default!, default!, default, default, default, default, default); + await authService.ReceivedWithAnyArgs(1).GetAccessTokenAsync( + default!, default, default, default, default, default, default, default); + } + + [Fact] + public void GetOauth2PermissionGrantsAsync_PreservesOriginalPublicVirtualSignature() + { + var method = typeof(GraphApiService).GetMethod( + nameof(GraphApiService.GetOauth2PermissionGrantsAsync), + [typeof(string), typeof(string), typeof(CancellationToken)]); + + method.Should().NotBeNull( + because: "existing callers and NSubstitute setups depend on the original three-parameter CLR signature"); + method!.IsPublic.Should().BeTrue(); + method.IsVirtual.Should().BeTrue( + because: "tests and downstream integrations substitute this administrative read API"); + } + + [Fact] + public async Task GetOauth2PermissionGrantsAsync_WhenGraphReturnsMalformedGrant_Throws() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"value":[{"resourceId":"11111111-1111-1111-1111-111111111111","scope":"User.Read"}]}""") + }); + var service = new GraphApiService( + _mockLogger, + _mockExecutor, + FakeAuth(), + handler, + loginHintResolver: () => Task.FromResult(null), + retryHelper: new RetryHelper(NullLogger.Instance, maxRetries: 1, baseDelaySeconds: 0)); + + Func act = async () => await service.GetOauth2PermissionGrantsAsync( + "tenant-123", + "blueprint-sp-object-id"); + + await act.Should().ThrowAsync() + .WithMessage("*invalid OAuth2 permission grant*", + because: "a malformed grant row must not be silently converted into a partial permissions result"); + } + + [Fact] + public async Task GetOauth2PermissionGrantsAsync_WhenResourceIdIsNotGuid_Throws() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """{"value":[{"resourceId":"not-a-guid","scope":"User.Read","consentType":"AllPrincipals"}]}""") + }); + var service = new GraphApiService( + _mockLogger, + _mockExecutor, + FakeAuth(), + handler, + loginHintResolver: () => Task.FromResult(null), + retryHelper: new RetryHelper(NullLogger.Instance, maxRetries: 1, baseDelaySeconds: 0)); + + Func act = async () => await service.GetOauth2PermissionGrantsAsync( + "tenant-123", + "blueprint-sp-object-id"); + + await act.Should().ThrowAsync() + .WithMessage("*invalid OAuth2 permission grant*", + because: "Graph resource identifiers must be GUIDs rather than arbitrary non-empty strings"); + } + [Fact] public async Task GraphPatchAsync_AmbientMode_IgnoresResolvedClientAppAndRequestedScopes() { From 749c1dbf8fba79ca713e12b6c301b23c4ee5a9b0 Mon Sep 17 00:00:00 2001 From: Rick Brighenti <202984599+rbrighenti@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:35:54 +0000 Subject: [PATCH 07/12] Simplify sovereign endpoint configuration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + .../Constants/ConfigConstants.cs | 65 +++++-- .../Services/Helpers/EndpointHelper.cs | 18 +- .../design.md | 4 +- .../Services/Helpers/EndpointHelperTests.cs | 173 ++++++++++++++++++ 5 files changed, 230 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9f7240e..89f89724 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g - `a365 develop get-token --device-code` — forces device code auth for Microsoft Graph scopes the Windows WAM broker rejects (e.g. Exchange `MailboxSettings.ReadWrite`, `ExchangeMessageTrace.Read.All`). ### Fixed +- Cloud-specific Agent 365 discover endpoint overrides now also select the messaging endpoint create and delete hosts unless explicit overrides are set. - Repeated `publish --aiteammate` runs now preserve customized manifest names instead of restoring an overlong blueprint name. - Repeated `setup blueprint --agent-name` runs now reuse the stored valid client secret instead of creating duplicate credentials. - `a365 query-entra blueprint-scopes` and `inheritance` now report permission-grant read failures, and `a365 create-instance` now stops safely instead of continuing when existing grants cannot be read. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs index 41f1ed9f..e88166e0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs @@ -11,6 +11,10 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Constants; /// public static class ConfigConstants { + private const string ProductionAgent365ToolsOrigin = "https://agent365.svc.cloud.microsoft"; + internal const string CreateAgentBlueprintPath = "/agents/botManagement/createAgentBlueprint"; + internal const string DeleteAgentBlueprintPath = "/agents/botManagement/deleteAgentBlueprint"; + /// /// Commercial-cloud OAuth authority host. Used as the fallback when no cloud-specific /// override is configured. @@ -62,12 +66,12 @@ public static class ConfigConstants /// /// Production Agent 365 Tools Create endpoint URL /// - public const string ProductionCreateEndpointUrl = "https://agent365.svc.cloud.microsoft/agents/botManagement/createAgentBlueprint"; + public const string ProductionCreateEndpointUrl = ProductionAgent365ToolsOrigin + CreateAgentBlueprintPath; /// /// Production Agent 365 Tools Delete endpoint URL /// - public const string ProductionDeleteEndpointUrl = "https://agent365.svc.cloud.microsoft/agents/botManagement/deleteAgentBlueprint"; + public const string ProductionDeleteEndpointUrl = ProductionAgent365ToolsOrigin + DeleteAgentBlueprintPath; /// /// Messaging Bot API App ID @@ -160,18 +164,13 @@ public static class ConfigConstants /// Get Discover endpoint URL based on environment /// public static string GetDiscoverEndpointUrl(string environment) - { - // Check for custom endpoint in environment variable first - var customEndpoint = GetEnvironmentScopedSetting("A365_DISCOVER_ENDPOINT", environment); - if (!string.IsNullOrEmpty(customEndpoint)) - return customEndpoint; + => ResolveDiscoverEndpointUri(environment).AbsoluteUri; - // Default to production endpoint - return environment?.ToLower() switch - { - _ => ProductionDiscoverEndpointUrl - }; - } + internal static string GetAgent365ToolsOrigin(string environment) + => ResolveDiscoverEndpointUri(environment).GetLeftPart(UriPartial.Authority); + + internal static string BuildAgent365ToolsEndpointUrl(string environment, string endpointPath) + => $"{GetAgent365ToolsOrigin(environment)}{endpointPath}"; /// /// environment-aware Agent 365 Tools resource Application ID @@ -229,18 +228,19 @@ public static string NormalizeEnvironmentKey(string? environment) } private static string? GetEnvironmentScopedSetting(string prefix, string? environment) - => Environment.GetEnvironmentVariable($"{prefix}_{NormalizeEnvironmentKey(environment)}") is { } value + => GetEnvironmentScopedValue(prefix, environment) is { } value && !string.IsNullOrWhiteSpace(value) ? value.Trim() : null; + private static string? GetEnvironmentScopedValue(string prefix, string? environment) + => Environment.GetEnvironmentVariable($"{prefix}_{NormalizeEnvironmentKey(environment)}"); + private static string NormalizeHttpsOrigin(string? value, string fallback, string settingName) { var candidate = string.IsNullOrWhiteSpace(value) ? fallback : value.Trim(); - if (!Uri.TryCreate(candidate, UriKind.Absolute, out var uri) || - !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || - !string.IsNullOrEmpty(uri.UserInfo) || - !string.IsNullOrEmpty(uri.Query) || + var uri = ParseHttpsUri(candidate, settingName); + if (!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment) || uri.AbsolutePath != "/") { @@ -250,4 +250,33 @@ private static string NormalizeHttpsOrigin(string? value, string fallback, strin return uri.GetLeftPart(UriPartial.Authority); } + + private static Uri ResolveDiscoverEndpointUri(string environment) + { + var configuredEndpoint = GetEnvironmentScopedValue("A365_DISCOVER_ENDPOINT", environment); + var candidate = configuredEndpoint is null + ? ProductionDiscoverEndpointUrl + : configuredEndpoint.Trim(); + var uri = ParseHttpsUri(candidate, "Agent 365 Tools discover endpoint"); + if (!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment)) + { + throw new ArgumentException( + "Agent 365 Tools discover endpoint must not contain a query or fragment."); + } + + return uri; + } + + private static Uri ParseHttpsUri(string value, string settingName) + { + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || + !string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) || + !string.IsNullOrEmpty(uri.UserInfo)) + { + throw new ArgumentException( + $"{settingName} must be an absolute HTTPS URL without user info."); + } + + return uri; + } } \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/EndpointHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/EndpointHelper.cs index c33ee3e9..c10ed6e2 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/EndpointHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/EndpointHelper.cs @@ -115,12 +115,9 @@ public static string GetCreateEndpointUrl(string environment) if (!string.IsNullOrEmpty(customEndpoint)) return customEndpoint; - // Default to production endpoint - return environment?.ToLower() switch - { - "prod" => ConfigConstants.ProductionCreateEndpointUrl, - _ => ConfigConstants.ProductionCreateEndpointUrl - }; + return ConfigConstants.BuildAgent365ToolsEndpointUrl( + environment, + ConfigConstants.CreateAgentBlueprintPath); } /// @@ -134,12 +131,9 @@ public static string GetDeleteEndpointUrl(string environment) if (!string.IsNullOrEmpty(customEndpoint)) return customEndpoint; - // Default to production endpoint - return environment?.ToLower() switch - { - "prod" => ConfigConstants.ProductionDeleteEndpointUrl, - _ => ConfigConstants.ProductionDeleteEndpointUrl - }; + return ConfigConstants.BuildAgent365ToolsEndpointUrl( + environment, + ConfigConstants.DeleteAgentBlueprintPath); } /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/design.md b/src/Microsoft.Agents.A365.DevTools.Cli/design.md index 4b4006cb..dc219f8b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/design.md +++ b/src/Microsoft.Agents.A365.DevTools.Cli/design.md @@ -149,7 +149,9 @@ For security and flexibility, the CLI supports environment variable overrides: |----------|---------| | `A365_MCP_APP_ID` | Override Agent 365 Tools App ID for authentication | | `A365_MCP_APP_ID_{ENV}` | Per-environment MCP Platform App ID | -| `A365_DISCOVER_ENDPOINT_{ENV}` | Per-environment discover endpoint URL | +| `A365_DISCOVER_ENDPOINT_{ENV}` | Canonical per-environment Agent 365 endpoint; discover uses the configured URL, while create and delete use its validated HTTPS origin with fixed routes | +| `A365_CREATE_ENDPOINT_{ENV}` | Higher-precedence per-environment create endpoint override | +| `A365_DELETE_ENDPOINT_{ENV}` | Higher-precedence per-environment delete endpoint override | | `POWERPLATFORM_API_URL` | Override Power Platform API URL | **Design Decision:** All test/preprod App IDs and URLs have been removed from the codebase. The production App ID is the only hardcoded value. Internal Microsoft developers use environment variables for non-production testing. diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/EndpointHelperTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/EndpointHelperTests.cs index 7a3df494..21ba82c1 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/EndpointHelperTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/EndpointHelperTests.cs @@ -2,11 +2,13 @@ // Licensed under the MIT License. using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Exceptions; using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services.Helpers; +[Collection("ConfigTests")] public class EndpointHelperTests { [Fact] @@ -337,4 +339,175 @@ public void GetEndpointNameFromHost_WithShortBlueprintId_UsesAvailableCharsAsSuf result.Should().Be("myapp-example-com-abcd"); result.Should().NotEndWith("-"); } + + [Fact] + public void Agent365Endpoints_WithoutOverrides_PreserveCommercialUrls() + { + WithEnvironmentVariables( + "PROD", + discoverEndpoint: null, + createEndpoint: null, + deleteEndpoint: null, + () => + { + ConfigConstants.GetDiscoverEndpointUrl("prod").Should().Be( + ConfigConstants.ProductionDiscoverEndpointUrl, + because: "commercial discover behavior must remain unchanged without overrides"); + EndpointHelper.GetCreateEndpointUrl("prod").Should().Be( + ConfigConstants.ProductionCreateEndpointUrl, + because: "commercial create behavior must remain unchanged without overrides"); + EndpointHelper.GetDeleteEndpointUrl("prod").Should().Be( + ConfigConstants.ProductionDeleteEndpointUrl, + because: "commercial delete behavior must remain unchanged without overrides"); + }); + } + + [Fact] + public void Agent365Endpoints_WithOnlyGccDiscoverOverride_UseItsHttpsOrigin() + { + const string discoverEndpoint = + "https://gcc.agent365.svc.cloud.microsoft/agents/v2/discoverMCPServers"; + + WithEnvironmentVariables( + "GCC", + discoverEndpoint, + createEndpoint: null, + deleteEndpoint: null, + () => + { + ConfigConstants.GetDiscoverEndpointUrl("gcc").Should().Be( + discoverEndpoint, + because: "the configured cloud discover endpoint is canonical"); + EndpointHelper.GetCreateEndpointUrl("gcc").Should().Be( + "https://gcc.agent365.svc.cloud.microsoft/agents/botManagement/createAgentBlueprint", + because: "create uses the canonical discover endpoint's HTTPS origin and fixed route"); + EndpointHelper.GetDeleteEndpointUrl("gcc").Should().Be( + "https://gcc.agent365.svc.cloud.microsoft/agents/botManagement/deleteAgentBlueprint", + because: "delete uses the canonical discover endpoint's HTTPS origin and fixed route"); + }); + } + + [Fact] + public void Agent365Endpoints_ExplicitCreateAndDeleteOverrides_TakeIndependentPrecedence() + { + const string discoverEndpoint = + "https://gcc.agent365.svc.cloud.microsoft/agents/v2/discoverMCPServers"; + const string createEndpoint = "https://create.example/custom"; + const string deleteEndpoint = "https://delete.example/custom"; + + WithEnvironmentVariables( + "GCC", + discoverEndpoint, + createEndpoint, + deleteEndpoint, + () => + { + EndpointHelper.GetCreateEndpointUrl("gcc").Should().Be( + createEndpoint, + because: "the explicit create override is an independent higher-precedence escape hatch"); + EndpointHelper.GetDeleteEndpointUrl("gcc").Should().Be( + deleteEndpoint, + because: "the explicit delete override is an independent higher-precedence escape hatch"); + }); + } + + [Fact] + public void Agent365Endpoints_ExplicitOverrides_BypassMalformedDiscoverIndependently() + { + const string createEndpoint = "https://create.example/custom"; + const string deleteEndpoint = "https://delete.example/custom"; + + WithEnvironmentVariables( + "GCC", + discoverEndpoint: "not-a-url", + createEndpoint, + deleteEndpoint: null, + () => + { + EndpointHelper.GetCreateEndpointUrl("gcc").Should().Be( + createEndpoint, + because: "the explicit create override must not depend on discover URL validity"); + FluentActions.Invoking(() => EndpointHelper.GetDeleteEndpointUrl("gcc")) + .Should().Throw( + because: "delete must still validate discover when it has no explicit override"); + }); + + WithEnvironmentVariables( + "GCC", + discoverEndpoint: "not-a-url", + createEndpoint: null, + deleteEndpoint, + () => + { + FluentActions.Invoking(() => EndpointHelper.GetCreateEndpointUrl("gcc")) + .Should().Throw( + because: "create must still validate discover when it has no explicit override"); + EndpointHelper.GetDeleteEndpointUrl("gcc").Should().Be( + deleteEndpoint, + because: "the explicit delete override must not depend on discover URL validity"); + }); + } + + [Theory] + [InlineData(" ")] + [InlineData("not-a-url")] + [InlineData("http://gcc.agent365.svc.cloud.microsoft/agents/v2/discoverMCPServers")] + [InlineData("https://user@gcc.agent365.svc.cloud.microsoft/agents/v2/discoverMCPServers")] + [InlineData("https://gcc.agent365.svc.cloud.microsoft/agents/v2/discoverMCPServers?version=2")] + [InlineData("https://gcc.agent365.svc.cloud.microsoft/agents/v2/discoverMCPServers#section")] + public void Agent365Endpoints_MalformedDiscoverOverride_FailsVisibly(string discoverEndpoint) + { + WithEnvironmentVariables( + "GCC", + discoverEndpoint, + createEndpoint: null, + deleteEndpoint: null, + () => + { + FluentActions.Invoking(() => ConfigConstants.GetDiscoverEndpointUrl("gcc")) + .Should().Throw( + because: "discover must reject malformed or non-HTTPS endpoint URLs"); + FluentActions.Invoking(() => EndpointHelper.GetCreateEndpointUrl("gcc")) + .Should().Throw( + because: "create must not silently fall back when the canonical discover endpoint is invalid"); + FluentActions.Invoking(() => EndpointHelper.GetDeleteEndpointUrl("gcc")) + .Should().Throw( + because: "delete must not silently fall back when the canonical discover endpoint is invalid"); + }); + } + + private static void WithEnvironmentVariables( + string environmentKey, + string? discoverEndpoint, + string? createEndpoint, + string? deleteEndpoint, + Action assertion) + { + var values = new Dictionary + { + [$"A365_DISCOVER_ENDPOINT_{environmentKey}"] = discoverEndpoint, + [$"A365_CREATE_ENDPOINT_{environmentKey}"] = createEndpoint, + [$"A365_DELETE_ENDPOINT_{environmentKey}"] = deleteEndpoint, + }; + var previousValues = values.Keys.ToDictionary( + name => name, + Environment.GetEnvironmentVariable); + + try + { + foreach (var (name, value) in values) + { + Environment.SetEnvironmentVariable(name, value); + } + + assertion(); + } + finally + { + foreach (var (name, value) in previousValues) + { + Environment.SetEnvironmentVariable(name, value); + } + } + } } From 6a4df0f0e6e7aaba5cc4ea80f7ccb4d8a17047f5 Mon Sep 17 00:00:00 2001 From: Rick Brighenti <202984599+rbrighenti@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:42:53 +0000 Subject: [PATCH 08/12] Fix sovereign observability setup Select the cloud-specific Observability resource and fail safely when blueprint discovery or Graph authorization is inconclusive. Refresh Graph tokens after client app permission changes and preserve the configured blueprint when duplicate display names exist. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 8 +- .../Commands/CreateInstanceCommand.cs | 5 +- .../Commands/QueryEntraCommand.cs | 4 +- .../BatchPermissionsOrchestrator.cs | 21 ++- .../SetupSubcommands/BlueprintSubcommand.cs | 17 ++- .../SetupSubcommands/PermissionsSubcommand.cs | 9 +- .../Commands/SetupSubcommands/SetupHelpers.cs | 97 +++++++++---- .../Commands/SetupSubcommands/SetupResults.cs | 1 + .../Constants/AuthenticationConstants.cs | 15 +- .../Constants/ConfigConstants.cs | 51 ++++++- .../Models/Agent365Config.cs | 8 +- .../Services/A365CreateInstanceRunner.cs | 3 +- .../Services/BlueprintLookupService.cs | 76 ++++++++-- .../Services/ClientAppValidator.cs | 3 + .../Services/GraphApiService.cs | 6 +- .../Internal/IMicrosoftGraphTokenProvider.cs | 5 + .../Internal/MicrosoftGraphTokenProvider.cs | 2 + .../Services/LogRedactionService.cs | 3 + .../design.md | 13 +- .../BatchPermissionsOrchestratorTests.cs | 33 +++++ .../BlueprintSubcommandInvalidationTests.cs | 82 ++++++++++- .../SetupSubcommands/PermissionSpecsTests.cs | 20 +++ .../Constants/AuthenticationConstantsTests.cs | 11 ++ .../Constants/ConfigConstantsTests.cs | 34 +++++ ...tupHelpersAdminConsentInstructionsTests.cs | 11 ++ .../Helpers/SetupHelpersBootstrapTests.cs | 35 ++++- .../Helpers/SetupHelpersConsentUrlTests.cs | 20 +++ .../SetupHelpersDisplaySetupSummaryTests.cs | 27 ++++ .../Services/BlueprintLookupServiceTests.cs | 134 +++++++++++++++--- .../Services/GraphApiServiceTests.cs | 19 +++ .../Services/LogRedactionServiceTests.cs | 16 +++ 31 files changed, 698 insertions(+), 91 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89f89724..42691937 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,11 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g **Option A — Entra portal** (no config files required): 1. [Entra portal](https://entra.microsoft.com) > **App registrations** > select your **Blueprint** app > **API permissions** -2. **Add a permission** > **APIs my organization uses** > search `9b975845-388f-4429-889e-eab1ef63949c` +2. **Add a permission** > **APIs my organization uses** > search for the Observability app ID for your cloud: + - Commercial: `9b975845-388f-4429-889e-eab1ef63949c` + - GCC Moderate: `2c672ad5-b104-44ed-8069-bb68dd138546` + - GCC High: `009c6bd0-82e4-4466-95b3-4c996521f3d7` + - DoD: `a9e04047-c6a7-430b-a7ae-faf8f8eed1b7` 3. **Delegated permissions** > select `Agent365.Observability.OtelWrite` > **Add permissions** 4. Repeat step 2 > **Application permissions** > select `Agent365.Observability.OtelWrite` > **Add permissions** 5. **Grant admin consent for \** > confirm @@ -59,6 +63,8 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g - `a365 develop get-token --device-code` — forces device code auth for Microsoft Graph scopes the Windows WAM broker rejects (e.g. Exchange `MailboxSettings.ReadWrite`, `ExchangeMessageTrace.Read.All`). ### Fixed +- Setup now requests the required Graph scopes and stops safely when existing blueprint discovery is inconclusive, preventing duplicate blueprints after CLI permission changes. +- GCC Moderate, GCC High, and DoD setup now grant permissions to each cloud's Observability service instead of the commercial service. - Cloud-specific Agent 365 discover endpoint overrides now also select the messaging endpoint create and delete hosts unless explicit overrides are set. - Repeated `publish --aiteammate` runs now preserve customized manifest names instead of restoring an overlong blueprint name. - Repeated `setup blueprint --agent-name` runs now reuse the stored valid client secret instead of creating duplicate credentials. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs index 042cc0a6..397b86c0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CreateInstanceCommand.cs @@ -168,11 +168,12 @@ public static Command CreateCommand(ILogger logger, IConf if (!botApiGrantOk) logger.LogWarning("Failed to create/update oauth2PermissionGrant for agent identity to Messaging Bot API."); + var observabilityApiAppId = ConfigConstants.GetObservabilityApiAppId(instanceConfig.Environment); var observabilityApiResourceSpObjectId = await graphApiService.EnsureServicePrincipalForAppIdAsync( instanceConfig.TenantId, - ConfigConstants.ObservabilityApiAppId) + observabilityApiAppId) ?? throw new InvalidOperationException( - $"Failed to resolve service principal for Observability API (appId {ConfigConstants.ObservabilityApiAppId})."); + $"Failed to resolve service principal for Observability API (appId {observabilityApiAppId})."); // Grant oauth2PermissionGrants: *agent identity SP* -> Observability API SP var observabilityApiGrantOk = await graphApiService.CreateOrUpdateOauth2PermissionGrantAsync( diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs index 282d9971..3b9bf538 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs @@ -614,12 +614,14 @@ private static Command CreateInstanceScopesSubcommand( /// private static string? GetWellKnownResourceName(string? resourceAppId) { + if (ConfigConstants.IsObservabilityApiAppId(resourceAppId)) + return "Observability API"; + return resourceAppId switch { null or "" => null, AuthenticationConstants.MicrosoftGraphResourceAppId => "Microsoft Graph", ConfigConstants.MessagingBotApiAppId => "Messaging Bot API", - ConfigConstants.ObservabilityApiAppId => "Observability API", PowerPlatformConstants.PowerPlatformApiResourceAppId => "Power Platform API", "00000002-0000-0000-c000-000000000000" => "Azure Active Directory Graph", "797f4846-ba00-4fd7-ba43-dac1f8f63013" => "Azure Service Management", diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs index 04a1ce08..ecf2bfff 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs @@ -87,6 +87,13 @@ internal static class BatchPermissionsOrchestrator // Filter out specs with no scopes — they would produce empty OAuth2 grants (HTTP 400). // This can happen when the MCP manifest is missing or contains no required scopes. var effectiveSpecs = specs.Where(s => s.Scopes.Length > 0).ToList(); + if (setupResults is not null) + { + setupResults.ObservabilityResourceAppId = effectiveSpecs + .FirstOrDefault(spec => ConfigConstants.IsObservabilityApiAppId(spec.ResourceAppId)) + ?.ResourceAppId; + } + if (effectiveSpecs.Count < specs.Count) { var skipped = specs.Count - effectiveSpecs.Count; @@ -920,11 +927,23 @@ await EnsureMissingResourceSpsAsync( /// Updates config.ResourceConsents in-memory for each spec based on phase results. /// The caller is responsible for persisting the config via configService.SaveStateAsync. /// - private static void UpdateResourceConsents( + internal static void UpdateResourceConsents( Agent365Config config, IReadOnlyList specs, Dictionary inheritedResults) { + var configuredObservabilityAppIds = specs + .Where(spec => ConfigConstants.IsObservabilityApiAppId(spec.ResourceAppId)) + .Select(spec => spec.ResourceAppId) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (configuredObservabilityAppIds.Count > 0) + { + config.ResourceConsents.RemoveAll(resourceConsent => + ConfigConstants.IsObservabilityApiAppId(resourceConsent.ResourceAppId) && + !configuredObservabilityAppIds.Contains(resourceConsent.ResourceAppId)); + } + foreach (var spec in specs) { inheritedResults.TryGetValue(spec.ResourceAppId, out var inherited); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs index 70c8cc47..f56b3cef 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs @@ -942,7 +942,11 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( if (!string.IsNullOrWhiteSpace(displayName)) { logger.LogDebug("Searching for existing blueprint by display name: {DisplayName}...", displayName); - var lookupResult = await blueprintLookupService.GetApplicationByDisplayNameAsync(tenantId, displayName, cancellationToken: ct); + var lookupResult = await blueprintLookupService.GetApplicationByDisplayNameAsync( + tenantId, + displayName, + preferredObjectId: setupConfig.AgentBlueprintObjectId, + cancellationToken: ct); if (lookupResult.Found) { @@ -958,6 +962,17 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( blueprintAlreadyExists = true; requiresPersistence = lookupResult.RequiresPersistence; } + else if (!string.IsNullOrWhiteSpace(lookupResult.ErrorMessage)) + { + throw new SetupValidationException( + "Could not determine whether the blueprint already exists.", + errorDetails: [lookupResult.ErrorMessage], + mitigationSteps: + [ + "Confirm the CLI application has Microsoft Graph Application.Read.All consent.", + "Sign in again, then retry setup." + ]); + } } // If blueprint exists, verify service principal still exists (cached ID may be stale if SP was deleted externally) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs index ff257c3b..090be96d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs @@ -764,7 +764,11 @@ public static async Task ConfigureBotPermissionsAsync( try { - var specs = new List(SetupHelpers.GetFixedApiPermissionSpecs(setInheritable: true, isM365: true)); + var specs = new List( + SetupHelpers.GetFixedApiPermissionSpecs( + setInheritable: true, + isM365: true, + setupConfig.Environment)); var localResults = setupResults ?? new SetupResults(); var (_, _, consentGranted, adminConsentUrl) = await BatchPermissionsOrchestrator.ConfigureAllPermissionsAsync( @@ -838,11 +842,12 @@ internal static async Task RemoveStaleCustomPermissionsAsync( { // Resource app IDs owned by standard setup subcommands — never remove these var envAtgAppId = ConfigConstants.GetAgent365ToolsResourceAppId(setupConfig.Environment); + var observabilityAppId = ConfigConstants.GetObservabilityApiAppId(setupConfig.Environment); var protectedIds = new HashSet(StringComparer.OrdinalIgnoreCase) { envAtgAppId, ConfigConstants.MessagingBotApiAppId, - ConfigConstants.ObservabilityApiAppId, + observabilityAppId, PowerPlatformConstants.PowerPlatformApiResourceAppId, AuthenticationConstants.MicrosoftGraphResourceAppId, }; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs index 75dc0c7b..742a5d45 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs @@ -54,7 +54,10 @@ internal static void PrintDryRunBlueprintReuseRows(ILogger logger, string bluepr /// have no messaging surface so Bot scopes serve no purpose. /// /// - internal static ResourcePermissionSpec[] GetFixedApiPermissionSpecs(bool setInheritable, bool isM365) + internal static ResourcePermissionSpec[] GetFixedApiPermissionSpecs( + bool setInheritable, + bool isM365, + string? environment = null) { var specs = new List(); if (isM365) @@ -74,7 +77,7 @@ internal static ResourcePermissionSpec[] GetFixedApiPermissionSpecs(bool setInhe setInheritable)); } specs.Add(new ResourcePermissionSpec( - ConfigConstants.ObservabilityApiAppId, + ConfigConstants.GetObservabilityApiAppId(environment), "Observability API", new[] { ConfigConstants.ObservabilityApiOtelWriteScope }, setInheritable, @@ -146,7 +149,7 @@ internal static async Task> BuildConfiguredPermissi : "Agent 365 Tools", kvp.Value, SetInheritable: setInheritable))); - specs.AddRange(GetFixedApiPermissionSpecs(setInheritable, isM365)); + specs.AddRange(GetFixedApiPermissionSpecs(setInheritable, isM365, config.Environment)); foreach (var customPerm in config.CustomBlueprintPermissions ?? new List()) { @@ -198,12 +201,26 @@ internal static async Task ResolveBootstrapEnvironmentAsync( "az", "cloud show --query name -o tsv", captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); var cloudName = result.StandardOutput?.Trim(); + if (string.Equals(cloudName, "AzureUSGovernment", StringComparison.OrdinalIgnoreCase)) + { + throw new SetupValidationException( + "The Azure CLI cloud does not distinguish GCC Moderate, GCC High, and DoD.", + mitigationSteps: + [ + "Set A365_ENVIRONMENT to gcc, gcc-high, or dod for the target tenant, then retry setup." + ]); + } + return string.IsNullOrWhiteSpace(cloudName) ? "prod" : cloudName; } catch (OperationCanceledException) { throw; } + catch (SetupValidationException) + { + throw; + } catch (Exception ex) { logger.LogDebug(ex, "Failed to resolve current Azure CLI cloud; using the default environment."); @@ -427,11 +444,22 @@ internal static async Task ResolveBootstrapEnvironmentAsync( /// when additional APIs are required (e.g. dynamic MCP scopes, custom permissions). /// internal static readonly IReadOnlyList<(string ResourceName, string ResourceAppId, string Scope, string PermissionType)> NonDwAdminConsentSpecs = - [ - ("Observability API", ConfigConstants.ObservabilityApiAppId, ConfigConstants.ObservabilityApiOtelWriteScope, "Application"), - ("Observability API", ConfigConstants.ObservabilityApiAppId, ConfigConstants.ObservabilityApiOtelWriteScope, "Delegated"), - ("Power Platform API", PowerPlatformConstants.PowerPlatformApiResourceAppId, PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead, "Delegated"), - ]; + GetNonDwAdminConsentSpecs("prod"); + + internal static IReadOnlyList<(string ResourceName, string ResourceAppId, string Scope, string PermissionType)> GetNonDwAdminConsentSpecs( + string? environment) + => BuildNonDwAdminConsentSpecs(ConfigConstants.GetObservabilityApiAppId(environment)); + + private static IReadOnlyList<(string ResourceName, string ResourceAppId, string Scope, string PermissionType)> BuildNonDwAdminConsentSpecs( + string observabilityAppId) + { + return + [ + ("Observability API", observabilityAppId, ConfigConstants.ObservabilityApiOtelWriteScope, "Application"), + ("Observability API", observabilityAppId, ConfigConstants.ObservabilityApiOtelWriteScope, "Delegated"), + ("Power Platform API", PowerPlatformConstants.PowerPlatformApiResourceAppId, PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead, "Delegated"), + ]; + } /// /// Logs step-by-step instructions for a Global Administrator to grant admin consent @@ -445,9 +473,11 @@ internal static void LogNonDwAdminConsentInstructions( ILogger logger, string blueprintId, IReadOnlyList<(string ResourceName, string ResourceAppId, string Scope, string PermissionType)>? specs = null, - string? tenantId = null) + string? tenantId = null, + string? environment = null) { - specs ??= NonDwAdminConsentSpecs; + specs ??= GetNonDwAdminConsentSpecs( + environment ?? Environment.GetEnvironmentVariable("A365_ENVIRONMENT") ?? "prod"); var delegatedSpecs = specs.Where(s => s.PermissionType == "Delegated").ToList(); var directLink = $"https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/CallAnAPI/appId/{blueprintId}/isMSAApp~/false"; @@ -456,8 +486,12 @@ internal static void LogNonDwAdminConsentInstructions( logger.LogInformation(" 1. Sign in as {Roles} and open:", AuthenticationConstants.DelegatedGrantRequiredRoles); logger.LogInformation(" {Link}", directLink); logger.LogInformation(" 2. Add the following permissions (click 'Add a permission' for each):"); - foreach (var group in delegatedSpecs.GroupBy(s => (s.ResourceName, s.Scope))) - logger.LogInformation(" - {ResourceName,-20}: {Scope} (Delegated)", group.Key.ResourceName, group.Key.Scope); + foreach (var group in delegatedSpecs.GroupBy(s => (s.ResourceName, s.ResourceAppId, s.Scope))) + logger.LogInformation( + " - {ResourceName,-20}: {Scope} (Delegated, app ID: {ResourceAppId})", + group.Key.ResourceName, + group.Key.Scope, + group.Key.ResourceAppId); logger.LogInformation(" 3. Click 'Grant admin consent for your organization' and confirm"); logger.LogInformation(""); @@ -521,6 +555,8 @@ public static async Task DisplayVerificationInfoAsync(FileInfo setupConfigFile, public static void DisplaySetupSummary(SetupResults results, ILogger logger, string? graphBaseUrl = null) { var resolvedGraphBaseUrl = ConfigConstants.NormalizeGraphBaseUrl(graphBaseUrl); + var observabilityResourceAppId = + results.ObservabilityResourceAppId ?? ConfigConstants.ObservabilityApiAppId; var isNonDw = results.IsNonDwBlueprintFlow; var isBlueprintOnly = results.IsBlueprintOnlyFlow; // Which row groups this run actually performs. Blueprint-only ('setup blueprint') stops after @@ -864,7 +900,11 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger, str if (isNonDw && string.IsNullOrWhiteSpace(consentUrl)) { logger.LogInformation(" {N}. Permission Grants — must be granted by {Roles} in the Entra portal:", actionCount, AuthenticationConstants.DelegatedGrantRequiredRoles); - LogNonDwAdminConsentInstructions(logger, adminCmdBlueprintId, tenantId: results.TenantId); + LogNonDwAdminConsentInstructions( + logger, + adminCmdBlueprintId, + specs: BuildNonDwAdminConsentSpecs(observabilityResourceAppId), + tenantId: results.TenantId); } else { @@ -904,7 +944,7 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger, str // Grant targets the agent identity SP directly (SP object ID, not an app ID). var agentSpId = results.AgentIdentityId ?? ""; logger.LogInformation(" $agentSpId = '{AgentSpId}'", agentSpId); - logger.LogInformation(" $obs = Get-MgServicePrincipal -Filter \"appId eq '{ObsApiAppId}'\"", ConfigConstants.ObservabilityApiAppId); + logger.LogInformation(" $obs = Get-MgServicePrincipal -Filter \"appId eq '{ObsApiAppId}'\"", observabilityResourceAppId); logger.LogInformation(" $rid = ($obs.AppRoles | Where-Object {{ $_.Value -eq '{ObsScope}' }}).Id", ConfigConstants.ObservabilityApiOtelWriteScope); logger.LogInformation(" New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $agentSpId -PrincipalId $agentSpId -ResourceId $obs.Id -AppRoleId $rid"); logger.LogInformation(""); @@ -916,7 +956,7 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger, str { // DW: grant targets the blueprint SP (looked up by app ID). logger.LogInformation(" $bp = Get-MgServicePrincipal -Filter \"appId eq '{BlueprintAppId}'\"", blueprintAppId); - logger.LogInformation(" $obs = Get-MgServicePrincipal -Filter \"appId eq '{ObsApiAppId}'\"", ConfigConstants.ObservabilityApiAppId); + logger.LogInformation(" $obs = Get-MgServicePrincipal -Filter \"appId eq '{ObsApiAppId}'\"", observabilityResourceAppId); logger.LogInformation(" $rid = ($obs.AppRoles | Where-Object {{ $_.Value -eq '{ObsScope}' }}).Id", ConfigConstants.ObservabilityApiOtelWriteScope); logger.LogInformation(" New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $bp.Id -PrincipalId $bp.Id -ResourceId $obs.Id -AppRoleId $rid"); logger.LogInformation(""); @@ -939,7 +979,7 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger, str logger.LogInformation(" $agentSpId = '{AgentSpId}'", results.AgentIdentityId ?? ""); logger.LogInformation(""); logger.LogInformation(" # Observability API"); - logger.LogInformation(" $obsSp = Get-MgServicePrincipal -Filter \"appId eq '{ObsAppId}'\"", ConfigConstants.ObservabilityApiAppId); + logger.LogInformation(" $obsSp = Get-MgServicePrincipal -Filter \"appId eq '{ObsAppId}'\"", observabilityResourceAppId); logger.LogInformation(" $body = @{{ clientId = $agentSpId; consentType = 'AllPrincipals'; resourceId = $obsSp.Id; scope = '{ObsScope}' }} | ConvertTo-Json", ConfigConstants.ObservabilityApiOtelWriteScope); logger.LogInformation(" Invoke-MgGraphRequest -Method POST -Uri '{GraphBaseUrl}/v1.0/oauth2PermissionGrants' -Body $body -ContentType 'application/json'", resolvedGraphBaseUrl); logger.LogInformation(""); @@ -1125,11 +1165,12 @@ internal static List PopulateAdminConsentUrls( var graphBaseUrl = ConfigConstants.GetGraphBaseUrl(config.Environment, config.GraphBaseUrl); var graphResourceUri = graphBaseUrl; var authorityHost = ConfigConstants.GetAuthorityHost(config.Environment, config.AuthorityHost); + var observabilityResourceAppId = ConfigConstants.GetObservabilityApiAppId(config.Environment); var urls = BuildAdminConsentUrls( config.TenantId, config.AgentBlueprintId!, config.AgentApplicationScopes, mcpScopes, isM365, mcpScopesByAudience, mcpAudienceDisplayNames, graphResourceUri, authorityHost, - mcpResourceAppId); + mcpResourceAppId, observabilityResourceAppId); // Map resource names to App IDs for upsert into ResourceConsents. The fixed-name // entries cover Graph + Bot + Obs + PP + the WorkIQ shared MCP audience. V2 @@ -1142,7 +1183,7 @@ internal static List PopulateAdminConsentUrls( ["Microsoft Graph"] = AuthenticationConstants.MicrosoftGraphResourceAppId, ["Agent 365 Tools"] = mcpResourceAppId, ["Messaging Bot API"] = ConfigConstants.MessagingBotApiAppId, - ["Observability API"] = ConfigConstants.ObservabilityApiAppId, + ["Observability API"] = observabilityResourceAppId, ["Power Platform API"] = PowerPlatformConstants.PowerPlatformApiResourceAppId, }; @@ -1248,8 +1289,8 @@ internal static string GetResourceIdentifierUri( return graphResourceUri; if (string.Equals(resourceAppId, ConfigConstants.MessagingBotApiAppId, StringComparison.OrdinalIgnoreCase)) return ConfigConstants.MessagingBotApiIdentifierUri; - if (string.Equals(resourceAppId, ConfigConstants.ObservabilityApiAppId, StringComparison.OrdinalIgnoreCase)) - return ConfigConstants.ObservabilityApiIdentifierUri; + if (ConfigConstants.IsObservabilityApiAppId(resourceAppId)) + return ConfigConstants.BuildObservabilityApiIdentifierUri(resourceAppId); if (string.Equals(resourceAppId, PowerPlatformConstants.PowerPlatformApiResourceAppId, StringComparison.OrdinalIgnoreCase)) return PowerPlatformConstants.PowerPlatformApiIdentifierUri; // WorkIQ Tools shared (issue #429): match by appId, not display name. V2 per-server @@ -1326,7 +1367,8 @@ internal static string BuildFullyQualifiedScope( IReadOnlyDictionary>? mcpAudienceDisplayNames = null, string graphResourceUri = AuthenticationConstants.MicrosoftGraphResourceUri, string? authorityHost = null, - string? sharedMcpResourceAppId = null) + string? sharedMcpResourceAppId = null, + string? observabilityResourceAppId = null) { var urls = new List<(string, string)>(); @@ -1389,7 +1431,9 @@ string Build(string tenant, string client, string resourceUri, IEnumerable? mcpScopesByAudience = null, string graphResourceUri = AuthenticationConstants.MicrosoftGraphResourceUri, string? authorityHost = null, - string? sharedMcpResourceAppId = null) + string? sharedMcpResourceAppId = null, + string? observabilityResourceAppId = null) { var allScopes = new List(); foreach (var s in graphScopes) @@ -1448,7 +1493,8 @@ internal static string BuildCombinedConsentUrl( if (isM365) allScopes.Add($"{ConfigConstants.MessagingBotApiIdentifierUri}/{ConfigConstants.MessagingBotApiAdminConsentScope}"); - allScopes.Add($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiOtelWriteScope}"); + allScopes.Add( + $"{ConfigConstants.BuildObservabilityApiIdentifierUri(observabilityResourceAppId ?? ConfigConstants.ObservabilityApiAppId)}/{ConfigConstants.ObservabilityApiOtelWriteScope}"); allScopes.Add($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}"); return BuildAdminConsentUrl(tenantId, blueprintClientId, allScopes, authorityHost); } @@ -1482,10 +1528,11 @@ internal static void ApplyConsentUrlsIfNeeded( var graphBaseUrl = ConfigConstants.GetGraphBaseUrl(ctx.Config.Environment, ctx.Config.GraphBaseUrl); var graphResourceUri = graphBaseUrl; var authorityHost = ConfigConstants.GetAuthorityHost(ctx.Config.Environment, ctx.Config.AuthorityHost); + var observabilityResourceAppId = ConfigConstants.GetObservabilityApiAppId(ctx.Config.Environment); ctx.Results.CombinedConsentUrl = BuildCombinedConsentUrl( ctx.Config.TenantId!, ctx.Config.AgentBlueprintId!, graphScopes, mcpScopes, isM365, mcpScopesByAudience, graphResourceUri, authorityHost, - mcpResourceAppId); + mcpResourceAppId, observabilityResourceAppId); } /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs index 2465f917..a842e664 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs @@ -16,6 +16,7 @@ public class SetupResults public string? BlueprintDisplayName { get; set; } public bool McpPermissionsConfigured { get; set; } public bool BotApiPermissionsConfigured { get; set; } + public string? ObservabilityResourceAppId { get; set; } public bool MessagingEndpointRegistered { get; set; } /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs index 5641dcfc..efb7cef5 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs @@ -260,16 +260,11 @@ public static bool IsWellKnownFirstPartyClientApp(string? clientAppId) => .ToArray(); /// - /// Explicit delegated scopes passed to EnsureGraphHeadersAsync for permission-grant operations. - /// Intentionally empty: the operations that previously needed explicit scopes here - /// (DelegatedPermissionGrant.ReadWrite.All for oauth2 grant CRUD, - /// AgentIdentityBlueprint.UpdateAuthProperties.All for inheritable permissions) are now - /// covered by the AgentIdentityBlueprint.ReadWrite.All umbrella in RequiredClientAppPermissions. - /// An empty array causes EnsureGraphHeadersAsync to route through the standard token path - /// (GetGraphAccessTokenAsync / AuthenticationService), which already carries all required scopes. - /// Validated end-to-end across all 4 setup variants (PR #409). - /// - public static readonly string[] RequiredPermissionGrantScopes = []; + /// Delegated scopes requested for permission and blueprint-resource operations. + /// These calls include application and service-principal reads, so they must not fall back to + /// the custom app's default User.Read token. + /// + public static readonly string[] RequiredPermissionGrantScopes = BlueprintOperationScopes.ToArray(); /// /// Additional scopes for S2S app role assignment calls in BatchPermissionsOrchestrator. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs index e88166e0..a37895f1 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs @@ -84,12 +84,27 @@ public static class ConfigConstants public const string MessagingBotApiIdentifierUri = "https://botapi.skype.com"; /// - /// Observability API App ID + /// Commercial Observability API App ID /// public const string ObservabilityApiAppId = "9b975845-388f-4429-889e-eab1ef63949c"; /// - /// Observability API identifier URI (uses api:// scheme — no public https URI registered). + /// GCC Moderate Observability API App ID + /// + public const string GccObservabilityApiAppId = "2c672ad5-b104-44ed-8069-bb68dd138546"; + + /// + /// GCC High Observability API App ID + /// + public const string GccHighObservabilityApiAppId = "009c6bd0-82e4-4466-95b3-4c996521f3d7"; + + /// + /// DoD Observability API App ID + /// + public const string DodObservabilityApiAppId = "a9e04047-c6a7-430b-a7ae-faf8f8eed1b7"; + + /// + /// Commercial Observability API identifier URI. /// public const string ObservabilityApiIdentifierUri = "api://9b975845-388f-4429-889e-eab1ef63949c"; @@ -179,6 +194,38 @@ public static string GetAgent365ToolsResourceAppId(string environment) => GetEnvironmentScopedSetting("A365_MCP_APP_ID", environment) ?? McpConstants.WorkIQToolsProdAppId; + /// + /// Returns the Observability resource Application ID for the selected cloud. + /// + public static string GetObservabilityApiAppId(string? environment) + => NormalizeEnvironmentKey(environment) switch + { + "GCC" or "GCC_MODERATE" => GccObservabilityApiAppId, + "GCC_HIGH" => GccHighObservabilityApiAppId, + "DOD" => DodObservabilityApiAppId, + "AZUREUSGOVERNMENT" => throw new ArgumentException( + "AzureUSGovernment does not distinguish GCC Moderate, GCC High, and DoD. " + + "Set the environment to gcc, gcc-high, or dod.", + nameof(environment)), + _ => ObservabilityApiAppId, + }; + + /// + /// Returns the Observability resource identifier URI for the selected cloud. + /// + public static string GetObservabilityApiIdentifierUri(string? environment) + => BuildObservabilityApiIdentifierUri(GetObservabilityApiAppId(environment)); + + internal static string BuildObservabilityApiIdentifierUri(string appId) + => $"api://{appId}"; + + internal static bool IsObservabilityApiAppId(string? appId) + => appId is not null && + (appId.Equals(ObservabilityApiAppId, StringComparison.OrdinalIgnoreCase) || + appId.Equals(GccObservabilityApiAppId, StringComparison.OrdinalIgnoreCase) || + appId.Equals(GccHighObservabilityApiAppId, StringComparison.OrdinalIgnoreCase) || + appId.Equals(DodObservabilityApiAppId, StringComparison.OrdinalIgnoreCase)); + /// /// Returns the authority host for the selected cloud environment. /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs index 17a22467..834bdf1c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs @@ -125,9 +125,9 @@ private static void ValidateAuthMode(string? value, List errors) public string TenantId { get; init; } = string.Empty; /// - /// Target environment for Agent 365 services (test, preprod, prod). - /// Controls which endpoints are used for Teams Graph API, Agent 365 Tools, etc. - /// Default: preprod + /// Target Agent 365 environment or cloud key. + /// Supported production cloud keys include prod, gcc, gcc-high, and dod. + /// Controls service endpoints, resource application IDs, and authentication audiences. /// [JsonPropertyName("environment")] public string Environment { get; init; } = "prod"; @@ -539,7 +539,7 @@ public bool IsBotInheritanceConfigured() { var botResources = ResourceConsents .Where(rc => rc.ResourceAppId.Equals(ConfigConstants.MessagingBotApiAppId, StringComparison.OrdinalIgnoreCase) || - rc.ResourceAppId.Equals(ConfigConstants.ObservabilityApiAppId, StringComparison.OrdinalIgnoreCase) || + ConfigConstants.IsObservabilityApiAppId(rc.ResourceAppId) || rc.ResourceAppId.Equals(PowerPlatformConstants.PowerPlatformApiResourceAppId, StringComparison.OrdinalIgnoreCase)) .Where(rc => rc.InheritablePermissionsConfigured.HasValue) .ToList(); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs index 6ff60507..40b264a1 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs @@ -162,6 +162,7 @@ string GetConfig(string name) => if (!string.IsNullOrWhiteSpace(configuredClientAppId)) _graphService.CustomClientAppId = configuredClientAppId; var mcpResourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(environment); + var observabilityResourceAppId = ConfigConstants.GetObservabilityApiAppId(environment); var usageLocation = GetConfig("agentUserUsageLocation"); @@ -342,7 +343,7 @@ string GetConfig(string name) => "McpServers.Mail.All", "McpServersMetadata.Read.All" }), - [ConfigConstants.ObservabilityApiAppId] = ( + [observabilityResourceAppId] = ( "Observability API", new HashSet(StringComparer.OrdinalIgnoreCase) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/BlueprintLookupService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/BlueprintLookupService.cs index fe9bf45d..1903591a 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/BlueprintLookupService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/BlueprintLookupService.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Text.Json; +using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Extensions.Logging; using Microsoft.Agents.A365.DevTools.Cli.Models; @@ -102,6 +103,7 @@ public async Task GetApplicationByDisplayNameAsync( string tenantId, string displayName, string signInAudience = "AzureADMultipleOrgs", + string? preferredObjectId = null, CancellationToken cancellationToken = default) { try @@ -112,18 +114,37 @@ public async Task GetApplicationByDisplayNameAsync( var escapedDisplayName = displayName.Replace("'", "''"); var filter = $"displayName eq '{escapedDisplayName}' and signInAudience eq '{signInAudience}'"; - var doc = await _graphApiService.GraphGetAsync( + var response = await _graphApiService.GraphGetWithResponseAsync( tenantId, $"/beta/applications?$filter={Uri.EscapeDataString(filter)}", - cancellationToken); + scopes: [AuthenticationConstants.ApplicationReadAllScope], + ct: cancellationToken); + + if (!response.IsSuccess) + { + response.Json?.Dispose(); + var errorMessage = $"Graph application lookup failed with HTTP {response.StatusCode} {response.ReasonPhrase}."; + _logger.LogDebug( + "Blueprint lookup by displayName failed with HTTP {StatusCode} {ReasonPhrase}: {Body}", + response.StatusCode, + response.ReasonPhrase, + response.Body); + return new BlueprintLookupResult + { + Found = false, + LookupMethod = "displayName", + ErrorMessage = errorMessage + }; + } + using var doc = response.Json; if (doc == null) { - _logger.LogDebug("No blueprints found with displayName: {DisplayName}", displayName); return new BlueprintLookupResult { Found = false, - LookupMethod = "displayName" + LookupMethod = "displayName", + ErrorMessage = "Graph application lookup returned an empty response." }; } @@ -138,18 +159,45 @@ public async Task GetApplicationByDisplayNameAsync( }; } - // Take first match (if multiple exist, log warning) - var firstMatch = valueElement[0]; - var objectId = firstMatch.GetProperty("id").GetString(); - var appId = firstMatch.GetProperty("appId").GetString(); - var foundDisplayName = firstMatch.GetProperty("displayName").GetString(); + JsonElement? selectedMatch = null; + if (!string.IsNullOrWhiteSpace(preferredObjectId)) + { + foreach (var candidate in valueElement.EnumerateArray()) + { + if (string.Equals( + candidate.GetProperty("id").GetString(), + preferredObjectId, + StringComparison.OrdinalIgnoreCase)) + { + selectedMatch = candidate; + break; + } + } + } + + if (selectedMatch is null && valueElement.GetArrayLength() == 1) + { + selectedMatch = valueElement[0]; + } - if (valueElement.GetArrayLength() > 1) + if (selectedMatch is null) { - _logger.LogWarning("Multiple blueprints found with displayName '{DisplayName}'. Using first match: {ObjectId}", - displayName, objectId); + var errorMessage = string.IsNullOrWhiteSpace(preferredObjectId) + ? $"Multiple blueprints were found with display name '{displayName}'." + : $"Multiple blueprints were found with display name '{displayName}', but none matched the stored object ID '{preferredObjectId}'."; + _logger.LogWarning("{ErrorMessage}", errorMessage); + return new BlueprintLookupResult + { + Found = false, + LookupMethod = "displayName", + ErrorMessage = errorMessage + }; } + var objectId = selectedMatch.Value.GetProperty("id").GetString(); + var appId = selectedMatch.Value.GetProperty("appId").GetString(); + var foundDisplayName = selectedMatch.Value.GetProperty("displayName").GetString(); + _logger.LogDebug("Found blueprint: {DisplayName} (ObjectId: {ObjectId}, AppId: {AppId})", foundDisplayName, objectId, appId); @@ -163,6 +211,10 @@ public async Task GetApplicationByDisplayNameAsync( RequiresPersistence = true }; } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception ex) { _logger.LogDebug(ex, "Failed to look up blueprint by displayName: {DisplayName}", displayName); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs index a81c0aeb..4dea9143 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs @@ -889,6 +889,9 @@ private async Task EnsurePermissionsConfiguredAsync( // Best-effort: also extend the existing oauth2PermissionGrant so consent takes effect immediately await TryExtendConsentGrantScopesAsync(clientAppId, missingPermissions, tenantId, ct); + // Tokens issued before the permission update cannot carry the newly consented scopes. + await _graphApiService.ClearTokenCacheAsync(); + return true; } catch (Exception ex) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index db3b2cea..da44ba02 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -183,7 +183,11 @@ public GraphApiService(ILogger logger, CommandExecutor executor /// can invalidate after an operation that makes cached tokens stale — most commonly after adding /// the wids optional claim to the client app registration. /// - public virtual Task ClearTokenCacheAsync() => _authService.ClearTokenCacheAsync(); + public virtual async Task ClearTokenCacheAsync() + { + _tokenProvider?.ClearTokenCache(); + await _authService.ClearTokenCacheAsync(); + } /// /// Acquires an access token for Microsoft Graph API via MSAL (WAM on Windows, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/IMicrosoftGraphTokenProvider.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/IMicrosoftGraphTokenProvider.cs index 006432b2..9e1b7e48 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/IMicrosoftGraphTokenProvider.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/IMicrosoftGraphTokenProvider.cs @@ -8,6 +8,11 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services; /// public interface IMicrosoftGraphTokenProvider { + /// + /// Clears access tokens cached in memory by this provider. + /// + void ClearTokenCache(); + /// /// Acquires a delegated access token for Microsoft Graph using PowerShell authentication. /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs index e9fed441..261e5462 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs @@ -86,6 +86,8 @@ public MicrosoftGraphTokenProvider( _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } + public void ClearTokenCache() => _tokenCache.Clear(); + public async Task GetMgGraphAccessTokenAsync( string tenantId, IEnumerable scopes, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/LogRedactionService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/LogRedactionService.cs index d50a8dcc..d8402d9d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/LogRedactionService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/LogRedactionService.cs @@ -60,6 +60,9 @@ public sealed class LogRedactionService : ILogRedactionService "00000003-0000-0000-c000-000000000000", // Microsoft Graph "5a807f24-c9de-44ee-a3a7-329e88a00ffc", // Agent 365 Messaging Bot API "9b975845-388f-4429-889e-eab1ef63949c", // Agent 365 Observability API + ConfigConstants.GccObservabilityApiAppId, + ConfigConstants.GccHighObservabilityApiAppId, + ConfigConstants.DodObservabilityApiAppId, "8578e004-a5c6-46e7-913e-12f58912df43", // Power Platform API (Connectivity) "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1", // Agent 365 Tools (MCP audience, production) AuthenticationConstants.WellKnownClientAppId, // Agent 365 CLI (well-known first-party application) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/design.md b/src/Microsoft.Agents.A365.DevTools.Cli/design.md index dc219f8b..3ed0f6d6 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/design.md +++ b/src/Microsoft.Agents.A365.DevTools.Cli/design.md @@ -154,11 +154,20 @@ For security and flexibility, the CLI supports environment variable overrides: | `A365_DELETE_ENDPOINT_{ENV}` | Higher-precedence per-environment delete endpoint override | | `POWERPLATFORM_API_URL` | Override Power Platform API URL | -**Design Decision:** All test/preprod App IDs and URLs have been removed from the codebase. The production App ID is the only hardcoded value. Internal Microsoft developers use environment variables for non-production testing. +**Design Decision:** Test and preproduction App IDs and URLs are supplied through environment variables. Public production resource IDs that differ by sovereign cloud, including Observability, are selected from the configured environment. + +| Cloud | `environment` value | Observability resource app ID | +|-------|---------------------|-------------------------------| +| Commercial | `prod` | `9b975845-388f-4429-889e-eab1ef63949c` | +| GCC Moderate | `gcc` | `2c672ad5-b104-44ed-8069-bb68dd138546` | +| GCC High | `gcc-high` | `009c6bd0-82e4-4466-95b3-4c996521f3d7` | +| DoD | `dod` | `a9e04047-c6a7-430b-a7ae-faf8f8eed1b7` | ### Sovereign / Government Cloud Configuration -By default the CLI targets the commercial Microsoft Graph endpoint. For sovereign or government cloud tenants, set `graphBaseUrl` in `a365.config.json`: +Set `environment` explicitly for every government cloud because it selects cloud-specific Agent 365 endpoints, authentication audiences, and Observability resources. The Graph endpoint alone cannot distinguish GCC High from DoD. + +By default the CLI targets the commercial Microsoft Graph endpoint. For GCC High, DoD, or other sovereign tenants, also set `graphBaseUrl` in `a365.config.json`: | Cloud | `graphBaseUrl` value | |-------|----------------------| diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BatchPermissionsOrchestratorTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BatchPermissionsOrchestratorTests.cs index 26c0a65a..7cfdeb20 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BatchPermissionsOrchestratorTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BatchPermissionsOrchestratorTests.cs @@ -862,6 +862,39 @@ await BatchPermissionsOrchestrator.ConfigureAllPermissionsAsync( // the deeper layer: that the URL-building loop honors knownMcpAudienceAppIds per spec. // ────────────────────────────────────────────────────────────────────────────────────── + [Fact] + public void UpdateResourceConsents_ReplacesCommercialObservabilityEntryForGcc() + { + var config = new Agent365Config(); + config.ResourceConsents.Add(new ResourceConsent + { + ResourceName = "Observability API", + ResourceAppId = ConfigConstants.ObservabilityApiAppId, + ConsentGranted = true, + }); + var specs = new[] + { + new ResourcePermissionSpec( + ConfigConstants.GccObservabilityApiAppId, + "Observability API", + new[] { ConfigConstants.ObservabilityApiOtelWriteScope }, + SetInheritable: true), + }; + var inheritedResults = new Dictionary + { + [ConfigConstants.GccObservabilityApiAppId] = (true, false), + }; + + BatchPermissionsOrchestrator.UpdateResourceConsents(config, specs, inheritedResults); + + config.ResourceConsents.Should().ContainSingle( + resourceConsent => ConfigConstants.IsObservabilityApiAppId(resourceConsent.ResourceAppId), + because: "state must contain only the Observability resource for the currently selected cloud"); + config.ResourceConsents.Single().ResourceAppId.Should().Be( + ConfigConstants.GccObservabilityApiAppId, + because: "a GCC rerun must replace stale commercial Observability state"); + } + /// /// Non-admin path: GrantAdminConsentAsync builds the unified consent URL via the catch-all /// spec loop and returns it for hand-off. When a spec's appId is in knownMcpAudienceAppIds, diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/BlueprintSubcommandInvalidationTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/BlueprintSubcommandInvalidationTests.cs index 31930596..4a7d05d9 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/BlueprintSubcommandInvalidationTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/BlueprintSubcommandInvalidationTests.cs @@ -5,6 +5,7 @@ using System.Text.Json.Nodes; using FluentAssertions; using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging; @@ -92,12 +93,18 @@ public async Task CreateAgentBlueprintAsync_WhenNoExistingBlueprintFound_Invalid { // Force the displayName-first lookup to report "not found" so we reach the new-blueprint // creation path. The service's `if (doc == null)` branch maps to Found=false. - _graphApiService.GraphGetAsync( + _graphApiService.GraphGetWithResponseAsync( Arg.Any(), Arg.Any(), - Arg.Any(), - Arg.Any?>()) - .Returns(Task.FromResult(null)); + false, + Arg.Any?>(), + Arg.Any()) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDocument.Parse("""{"value":[]}""") + }); // Pre-populate the in-memory JsonObject with the kinds of stale identifiers the // invalidation block exists to wipe. If the clear loop is removed, these survive and @@ -217,4 +224,71 @@ await _configService.Received(1).InvalidateGeneratedConfigAsync( } } } + + [Fact] + public async Task CreateAgentBlueprintAsync_WhenLookupIsForbidden_DoesNotInvalidateGeneratedConfig() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), $"a365-blueprint-lookup-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + var configFile = new FileInfo(Path.Combine(tempDir, "a365.config.json")); + var generatedConfig = new JsonObject + { + ["agentBlueprintId"] = "existing-blueprint-app-id", + ["agentBlueprintObjectId"] = "existing-blueprint-object-id" + }; + var setupConfig = new Agent365Config + { + TenantId = TenantId, + AgentBlueprintDisplayName = DisplayName, + AgentBlueprintObjectId = "existing-blueprint-object-id" + }; + + try + { + _graphApiService.GraphGetWithResponseAsync( + Arg.Any(), + Arg.Any(), + false, + Arg.Any?>(), + Arg.Any()) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = false, + StatusCode = 403, + ReasonPhrase = "Forbidden" + }); + + // Act + var act = () => BlueprintSubcommand.CreateAgentBlueprintAsync( + _logger, + _executor, + _graphApiService, + _blueprintService, + _blueprintLookupService, + _federatedCredentialService, + tenantId: TenantId, + displayName: DisplayName, + agentIdentityDisplayName: null, + managedIdentityPrincipalId: null, + useManagedIdentity: true, + generatedConfig, + setupConfig, + _configService, + configFile, + CancellationToken.None); + + // Assert + await act.Should().ThrowAsync( + because: "an authorization failure makes existing-resource discovery inconclusive"); + await _configService.DidNotReceiveWithAnyArgs() + .InvalidateGeneratedConfigAsync(default!, default!, default!); + generatedConfig["agentBlueprintObjectId"]!.GetValue().Should().Be("existing-blueprint-object-id", + because: "inconclusive discovery must preserve the current generated state"); + } + finally + { + Directory.Delete(tempDir, recursive: true); + } + } } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs index 674d6a52..ca1fae04 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs @@ -103,6 +103,26 @@ public async Task DwPath_NoManifest_NoCustom_ProducesBaselineSpecSet() because: "Microsoft Graph spec scopes must come from Agent365Config.AgentApplicationScopes"); } + [Fact] + public async Task GccPath_UsesGccObservabilityResource() + { + var config = new Agent365Config + { + DeploymentProjectPath = _tempDir, + Environment = "gcc", + }; + + var specs = await SetupHelpers.BuildConfiguredPermissionSpecsAsync( + config, + setInheritable: true, + isM365: true); + + ResourceAppIds(specs).Should().Contain(ConfigConstants.GccObservabilityApiAppId, + because: "GCC blueprints must inherit permissions from the GCC Observability resource"); + ResourceAppIds(specs).Should().NotContain(ConfigConstants.ObservabilityApiAppId, + because: "the commercial Observability resource must not be stamped on a GCC blueprint"); + } + [Fact] public async Task DwPath_WithV1Manifest_CollapsesLegacyAudienceOntoAtgAppId() { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/AuthenticationConstantsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/AuthenticationConstantsTests.cs index 97e98898..ba060224 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/AuthenticationConstantsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/AuthenticationConstantsTests.cs @@ -11,6 +11,17 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Constants; /// public class AuthenticationConstantsTests { + [Fact] + public void RequiredPermissionGrantScopes_IncludeApplicationAndBlueprintScopes() + { + AuthenticationConstants.RequiredPermissionGrantScopes.Should().Contain( + AuthenticationConstants.ApplicationReadAllScope, + because: "permission setup reads applications and service principals before applying grants"); + AuthenticationConstants.RequiredPermissionGrantScopes.Should().Contain( + "AgentIdentityBlueprint.ReadWrite.All", + because: "permission setup reads and writes inheritable blueprint permissions"); + } + [Fact] public void AzureCliClientId_ShouldBeValidGuid() { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/ConfigConstantsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/ConfigConstantsTests.cs index 60e1efbd..d568c048 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/ConfigConstantsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Constants/ConfigConstantsTests.cs @@ -64,6 +64,40 @@ public void AuthorityHost_UsesScopedOverrideThenConfigThenDefault() } + [Theory] + [InlineData("prod", ConfigConstants.ObservabilityApiAppId)] + [InlineData("commercial", ConfigConstants.ObservabilityApiAppId)] + [InlineData("gcc", ConfigConstants.GccObservabilityApiAppId)] + [InlineData("GCC Moderate", ConfigConstants.GccObservabilityApiAppId)] + [InlineData("gcc-moderate", ConfigConstants.GccObservabilityApiAppId)] + [InlineData("gcc-high", ConfigConstants.GccHighObservabilityApiAppId)] + [InlineData("GCC High", ConfigConstants.GccHighObservabilityApiAppId)] + [InlineData("dod", ConfigConstants.DodObservabilityApiAppId)] + public void GetObservabilityApiAppId_ReturnsCloudSpecificResource( + string environment, + string expected) + { + ConfigConstants.GetObservabilityApiAppId(environment).Should().Be(expected, + because: "Observability tokens and permission grants must target the resource deployed in the selected cloud"); + } + + [Fact] + public void GetObservabilityApiIdentifierUri_UsesResolvedCloudResource() + { + ConfigConstants.GetObservabilityApiIdentifierUri("gcc") + .Should().Be($"api://{ConfigConstants.GccObservabilityApiAppId}", + because: "the admin-consent scope prefix must match the GCC Observability application ID"); + } + + [Fact] + public void GetObservabilityApiAppId_WithAmbiguousAzureGovernmentCloud_Throws() + { + var act = () => ConfigConstants.GetObservabilityApiAppId("AzureUSGovernment"); + + act.Should().Throw( + because: "the Azure CLI government cloud name cannot distinguish GCC Moderate, GCC High, and DoD resources"); + } + [Theory] [InlineData("http://graph.example")] [InlineData("https://user@graph.example")] diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs index 4e8d61e1..03fcd851 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs @@ -135,6 +135,17 @@ public void NonDwAdminConsentSpecs_ContainsBothPermissionTypesForObservabilityAp because: "OBO flow requires a delegated oauth2 grant"); } + [Fact] + public void GetNonDwAdminConsentSpecs_ForGcc_UsesGccObservabilityResource() + { + var specs = SetupHelpers.GetNonDwAdminConsentSpecs("gcc"); + + specs.Where(spec => spec.ResourceName == "Observability API") + .Should().OnlyContain( + spec => spec.ResourceAppId == ConfigConstants.GccObservabilityApiAppId, + because: "manual GCC consent instructions must target the GCC Observability resource"); + } + [Fact] public void NonDwAdminConsentSpecs_PowerPlatformApi_IsDelegatedOnly() { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersBootstrapTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersBootstrapTests.cs index f1c21b53..298583b8 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersBootstrapTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersBootstrapTests.cs @@ -232,7 +232,7 @@ public async Task ResolveBootstrapTenantIdAsync_WhenNoFlag_AndExecutorFails_Retu [Fact] public async Task ResolveBootstrapEnvironmentAsync_WhenEnvironmentVariableIsSet_UsesItWithoutCallingAzureCli() { - const string expectedEnvironment = "AzureUSGovernment"; + const string expectedEnvironment = "gcc"; var originalEnvironment = Environment.GetEnvironmentVariable("A365_ENVIRONMENT"); Environment.SetEnvironmentVariable("A365_ENVIRONMENT", $" {expectedEnvironment} "); try @@ -265,14 +265,14 @@ public async Task ResolveBootstrapEnvironmentAsync_WhenUnset_UsesActiveAzureCliC .Returns(new CommandResult { ExitCode = 0, - StandardOutput = "AzureUSGovernment\n", + StandardOutput = "AzureCloud\n", StandardError = string.Empty }); var result = await SetupHelpers.ResolveBootstrapEnvironmentAsync( _mockExecutor, NullLogger.Instance, CancellationToken.None); - result.Should().Be("AzureUSGovernment", + result.Should().Be("AzureCloud", because: "config-free bootstrap must target the active Azure CLI cloud before resolving the client application"); } finally @@ -281,6 +281,35 @@ public async Task ResolveBootstrapEnvironmentAsync_WhenUnset_UsesActiveAzureCliC } } + [Fact] + public async Task ResolveBootstrapEnvironmentAsync_WhenAzureCliUsesUsGovernment_RequiresExplicitEnvironment() + { + var originalEnvironment = Environment.GetEnvironmentVariable("A365_ENVIRONMENT"); + Environment.SetEnvironmentVariable("A365_ENVIRONMENT", null); + try + { + _mockExecutor.ExecuteAsync( + "az", "cloud show --query name -o tsv", + Arg.Any(), true, true, Arg.Any()) + .Returns(new CommandResult + { + ExitCode = 0, + StandardOutput = "AzureUSGovernment\n", + StandardError = string.Empty + }); + + var act = () => SetupHelpers.ResolveBootstrapEnvironmentAsync( + _mockExecutor, NullLogger.Instance, CancellationToken.None); + + await act.Should().ThrowAsync( + because: "AzureUSGovernment cannot identify whether the tenant is GCC Moderate, GCC High, or DoD"); + } + finally + { + Environment.SetEnvironmentVariable("A365_ENVIRONMENT", originalEnvironment); + } + } + // ── ResolveBootstrapClientAppIdAsync ────────────────────────────────────── [Fact] 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 fe42ea5e..b4711797 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 @@ -229,6 +229,26 @@ public void BuildCombinedConsentUrl_AlwaysIncludesAllThreeFixedResources() url.Should().Contain(Uri.EscapeDataString($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}")); } + [Fact] + public void BuildCombinedConsentUrl_WithGccObservabilityResource_UsesGccAudience() + { + var url = SetupHelpers.BuildCombinedConsentUrl( + TenantId, + BlueprintClientId, + Array.Empty(), + Array.Empty(), + observabilityResourceAppId: ConfigConstants.GccObservabilityApiAppId); + + url.Should().Contain( + Uri.EscapeDataString( + $"api://{ConfigConstants.GccObservabilityApiAppId}/{ConfigConstants.ObservabilityApiOtelWriteScope}"), + because: "GCC admin consent must grant the OtelWrite scope on the GCC Observability resource"); + url.Should().NotContain( + Uri.EscapeDataString( + $"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiOtelWriteScope}"), + because: "a GCC consent URL must not request the commercial Observability audience"); + } + [Fact] public void BuildCombinedConsentUrl_ScopesJoinedWithEncodedSpaceNotAmpersand() { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersDisplaySetupSummaryTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersDisplaySetupSummaryTests.cs index 4760b3bc..86755e8e 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersDisplaySetupSummaryTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersDisplaySetupSummaryTests.cs @@ -273,6 +273,33 @@ public void DisplaySetupSummary_NonDwAdminConsentPending_NoConsentUrl_FallsBackT because: "when no consent URL is available the non-DW summary must fall back to the LogNonDwAdminConsentInstructions portal walkthrough so the user still has a recovery path"); } + [Fact] + public void DisplaySetupSummary_NonDwGccAdminConsentPending_UsesGccObservabilityResource() + { + var logger = new CapturingLogger(); + var results = new SetupResults + { + IsNonDwBlueprintFlow = true, + BlueprintCreated = true, + BlueprintId = BlueprintId, + AgentIdentityCreated = true, + AgentIdentityId = AgentSpId, + TenantId = TenantId, + EffectiveAuthMode = Cli.Models.AuthMode.Obo, + TenantWideConsentOutcome = Cli.Models.GrantOutcome.Failed, + BatchPermissionsPhase1Completed = true, + BatchPermissionsPhase2Completed = true, + ObservabilityResourceAppId = ConfigConstants.GccObservabilityApiAppId, + }; + + SetupHelpers.DisplaySetupSummary(results, logger); + + logger.AllOutput.Should().Contain(ConfigConstants.GccObservabilityApiAppId, + because: "manual GCC recovery instructions must target the GCC Observability service"); + logger.AllOutput.Should().NotContain(ConfigConstants.ObservabilityApiAppId, + because: "manual GCC recovery instructions must not target the commercial Observability service"); + } + /// /// B2 regression — non-admin AID developer running `setup all` as OBO must see the consent URL /// surfaced as an action item. Pre-refactor, the orchestrator wrote a misleading diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/BlueprintLookupServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/BlueprintLookupServiceTests.cs index 84ccb243..bbfc32de 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/BlueprintLookupServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/BlueprintLookupServiceTests.cs @@ -92,11 +92,13 @@ public async Task GetApplicationByDisplayNameAsync_WhenBlueprintExists_ReturnsFo }}"; var jsonDoc = JsonDocument.Parse(jsonResponse); - _graphApiService.GraphGetAsync( + _graphApiService.GraphGetWithResponseAsync( TestTenantId, Arg.Is(s => s.Contains("/beta/applications?$filter=")), + false, + Arg.Is?>(scopes => scopes != null && scopes.Contains("Application.Read.All")), Arg.Any()) - .Returns(jsonDoc); + .Returns(new GraphApiService.GraphResponse { IsSuccess = true, StatusCode = 200, Json = jsonDoc }); // Act var result = await _service.GetApplicationByDisplayNameAsync(TestTenantId, TestDisplayName); @@ -118,11 +120,13 @@ public async Task GetApplicationByDisplayNameAsync_WhenNoBlueprintsFound_Returns var jsonResponse = @"{""value"": []}"; var jsonDoc = JsonDocument.Parse(jsonResponse); - _graphApiService.GraphGetAsync( + _graphApiService.GraphGetWithResponseAsync( TestTenantId, Arg.Is(s => s.Contains("/beta/applications?$filter=")), + false, + Arg.Any?>(), Arg.Any()) - .Returns(jsonDoc); + .Returns(new GraphApiService.GraphResponse { IsSuccess = true, StatusCode = 200, Json = jsonDoc }); // Act var result = await _service.GetApplicationByDisplayNameAsync(TestTenantId, TestDisplayName); @@ -142,22 +146,24 @@ public async Task GetApplicationByDisplayNameAsync_EscapesSingleQuotes() var jsonResponse = @"{""value"": []}"; var jsonDoc = JsonDocument.Parse(jsonResponse); - _graphApiService.GraphGetAsync( + _graphApiService.GraphGetWithResponseAsync( TestTenantId, Arg.Is(s => s.Contains("Test%27%27Blueprint%27%27Name")), // URL encoded double single quotes - Arg.Any(), - null) - .Returns(jsonDoc); + false, + Arg.Any?>(), + Arg.Any()) + .Returns(new GraphApiService.GraphResponse { IsSuccess = true, StatusCode = 200, Json = jsonDoc }); // Act await _service.GetApplicationByDisplayNameAsync(TestTenantId, displayNameWithQuotes); // Assert - await _graphApiService.Received(1).GraphGetAsync( + await _graphApiService.Received(1).GraphGetWithResponseAsync( TestTenantId, Arg.Is(s => s.Contains("Test%27%27Blueprint%27%27Name")), - Arg.Any(), - null); + false, + Arg.Any?>(), + Arg.Any()); } [Fact] @@ -235,7 +241,7 @@ public async Task GetApplicationByObjectIdAsync_OnException_ReturnsNotFoundWithE } [Fact] - public async Task GetApplicationByDisplayNameAsync_WhenMultipleBlueprintsFound_ReturnsFirst() + public async Task GetApplicationByDisplayNameAsync_WhenMultipleBlueprintsFoundWithoutPreferredId_ReturnsInconclusiveError() { // Arrange - Simulate multiple results (shouldn't happen with proper naming, but test resilience) var objectId1 = "44444444-4444-4444-4444-444444444444"; @@ -256,19 +262,107 @@ public async Task GetApplicationByDisplayNameAsync_WhenMultipleBlueprintsFound_R }}"; var jsonDoc = JsonDocument.Parse(jsonResponse); - _graphApiService.GraphGetAsync( + _graphApiService.GraphGetWithResponseAsync( TestTenantId, Arg.Is(s => s.Contains("/beta/applications?$filter=")), + false, + Arg.Any?>(), Arg.Any()) - .Returns(jsonDoc); + .Returns(new GraphApiService.GraphResponse { IsSuccess = true, StatusCode = 200, Json = jsonDoc }); // Act var result = await _service.GetApplicationByDisplayNameAsync(TestTenantId, TestDisplayName); // Assert - result.Should().NotBeNull(); - result.Found.Should().BeTrue(); - result.ObjectId.Should().Be(objectId1); // Should return the first match + result.Found.Should().BeFalse(); + result.ErrorMessage.Should().Contain("Multiple blueprints", + because: "setup must not select an arbitrary application when display names are ambiguous"); + } + + [Fact] + public async Task GetApplicationByDisplayNameAsync_WhenMultipleBlueprintsFound_PrefersCachedObjectId() + { + // Arrange + var objectId1 = "44444444-4444-4444-4444-444444444444"; + var objectId2 = "55555555-5555-5555-5555-555555555555"; + var jsonDoc = JsonDocument.Parse($$""" + { + "value": [ + { "id": "{{objectId1}}", "appId": "{{TestAppId}}", "displayName": "{{TestDisplayName}}" }, + { "id": "{{objectId2}}", "appId": "66666666-6666-6666-6666-666666666666", "displayName": "{{TestDisplayName}}" } + ] + } + """); + + _graphApiService.GraphGetWithResponseAsync( + TestTenantId, + Arg.Any(), + false, + Arg.Any?>(), + Arg.Any()) + .Returns(new GraphApiService.GraphResponse { IsSuccess = true, StatusCode = 200, Json = jsonDoc }); + + // Act + var result = await _service.GetApplicationByDisplayNameAsync( + TestTenantId, + TestDisplayName, + preferredObjectId: objectId2); + + // Assert + result.ObjectId.Should().Be(objectId2, + because: "a cached blueprint object ID must win when duplicate display names exist"); + } + + [Fact] + public async Task GetApplicationByDisplayNameAsync_WhenGraphRequestFails_ReturnsInconclusiveError() + { + // Arrange + _graphApiService.GraphGetWithResponseAsync( + TestTenantId, + Arg.Any(), + false, + Arg.Any?>(), + Arg.Any()) + .Returns(new GraphApiService.GraphResponse + { + IsSuccess = false, + StatusCode = 403, + ReasonPhrase = "Forbidden", + Body = """{"error":{"code":"Authorization_RequestDenied"}}""" + }); + + // Act + var result = await _service.GetApplicationByDisplayNameAsync(TestTenantId, TestDisplayName); + + // Assert + result.Found.Should().BeFalse(); + result.ErrorMessage.Should().Contain("HTTP 403 Forbidden", + because: "authorization failures must remain distinguishable from a successful empty lookup"); + } + + [Fact] + public async Task GetApplicationByDisplayNameAsync_WhenCanceled_PropagatesCancellation() + { + // Arrange + using var cts = new CancellationTokenSource(); + cts.Cancel(); + _graphApiService.GraphGetWithResponseAsync( + TestTenantId, + Arg.Any(), + false, + Arg.Any?>(), + cts.Token) + .Returns>(_ => throw new OperationCanceledException(cts.Token)); + + // Act + var act = () => _service.GetApplicationByDisplayNameAsync( + TestTenantId, + TestDisplayName, + cancellationToken: cts.Token); + + // Assert + await act.Should().ThrowAsync( + because: "Ctrl+C must remain cancellation rather than being reported as a permission failure"); } [Fact] @@ -290,11 +384,13 @@ public async Task GetApplicationByDisplayNameAsync_WhenDisplayNameMismatch_Retur var jsonResponse = @"{""value"": []}"; // No blueprints match the new displayName var jsonDoc = JsonDocument.Parse(jsonResponse); - _graphApiService.GraphGetAsync( + _graphApiService.GraphGetWithResponseAsync( TestTenantId, Arg.Is(s => s.Contains("/beta/applications?$filter=") && s.Contains("NewAgent")), + false, + Arg.Any?>(), Arg.Any()) - .Returns(jsonDoc); + .Returns(new GraphApiService.GraphResponse { IsSuccess = true, StatusCode = 200, Json = jsonDoc }); // Act var result = await _service.GetApplicationByDisplayNameAsync(TestTenantId, newDisplayName); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs index 02dcb202..ef82bd99 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs @@ -30,6 +30,25 @@ public GraphApiServiceTests() _mockTokenProvider = Substitute.For(); } + [Fact] + public async Task ClearTokenCacheAsync_ClearsProviderAndAuthenticationCaches() + { + // Arrange + var authService = FakeAuth(); + var service = new GraphApiService( + _mockLogger, + _mockExecutor, + authService, + tokenProvider: _mockTokenProvider); + + // Act + await service.ClearTokenCacheAsync(); + + // Assert + _mockTokenProvider.Received(1).ClearTokenCache(); + await authService.Received(1).ClearTokenCacheAsync(); + } + [Fact] public async Task GetClientAppAccessTokenAsync_PassesExplicitClientIdAndScopesToTokenProvider() diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/LogRedactionServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/LogRedactionServiceTests.cs index b784346d..78a76a99 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/LogRedactionServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/LogRedactionServiceTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Services; using Xunit; @@ -91,6 +92,21 @@ public void Redact_Guid_IsReplacedWithAlias() because: "exactly one GUID was present and it must be counted to keep the redaction summary trustworthy"); } + [Theory] + [InlineData(ConfigConstants.ObservabilityApiAppId)] + [InlineData(ConfigConstants.GccObservabilityApiAppId)] + [InlineData(ConfigConstants.GccHighObservabilityApiAppId)] + [InlineData(ConfigConstants.DodObservabilityApiAppId)] + public void Redact_ObservabilityResourceAppId_IsPreserved(string appId) + { + var result = _sut.Redact($"[INF] Observability resource: {appId}", Source); + + result.RedactedContent.Should().Contain(appId, + because: "public cloud-specific Observability app IDs must remain visible for support diagnostics"); + result.IdsRedacted.Should().Be(0, + because: "well-known first-party resource IDs do not identify a tenant or user"); + } + [Fact] public void Redact_SameGuidAppearsMultipleTimes_SameAliasUsed() { From a18beb7de6fcdd07789bab98f3c916f966e9b740 Mon Sep 17 00:00:00 2001 From: Rick Brighenti <202984599+rbrighenti@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:29:40 +0000 Subject: [PATCH 09/12] Fix GCC validation diagnostics and registration failure status Use service-principal IDs for direct grant queries and distinguish inherited consent from empty or unreadable results. Handle invalid endpoint and manifest values safely, and fail registration-only commands when registration fails. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 + .../Commands/PublishCommand.cs | 13 +- .../Commands/QueryEntraCommand.cs | 96 ++++-- .../NonDwBlueprintSetupOrchestrator.cs | 13 +- .../Services/A365CreateInstanceRunner.cs | 21 +- ...wBlueprintSetupOrchestratorExecuteTests.cs | 54 ++- .../Commands/PublishCommandTests.cs | 128 +++++++ ...yEntraCommandInstanceScopesHandlerTests.cs | 314 ++++++++++++++++++ .../SetupHelpersDisplaySetupSummaryTests.cs | 67 ++++ .../Services/A365CreateInstanceRunnerTests.cs | 47 +++ 10 files changed, 718 insertions(+), 39 deletions(-) create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/QueryEntraCommandInstanceScopesHandlerTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 42691937..3c441e7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,7 +70,11 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g - Repeated `setup blueprint --agent-name` runs now reuse the stored valid client secret instead of creating duplicate credentials. - `a365 query-entra blueprint-scopes` and `inheritance` now report permission-grant read failures, and `a365 create-instance` now stops safely instead of continuing when existing grants cannot be read. - `setup requirements` now validates and repairs tenant-owned fallback CLI apps with the administrator bootstrap identity, preventing false "app not found" failures when the first-party CLI app is unavailable. +- `a365 create-instance` now reports invalid custom Graph or authority endpoints as configuration errors instead of aborting with an unhandled exception (#478). - Cloud-specific Graph, authority, and Agent 365 Tools endpoint overrides now apply consistently across setup, consent, authentication, query, and create-instance flows for sovereign and custom clouds. (#478) +- `a365 query-entra instance-scopes` now reports consent status correctly and fails visibly when permission grants cannot be read (#478). +- `a365 publish` no longer crashes when `manifest.json` has a non-string `name.short` value (#478). +- `setup all --agent-registration-only` now exits non-zero and reports errors when the requested agent registration step fails, while full setup continues to treat registration as best-effort (#478). - Setup no longer fails to detect the Agent 365 CLI application in tenants where it is not yet provisioned, and reports lookup errors instead of silently switching your configured client app (#489). - The first-party Agent 365 CLI app now uses device code authentication when Windows Account Manager is unavailable, avoiding unsupported browser-response errors in WSL, macOS, and Linux (#489). - `setup all --authmode s2s` no longer prints spurious "Action Required" PowerShell steps when the agent identity already inherits its app roles from the blueprint, and now retries the grant automatically before falling back to manual steps (#460). diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs index 3c230043..4786655d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommand.cs @@ -222,7 +222,7 @@ public static Command CreateCommand( var updatedManifest = await UpdateManifestFileAsync(displayName, blueprintId, manifestPath); var updatedAgenticUserManifest = await UpdateAgenticUserManifestTemplateFileAsync(blueprintId, agenticUserManifestPath); var updatedManifestNode = JsonNode.Parse(updatedManifest); - var shortName = updatedManifestNode?["name"]?["short"]?.GetValue(); + var shortName = GetManifestStringValue(updatedManifestNode?["name"]?["short"]); if (dryRun) { @@ -367,10 +367,7 @@ private static void SetManifestNameDefault( string templateValue, string displayName) { - var currentValue = name[propertyName] is JsonValue valueNode && - valueNode.TryGetValue(out var value) - ? value - : null; + var currentValue = GetManifestStringValue(name[propertyName]); if (string.IsNullOrWhiteSpace(currentValue) || string.Equals(currentValue, templateValue, StringComparison.Ordinal)) @@ -379,6 +376,12 @@ private static void SetManifestNameDefault( } } + private static string? GetManifestStringValue(JsonNode? node) + => node is JsonValue valueNode && + valueNode.TryGetValue(out var value) + ? value + : null; + private static async Task UpdateAgenticUserManifestTemplateFileAsync(string blueprintId, string agenticUserManifestPath) { var contents = await File.ReadAllTextAsync(agenticUserManifestPath); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs index 3b9bf538..6a5b0cb7 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs @@ -437,11 +437,10 @@ private static Command CreateInstanceScopesSubcommand( logger.LogInformation("{IdentityType} ID: {IdentityId}", identityType, agenticAppId); logger.LogInformation(""); - // Query Entra ID for the agent identity and OAuth2 grants + // Query Entra ID for the agent identity and OAuth2 grants. logger.LogInformation("Querying Microsoft Entra ID for agent identity and OAuth2 grants..."); - - // Get the service principal details for this application - var spResult = await executor.ExecuteAsync("az", + + var spResult = await executor.ExecuteAsync("az", $"ad sp list --filter \"appId eq '{agenticAppId}'\" --query \"[].{{objectId:id,appId:appId,displayName:displayName}}\" --output json"); if (!spResult.Success) @@ -452,19 +451,59 @@ private static Command CreateInstanceScopesSubcommand( return; } - using var spDoc = JsonDocument.Parse(spResult.StandardOutput); - - if (spDoc.RootElement.ValueKind != JsonValueKind.Array || spDoc.RootElement.GetArrayLength() == 0) + string? agentServicePrincipalObjectId; + string? displayName = "Unknown"; + string? appId = agenticAppId; + try + { + using var spDoc = JsonDocument.Parse(spResult.StandardOutput); + + if (spDoc.RootElement.ValueKind != JsonValueKind.Array) + { + logger.LogError("Service principal lookup returned malformed JSON. Expected an array response."); + context.ExitCode = 1; + return; + } + + if (spDoc.RootElement.GetArrayLength() == 0) + { + logger.LogWarning("No service principal found for this application. The app may not be installed in this tenant."); + context.ExitCode = 1; + return; + } + + var spElement = spDoc.RootElement[0]; + if (!spElement.TryGetProperty("objectId", out var objectIdElement) || + objectIdElement.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(objectIdElement.GetString())) + { + logger.LogError("Service principal lookup returned malformed data. Expected a non-empty string objectId."); + context.ExitCode = 1; + return; + } + + agentServicePrincipalObjectId = objectIdElement.GetString(); + if (spElement.TryGetProperty("displayName", out var nameElement) && + nameElement.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(nameElement.GetString())) + { + displayName = nameElement.GetString(); + } + + if (spElement.TryGetProperty("appId", out var appIdElement) && + appIdElement.ValueKind == JsonValueKind.String && + !string.IsNullOrWhiteSpace(appIdElement.GetString())) + { + appId = appIdElement.GetString(); + } + } + catch (JsonException ex) { - logger.LogWarning("No service principal found for this application. The app may not be installed in this tenant."); + logger.LogError("Failed to parse service principal lookup response: {Error}", ex.Message); context.ExitCode = 1; return; } - - var spElement = spDoc.RootElement[0]; // Get the first (and only) service principal - var displayName = spElement.TryGetProperty("displayName", out var nameElement) ? nameElement.GetString() : "Unknown"; - var appId = spElement.TryGetProperty("appId", out var appIdElement) ? appIdElement.GetString() : agenticAppId; - + logger.LogInformation("Application: {DisplayName}", displayName); logger.LogInformation("App ID: {AppId}", appId); @@ -479,8 +518,10 @@ private static Command CreateInstanceScopesSubcommand( logger.LogInformation("============================================"); // Use Microsoft Graph API through Azure CLI to get OAuth2 permission grants + // oauth2PermissionGrants.clientId is the caller service principal object ID, not + // the application/client ID printed in the portal. var grantsResult = await executor.ExecuteAsync("az", - $"rest --method GET --url \"{graphApiService.GraphBaseUrl}/v1.0/oauth2PermissionGrants?$filter=clientId eq '{agenticAppId}'\" --output json"); + $"rest --method GET --url \"{graphApiService.GraphBaseUrl}/v1.0/oauth2PermissionGrants?$filter=clientId eq '{agentServicePrincipalObjectId}'\" --output json"); // Distinguish "API call failed" (can't read) from "API succeeded but returned no grants". // Non-admin developers lack DelegatedPermissionGrant.Read.All and always get a failure here — @@ -493,11 +534,17 @@ private static Command CreateInstanceScopesSubcommand( try { using var grantsDoc = JsonDocument.Parse(grantsResult.StandardOutput); - if (grantsDoc.RootElement.TryGetProperty("value", out var valueElement) && - valueElement.ValueKind == JsonValueKind.Array && valueElement.GetArrayLength() > 0) + if (grantsDoc.RootElement.ValueKind != JsonValueKind.Object || + !grantsDoc.RootElement.TryGetProperty("value", out var valueElement) || + valueElement.ValueKind != JsonValueKind.Array) + { + logger.LogWarning("OAuth2 grants response was malformed. Expected a top-level 'value' array."); + grantsReadable = false; + } + else if (valueElement.GetArrayLength() > 0) { hasGrants = true; - + foreach (var grantElement in valueElement.EnumerateArray()) { var scope = grantElement.TryGetProperty("scope", out var scopeElement) ? scopeElement.GetString() : "Unknown"; @@ -552,6 +599,7 @@ private static Command CreateInstanceScopesSubcommand( catch (JsonException ex) { logger.LogWarning("Failed to parse OAuth2 grants response: {Error}", ex.Message); + grantsReadable = false; } } @@ -563,17 +611,17 @@ private static Command CreateInstanceScopesSubcommand( logger.LogInformation(" (Reading grants requires a Global Administrator or Application Administrator account; the other information shown above does not.)"); logger.LogInformation(" To verify consent status, sign in as a tenant administrator and re-run, or inspect the app in the Entra portal:"); logger.LogInformation(" https://portal.azure.com -> Entra ID -> App registrations -> {DisplayName} -> API permissions", displayName); + context.ExitCode = 1; } else { - logger.LogInformation(" No OAuth2 permission grants found"); - logger.LogInformation(" This means admin consent has not been granted for any API permissions"); + logger.LogInformation(" No direct OAuth2 permission grants found on this service principal."); + logger.LogInformation(" This query does not include permissions inherited from the agent identity blueprint."); + logger.LogInformation(" An empty direct-grant list does not mean the agent has no consented permissions."); logger.LogInformation(""); - logger.LogInformation("To grant admin consent:"); - logger.LogInformation(" 1. Visit the Azure portal: https://portal.azure.com"); - logger.LogInformation(" 2. Go to Entra ID > App registrations"); - logger.LogInformation(" 3. Find your application: {DisplayName}", displayName); - logger.LogInformation(" 4. Go to API permissions and click 'Grant admin consent'"); + logger.LogInformation("To inspect blueprint grants and inheritance configuration:"); + logger.LogInformation(" a365 query-entra blueprint-scopes"); + logger.LogInformation(" a365 query-entra inheritance"); } } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs index 6292b115..017cbea7 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs @@ -634,8 +634,17 @@ private static async Task ExecuteAgentIdentityAndRegistrationAsync( else { ctx.Results.AgentRegistrationFailed = true; - ctx.Results.Warnings.Add("Agent registration failed via Graph copilot/agentRegistrations API."); - ctx.Logger.LogWarning("Agent registration failed via Graph copilot/agentRegistrations API."); + const string registrationFailedMessage = "Agent registration failed via Graph copilot/agentRegistrations API."; + if (skipIdentityAndPermissions) + { + ctx.Results.Errors.Add(registrationFailedMessage); + ctx.Logger.LogError(registrationFailedMessage); + } + else + { + ctx.Results.Warnings.Add(registrationFailedMessage); + ctx.Logger.LogWarning(registrationFailedMessage); + } } } // end else (AgenticAppId present) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs index 40b264a1..00001ed3 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs @@ -151,13 +151,22 @@ string GetConfig(string name) => // Wire the sovereign/government cloud endpoints so all Graph calls and client-credential // token acquisition target the correct national cloud (commercial by default). var configuredGraphBaseUrl = GetConfig("graphBaseUrl"); - _graphService.GraphBaseUrl = ConfigConstants.GetGraphBaseUrl( - environment, - string.IsNullOrWhiteSpace(configuredGraphBaseUrl) ? null : configuredGraphBaseUrl); var configuredAuthorityHost = GetConfig("authorityHost"); - _graphService.AuthorityHost = ConfigConstants.GetAuthorityHost( - environment, - string.IsNullOrWhiteSpace(configuredAuthorityHost) ? null : configuredAuthorityHost); + try + { + _graphService.GraphBaseUrl = ConfigConstants.GetGraphBaseUrl( + environment, + string.IsNullOrWhiteSpace(configuredGraphBaseUrl) ? null : configuredGraphBaseUrl); + _graphService.AuthorityHost = ConfigConstants.GetAuthorityHost( + environment, + string.IsNullOrWhiteSpace(configuredAuthorityHost) ? null : configuredAuthorityHost); + } + catch (ArgumentException ex) + { + _logger.LogError(ex, "Invalid cloud endpoint configuration in {Path}: {Message}", configPath, ex.Message); + return false; + } + var configuredClientAppId = GetConfig("clientAppId"); if (!string.IsNullOrWhiteSpace(configuredClientAppId)) _graphService.CustomClientAppId = configuredClientAppId; diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NonDwBlueprintSetupOrchestratorExecuteTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NonDwBlueprintSetupOrchestratorExecuteTests.cs index 7a15f25a..31c5d0ba 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NonDwBlueprintSetupOrchestratorExecuteTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NonDwBlueprintSetupOrchestratorExecuteTests.cs @@ -27,6 +27,20 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; /// public class NonDwBlueprintSetupOrchestratorExecuteTests { + private sealed class CapturingLogger : ILogger + { + private readonly List _messages = []; + + public string AllOutput => string.Join("\n", _messages); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => _messages.Add(formatter(state, exception)); + } + // ------------------------------------------------------------------------- // ExecuteAsync behavioral tests — error paths // ------------------------------------------------------------------------- @@ -247,7 +261,7 @@ public void SetupResults_CanSetAgentInstanceRegisteredAndId() /// configure stub return values. /// private static (SetupContext ctx, GraphApiService graph, AgentBlueprintService blueprintService) - BuildIdempotencyTestContext(Agent365Config? config = null) + BuildIdempotencyTestContext(Agent365Config? config = null, ILogger? logger = null) { var graph = Substitute.ForPartsOf(); @@ -277,7 +291,7 @@ private static (SetupContext ctx, GraphApiService graph, AgentBlueprintService b var ctx = new SetupContext( config: cfg, results: new SetupResults(), - logger: Substitute.For(), + logger: logger ?? Substitute.For(), configFile: new FileInfo("a365.config.json"), generatedConfigPath: "a365.generated.config.json", correlationId: "test-correlation-id", @@ -370,6 +384,42 @@ await graph.DidNotReceive().CreateAgentIdentityDelegatedAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } + /// + /// Step 6 (--agent-registration-only): A registration API failure must be fatal for the focused + /// command, returning exit code 1 and emitting an error summary instead of a success-with-warnings banner. + /// + [Fact] + public async Task Step6_RegistrationOnly_ReturnsExitCode1AndAvoidsSuccessfulSummary_WhenRegistrationFails() + { + var logger = new CapturingLogger(); + var config = new Agent365Config + { + AiTeammate = false, + TenantId = "tenant-id", + AgentBlueprintId = "blueprint-id", + AgentIdentityDisplayName = "sellakapri211 Identity", + ClientAppId = "client-app-id", + AgenticAppId = "agentic-app-id", + }; + var (ctx, graph, _) = BuildIdempotencyTestContext(config, logger); + + graph.RegisterAgentInstanceAsyncV2( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(((string?)null, false)); + + var exitCode = await NonDwBlueprintSetupOrchestrator.ExecuteAsync(ctx); + + exitCode.Should().Be(1, + because: "registration-only mode requested only agent registration, so that failure must be fatal"); + ctx.Results.Errors.Should().ContainSingle(error => error == "Agent registration failed via Graph copilot/agentRegistrations API."); + ctx.Results.Warnings.Should().NotContain("Agent registration failed via Graph copilot/agentRegistrations API."); + logger.AllOutput.Should().Contain("Setup completed with errors", + because: "the summary must not present a registration-only failure as successful"); + logger.AllOutput.Should().NotContain("Setup completed successfully", + because: "registration-only failure must not emit either success status line"); + } + /// /// Step 6: When AgentRegistrationId is not in config, RegisterAgentInstanceAsyncV2 must be called. /// diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandTests.cs index 22da5d7c..41ed87b9 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandTests.cs @@ -251,6 +251,134 @@ await File.WriteAllTextAsync( } } + [Theory] + [InlineData("""123""", """123""")] + [InlineData("""{"number": 1}""", """{"number":1}""")] + [InlineData("""["short"]""", """["short"]""")] + [InlineData("""true""", """true""")] + [InlineData("""null""", """null""")] + public async Task PublishCommand_WithNonStringShortName_DoesNotThrowAndTreatsItAsUnset( + string invalidShortNameJson, + string expectedSerializedShortName) + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var manifestDir = Path.Combine(tempDir, "manifest"); + Directory.CreateDirectory(manifestDir); + + try + { + await File.WriteAllTextAsync( + Path.Combine(manifestDir, "manifest.json"), + $$""" + { + "id": "old-id", + "name": { + "short": {{invalidShortNameJson}}, + "full": "Custom Full Name" + } + } + """); + await File.WriteAllTextAsync( + Path.Combine(manifestDir, "agenticUserTemplateManifest.json"), + "{\"agentIdentityBlueprintId\":\"old-id\"}"); + + _configService.LoadAsync(Arg.Any(), Arg.Any()).Returns(new Agent365Config + { + AgentBlueprintId = "test-blueprint-id", + AgentBlueprintDisplayName = null, + TenantId = "test-tenant", + DeploymentProjectPath = tempDir + }); + + var root = new RootCommand(); + root.AddCommand(PublishCommand.CreateCommand(_logger, _configService, _manifestTemplateService)); + + var exitCode = await root.InvokeAsync("publish"); + + exitCode.Should().Be(0, + because: "manifest validation should treat non-string name.short values as unset metadata, not crash packaging"); + var savedManifest = JsonNode.Parse( + await File.ReadAllTextAsync(Path.Combine(manifestDir, "manifest.json")))!; + var nameObject = savedManifest["name"]!.AsObject(); + nameObject.TryGetPropertyValue("short", out var savedShortNameNode).Should().BeTrue( + because: "publish should not drop name.short even when its pre-existing value is invalid metadata"); + (savedShortNameNode is null ? "null" : savedShortNameNode.ToJsonString()).Should().Be( + expectedSerializedShortName, + because: "publish preserves pre-existing customized manifest content unless it has a string default/template value to replace"); + savedManifest["name"]!["full"]!.GetValue().Should().Be( + "Custom Full Name", + because: "an invalid short-name node must not overwrite the user's valid full-name customization"); + + _logger.Received().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("name.short - not set", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any>()); + _logger.DidNotReceive().Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("Publish command failed", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any>()); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task PublishCommand_WithMissingShortName_DoesNotThrowAndWarnsItIsUnset() + { + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var manifestDir = Path.Combine(tempDir, "manifest"); + Directory.CreateDirectory(manifestDir); + + try + { + await File.WriteAllTextAsync( + Path.Combine(manifestDir, "manifest.json"), + """ + { + "id": "old-id", + "name": { + "full": "Custom Full Name" + } + } + """); + await File.WriteAllTextAsync( + Path.Combine(manifestDir, "agenticUserTemplateManifest.json"), + "{\"agentIdentityBlueprintId\":\"old-id\"}"); + + _configService.LoadAsync(Arg.Any(), Arg.Any()).Returns(new Agent365Config + { + AgentBlueprintId = "test-blueprint-id", + AgentBlueprintDisplayName = null, + TenantId = "test-tenant", + DeploymentProjectPath = tempDir + }); + + var root = new RootCommand(); + root.AddCommand(PublishCommand.CreateCommand(_logger, _configService, _manifestTemplateService)); + + var exitCode = await root.InvokeAsync("publish"); + + exitCode.Should().Be(0, + because: "missing optional name.short metadata should be surfaced as a packaging warning, not a fatal exception"); + _logger.Received().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("name.short - not set", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any>()); + } + finally + { + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + } + } + [Fact] public async Task PublishCommand_WithException_ShouldReturnExitCode1() { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/QueryEntraCommandInstanceScopesHandlerTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/QueryEntraCommandInstanceScopesHandlerTests.cs new file mode 100644 index 00000000..3a9572de --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/QueryEntraCommandInstanceScopesHandlerTests.cs @@ -0,0 +1,314 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.CommandLine.Builder; +using System.CommandLine.IO; +using System.CommandLine.Parsing; +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Commands; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; +using CommandExecutionResult = Microsoft.Agents.A365.DevTools.Cli.Services.CommandResult; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +/// +/// Handler-level tests for a365 query-entra instance-scopes. +/// They pin the Azure CLI service-principal lookup and unreadable-grants failure contract. +/// +public class QueryEntraCommandInstanceScopesHandlerTests +{ + private const string TenantId = "11111111-1111-1111-1111-111111111111"; + private const string AgentIdentityAppId = "22222222-2222-2222-2222-222222222222"; + private const string AgentIdentityServicePrincipalObjectId = "33333333-3333-3333-3333-333333333333"; + + private readonly ILogger _mockLogger; + private readonly IConfigService _mockConfigService; + private readonly CommandExecutor _mockExecutor; + private readonly GraphApiService _mockGraphApiService; + private readonly AgentBlueprintService _mockBlueprintService; + private readonly IBootstrapConfigResolver _mockResolver; + + public QueryEntraCommandInstanceScopesHandlerTests() + { + _mockLogger = Substitute.For>(); + _mockConfigService = Substitute.For(); + _mockExecutor = Substitute.For(Substitute.For>()); + _mockGraphApiService = Substitute.For( + Substitute.For>(), + _mockExecutor); + _mockBlueprintService = Substitute.ForPartsOf( + Substitute.For>(), + _mockGraphApiService); + _mockResolver = Substitute.For(); + + _mockResolver.ResolveAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(new Agent365Config + { + TenantId = TenantId, + AgenticAppId = AgentIdentityAppId, + AgentUserPrincipalName = "agent@example.com" + }); + } + + private Command BuildRootCommand() => + QueryEntraCommand.CreateCommand( + _mockLogger, + _mockConfigService, + _mockExecutor, + _mockGraphApiService, + _mockBlueprintService, + _mockResolver); + + private bool LoggerReceivedContaining(LogLevel level, string fragment) + { + var calls = _mockLogger.ReceivedCalls() + .Where(c => c.GetMethodInfo().Name == nameof(ILogger.Log)) + .Select(c => c.GetArguments()) + .Where(args => args.Length >= 3 && args[0] is LogLevel lvl && lvl == level) + .Select(args => args[2]?.ToString() ?? string.Empty); + return calls.Any(s => s.Contains(fragment, StringComparison.Ordinal)); + } + + private static CommandExecutionResult SuccessfulCommand(string standardOutput) => + new() + { + ExitCode = 0, + StandardOutput = standardOutput + }; + + private static CommandExecutionResult FailedCommand(string standardError) => + new() + { + ExitCode = 1, + StandardError = standardError + }; + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task InstanceScopesSubcommand_UsesAzureCliLookup_AndServicePrincipalObjectIdForOauth2GrantFilter(bool hasGrants) + { + _mockExecutor.ExecuteAsync( + "az", + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(ci => + { + var args = ci.ArgAt(1); + if (args.StartsWith("ad sp list", StringComparison.Ordinal)) + return Task.FromResult(SuccessfulCommand($$"""[{"objectId":"{{AgentIdentityServicePrincipalObjectId}}","appId":"{{AgentIdentityAppId}}","displayName":"Agent Identity"}]""")); + if (args.Contains("/oauth2PermissionGrants?", StringComparison.Ordinal)) + return Task.FromResult(SuccessfulCommand(hasGrants + ? """{"value":[{"scope":"User.Read","resourceId":"44444444-4444-4444-4444-444444444444"}]}""" + : """{"value": []}""")); + return Task.FromResult(SuccessfulCommand( + """{"displayName":"Microsoft Graph","appId":"00000003-0000-0000-c000-000000000000"}""")); + }); + + var parser = new CommandLineBuilder(BuildRootCommand()).Build(); + var exitCode = await parser.InvokeAsync("instance-scopes --agent-name test-agent", new TestConsole()); + + exitCode.Should().Be(0, + because: "both populated and empty grants lists are successful diagnostic reads"); + LoggerReceivedContaining(LogLevel.Information, "No direct OAuth2 permission grants found").Should().Be(!hasGrants, + because: "the query reports only direct grants, not permissions inherited from the blueprint"); + LoggerReceivedContaining(LogLevel.Information, "admin consent has not been granted").Should().BeFalse( + because: "an empty direct-grant response cannot establish that inherited consent is absent"); + if (!hasGrants) + { + LoggerReceivedContaining(LogLevel.Information, "a365 query-entra inheritance").Should().BeTrue( + because: "users must inspect inheritance before attempting unnecessary direct consent"); + } + if (hasGrants) + { + LoggerReceivedContaining(LogLevel.Information, "User.Read").Should().BeTrue( + because: "the diagnostic must display the scopes returned for the service principal"); + } + await _mockExecutor.Received().ExecuteAsync( + "az", + Arg.Is(s => + s.Contains("ad sp list", StringComparison.Ordinal) && + s.Contains("""[].{objectId:id,appId:appId,displayName:displayName}""", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + await _mockExecutor.Received().ExecuteAsync( + "az", + Arg.Is(s => + s.Contains($"oauth2PermissionGrants?$filter=clientId eq '{AgentIdentityServicePrincipalObjectId}'", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); + await _mockGraphApiService.DidNotReceiveWithAnyArgs().LookupServicePrincipalByAppIdWithResponseAsync( + default!, + default!, + default, + default); + await _mockGraphApiService.DidNotReceiveWithAnyArgs().GetServicePrincipalDisplayNameByAppIdAsync( + default!, + default!, + default, + default); + } + + [Fact] + public async Task InstanceScopesSubcommand_WhenServicePrincipalLookupReturnsEmptyArray_WarnsAndExitsOne() + { + _mockExecutor.ExecuteAsync( + "az", + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(SuccessfulCommand("[]"))); + + var parser = new CommandLineBuilder(BuildRootCommand()).Build(); + var exitCode = await parser.InvokeAsync("instance-scopes --agent-name test-agent", new TestConsole()); + + exitCode.Should().Be(1, + because: "without a tenant service principal the command cannot inspect tenant-wide delegated grants for the instance"); + LoggerReceivedContaining(LogLevel.Warning, "No service principal found for this application").Should().BeTrue(); + await AssertNoGrantQueryAsync(); + } + + [Theory] + [InlineData("""[{"appId":"22222222-2222-2222-2222-222222222222","displayName":"Agent Identity"}]""")] + [InlineData("""[{"objectId":null,"appId":"22222222-2222-2222-2222-222222222222","displayName":"Agent Identity"}]""")] + [InlineData("""[{"objectId":123,"appId":"22222222-2222-2222-2222-222222222222","displayName":"Agent Identity"}]""")] + [InlineData("""[{"objectId":" ","appId":"22222222-2222-2222-2222-222222222222","displayName":"Agent Identity"}]""")] + public async Task InstanceScopesSubcommand_WhenServicePrincipalLookupResponseHasMissingOrInvalidObjectId_LogsErrorAndExitsOne( + string lookupJson) + { + _mockExecutor.ExecuteAsync( + "az", + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(SuccessfulCommand(lookupJson))); + + var parser = new CommandLineBuilder(BuildRootCommand()).Build(); + var exitCode = await parser.InvokeAsync("instance-scopes --agent-name test-agent", new TestConsole()); + + exitCode.Should().Be(1, + because: "the oauth2PermissionGrants clientId filter requires a non-empty service-principal object ID"); + LoggerReceivedContaining(LogLevel.Error, "Expected a non-empty string objectId").Should().BeTrue( + because: "malformed lookup data must stay visible instead of being treated as missing consent"); + await AssertNoGrantQueryAsync(); + } + + [Fact] + public async Task InstanceScopesSubcommand_WhenServicePrincipalLookupFails_LogsErrorAndExitsOne() + { + _mockExecutor.ExecuteAsync( + "az", + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(FailedCommand("HTTP 403 Forbidden."))); + + var parser = new CommandLineBuilder(BuildRootCommand()).Build(); + var exitCode = await parser.InvokeAsync("instance-scopes --agent-name test-agent", new TestConsole()); + + exitCode.Should().Be(1, + because: "the command cannot continue when the service-principal lookup itself failed"); + LoggerReceivedContaining(LogLevel.Error, "HTTP 403 Forbidden").Should().BeTrue( + because: "lookup failures must be surfaced as permission or transport errors, not misreported as 'no grants found'"); + await AssertNoGrantQueryAsync(); + } + + [Fact] + public async Task InstanceScopesSubcommand_WhenGrantsCallFails_LogsUnreadableGuidanceAndExitsOne() + { + _mockExecutor.ExecuteAsync( + "az", + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(ci => + { + var args = ci.ArgAt(1); + return Task.FromResult( + args.StartsWith("ad sp list", StringComparison.Ordinal) + ? SuccessfulCommand($$"""[{"objectId":"{{AgentIdentityServicePrincipalObjectId}}","appId":"{{AgentIdentityAppId}}","displayName":"Agent Identity"}]""") + : FailedCommand("HTTP 403 Forbidden.")); + }); + + var parser = new CommandLineBuilder(BuildRootCommand()).Build(); + var exitCode = await parser.InvokeAsync("instance-scopes --agent-name test-agent", new TestConsole()); + + exitCode.Should().Be(1, + because: "an unreadable grants table is not evidence that consent is absent"); + LoggerReceivedContaining(LogLevel.Information, "Cannot read tenant-wide OAuth2 permission grants").Should().BeTrue( + because: "the operator needs the administrative-read guidance instead of a false no-consent result"); + LoggerReceivedContaining(LogLevel.Information, "No direct OAuth2 permission grants found").Should().BeFalse( + because: "failed grant enumeration must not be misreported as a successful empty read"); + } + + [Theory] + [InlineData("{")] + [InlineData("""{"unexpected":[]}""")] + [InlineData("""{"value":{}}""")] + [InlineData("""{"value":null}""")] + [InlineData("[]")] + [InlineData("null")] + public async Task InstanceScopesSubcommand_WhenGrantsResponseIsMalformed_LogsUnreadableGuidanceAndExitsOne( + string grantsJson) + { + _mockExecutor.ExecuteAsync( + "az", + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(ci => + { + var args = ci.ArgAt(1); + return Task.FromResult( + args.StartsWith("ad sp list", StringComparison.Ordinal) + ? SuccessfulCommand($$"""[{"objectId":"{{AgentIdentityServicePrincipalObjectId}}","appId":"{{AgentIdentityAppId}}","displayName":"Agent Identity"}]""") + : SuccessfulCommand(grantsJson)); + }); + + var parser = new CommandLineBuilder(BuildRootCommand()).Build(); + var exitCode = await parser.InvokeAsync("instance-scopes --agent-name test-agent", new TestConsole()); + + exitCode.Should().Be(1, + because: "a malformed grants payload is unreadable and must fail the diagnostic instead of claiming consent is absent"); + LoggerReceivedContaining(LogLevel.Information, "Cannot read tenant-wide OAuth2 permission grants").Should().BeTrue( + because: "malformed responses still require the unreadable-grants guidance"); + LoggerReceivedContaining(LogLevel.Information, "No direct OAuth2 permission grants found").Should().BeFalse( + because: "a malformed payload is not equivalent to a successful empty array"); + } + + private async Task AssertNoGrantQueryAsync() => + await _mockExecutor.DidNotReceive().ExecuteAsync( + "az", + Arg.Is(s => s.Contains("/oauth2PermissionGrants?", StringComparison.Ordinal)), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()); +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersDisplaySetupSummaryTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersDisplaySetupSummaryTests.cs index 86755e8e..1a0843a6 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersDisplaySetupSummaryTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersDisplaySetupSummaryTests.cs @@ -602,6 +602,36 @@ public void DisplaySetupSummary_BlueprintOnly_EmitsPermissionsNextSteps() because: "the blueprint summary must point to the bot/observability permissions step"); } + [Fact] + public void DisplaySetupSummary_AgentRegistrationOnlyFailure_UsesErrorStatusInsteadOfSuccess() + { + var logger = new CapturingLogger(); + + SetupHelpers.DisplaySetupSummary(BuildAgentRegistrationOnlyFailureResults(), logger); + + logger.AllOutput.Should().Contain("Setup completed with errors", + because: "registration-only mode should treat the requested registration failure as fatal"); + logger.AllOutput.Should().NotContain("Setup completed successfully", + because: "the previous success-with-warnings banner was misleading for this focused failure"); + logger.AllOutput.Should().Contain("failed — see errors", + because: "the registration-only row should direct the operator to the error block"); + } + + [Fact] + public void DisplaySetupSummary_FullSetupRegistrationFailure_RemainsWarningStatus() + { + var logger = new CapturingLogger(); + + SetupHelpers.DisplaySetupSummary(BuildFullSetupRegistrationWarningResults(), logger); + + logger.AllOutput.Should().Contain("Setup completed successfully with warnings", + because: "full setup intentionally keeps agent registration best-effort so other completed work is preserved"); + logger.AllOutput.Should().NotContain("Setup completed with errors", + because: "the compatibility path should remain non-fatal outside registration-only mode"); + logger.AllOutput.Should().Contain("failed — see warnings", + because: "the full setup row should continue to point at the warning block"); + } + private const string BlueprintConsentUrl = "https://login.microsoftonline.com/" + TenantId + "/v2.0/adminconsent?client_id=" + BlueprintId; private static SetupResults BuildBlueprintOnlyResults(bool consentPending) => new() @@ -620,6 +650,43 @@ public void DisplaySetupSummary_BlueprintOnly_EmitsPermissionsNextSteps() AdminConsentUrl = consentPending ? BlueprintConsentUrl : null, }; + private static SetupResults BuildAgentRegistrationOnlyFailureResults() + { + var results = new SetupResults + { + IsNonDwBlueprintFlow = true, + PermissionGrantsSkipped = true, + AgentIdentityCreated = true, + AgentIdentityAlreadyExisted = true, + AgentIdentityId = AgentSpId, + BlueprintId = BlueprintId, + AgentRegistrationFailed = true, + }; + results.Errors.Add("Agent registration failed via Graph copilot/agentRegistrations API."); + return results; + } + + private static SetupResults BuildFullSetupRegistrationWarningResults() + { + var results = new SetupResults + { + IsNonDwBlueprintFlow = true, + BlueprintCreated = true, + BlueprintServicePrincipalCreated = true, + BlueprintId = BlueprintId, + BlueprintDisplayName = "Repro Blueprint", + AgentIdentityCreated = true, + AgentIdentityId = AgentSpId, + AgentIdentityDisplayName = "Repro Agent Identity", + AgentRegistrationFailed = true, + BatchPermissionsPhase1Completed = true, + BatchPermissionsPhase2Completed = true, + TenantWideConsentOutcome = Cli.Models.GrantOutcome.Granted, + }; + results.Warnings.Add("Agent registration failed via Graph copilot/agentRegistrations API."); + return results; + } + // ── helpers ─────────────────────────────────────────────────────────────── private static SetupResults BuildDelegatedPendingResults() => new() diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/A365CreateInstanceRunnerTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/A365CreateInstanceRunnerTests.cs index 78ff814c..651d03aa 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/A365CreateInstanceRunnerTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/A365CreateInstanceRunnerTests.cs @@ -92,6 +92,53 @@ await graph.DidNotReceive().CreateOrUpdateOauth2PermissionGrantAsync( Arg.Any?>()); } + [Theory] + [InlineData("\"graphBaseUrl\": \"https://graph.example/us\"", "Graph base URL")] + [InlineData("\"authorityHost\": \"http://login.example.com\"", "Authority host")] + public async Task RunAsync_WhenConfiguredCloudEndpointIsInvalid_LogsErrorAndReturnsFalse( + string invalidEndpointProperty, + string expectedSettingName) + { + var configPath = Path.Combine(_testDirectory, "a365.config.json"); + var generatedConfigPath = Path.Combine(_testDirectory, "a365.generated.config.json"); + await File.WriteAllTextAsync( + configPath, + $$""" + { + "tenantId": "11111111-1111-1111-1111-111111111111", + "environment": "prod", + {{invalidEndpointProperty}} + } + """); + await File.WriteAllTextAsync( + generatedConfigPath, + """ + { + "agentBlueprintId": "22222222-2222-2222-2222-222222222222", + "agentBlueprintClientSecret": "test-secret" + } + """); + + var logger = Substitute.For>(); + var executor = Substitute.For(NullLogger.Instance); + var graph = Substitute.For(NullLogger.Instance, executor); + var runner = new A365CreateInstanceRunner(logger, executor, graph); + + var succeeded = await runner.RunAsync( + configPath, + generatedConfigPath, + step: "licenses"); + + succeeded.Should().BeFalse( + because: "invalid sovereign-cloud endpoint configuration is a user-fixable input error, not an unhandled exception path"); + logger.Received().Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(state => state.ToString()!.Contains(expectedSettingName, StringComparison.Ordinal)), + Arg.Is(ex => ex.Message.Contains(expectedSettingName, StringComparison.Ordinal)), + Arg.Any>()); + } + public void Dispose() { if (Directory.Exists(_testDirectory)) From aa6494af7789db2088ef7cdc34ebcc1d4c659001 Mon Sep 17 00:00:00 2001 From: Rick Brighenti <202984599+rbrighenti@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:01:57 +0000 Subject: [PATCH 10/12] Validate explicit Agent 365 endpoint overrides Apply the discovery HTTPS URL contract to explicit create/delete overrides before token acquisition, preserving valid custom paths and independent override precedence. Add regression tests for unsafe URL components and normalization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + .../Constants/ConfigConstants.cs | 9 +++- .../Services/Helpers/EndpointHelper.cs | 6 ++- .../design.md | 2 +- .../Services/Helpers/EndpointHelperTests.cs | 45 +++++++++++++++++++ 5 files changed, 58 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9a3c784..98a04148 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g - `a365 develop get-token --device-code` — forces device code auth for Microsoft Graph scopes the Windows WAM broker rejects (e.g. Exchange `MailboxSettings.ReadWrite`, `ExchangeMessageTrace.Read.All`). ### Fixed +- Messaging endpoint create and delete overrides now reject non-HTTPS URLs and URLs containing user information, query strings, or fragments (#478). - Graph authentication now keeps cached tokens separate for each authority host when switching clouds (#478). - Blueprint discovery now tolerates malformed unrelated results when locating a stored blueprint and stops safely when the selected result is invalid (#478). - Setup now requests the required Graph scopes and stops safely when existing blueprint discovery is inconclusive, preventing duplicate blueprints after CLI permission changes. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs index a37895f1..2168ee4c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs @@ -304,11 +304,16 @@ private static Uri ResolveDiscoverEndpointUri(string environment) var candidate = configuredEndpoint is null ? ProductionDiscoverEndpointUrl : configuredEndpoint.Trim(); - var uri = ParseHttpsUri(candidate, "Agent 365 Tools discover endpoint"); + return ParseAgent365ToolsEndpointUri(candidate, "Agent 365 Tools discover endpoint"); + } + + internal static Uri ParseAgent365ToolsEndpointUri(string value, string settingName) + { + var uri = ParseHttpsUri(value.Trim(), settingName); if (!string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment)) { throw new ArgumentException( - "Agent 365 Tools discover endpoint must not contain a query or fragment."); + $"{settingName} must not contain a query or fragment."); } return uri; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/EndpointHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/EndpointHelper.cs index c10ed6e2..8c993e98 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/EndpointHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/EndpointHelper.cs @@ -113,7 +113,8 @@ public static string GetCreateEndpointUrl(string environment) var customEndpoint = Environment.GetEnvironmentVariable( $"A365_CREATE_ENDPOINT_{ConfigConstants.NormalizeEnvironmentKey(environment)}"); if (!string.IsNullOrEmpty(customEndpoint)) - return customEndpoint; + return ConfigConstants.ParseAgent365ToolsEndpointUri( + customEndpoint, "Agent 365 Tools create endpoint").AbsoluteUri; return ConfigConstants.BuildAgent365ToolsEndpointUrl( environment, @@ -129,7 +130,8 @@ public static string GetDeleteEndpointUrl(string environment) var customEndpoint = Environment.GetEnvironmentVariable( $"A365_DELETE_ENDPOINT_{ConfigConstants.NormalizeEnvironmentKey(environment)}"); if (!string.IsNullOrEmpty(customEndpoint)) - return customEndpoint; + return ConfigConstants.ParseAgent365ToolsEndpointUri( + customEndpoint, "Agent 365 Tools delete endpoint").AbsoluteUri; return ConfigConstants.BuildAgent365ToolsEndpointUrl( environment, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/design.md b/src/Microsoft.Agents.A365.DevTools.Cli/design.md index 2f6d1927..c5f46fcf 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/design.md +++ b/src/Microsoft.Agents.A365.DevTools.Cli/design.md @@ -177,7 +177,7 @@ export A365_ENVIRONMENT=gcc export A365_DISCOVER_ENDPOINT_GCC=https://gcc.agent365.svc.cloud.microsoft/agents/v2/discoverMCPServers ``` -The discovery override supplies the full discovery URL and the HTTPS origin used for related Agent 365 routes. Explicit create/delete overrides take precedence. Setting only `A365_ENVIRONMENT=gcc` leaves discovery pointing at the commercial service. +The discovery override supplies the full discovery URL and the HTTPS origin used for related Agent 365 routes. Explicit create/delete overrides take precedence and allow custom paths, but must also be HTTPS URLs without user information, query strings, or fragments. Setting only `A365_ENVIRONMENT=gcc` leaves discovery pointing at the commercial service. For other clouds, configure `graphBaseUrl` and `authorityHost` in `a365.config.json`, or supply `A365_GRAPH_BASE_URL_{ENV}` and `A365_AUTHORITY_HOST_{ENV}`. Environment-scoped variables take precedence over config; otherwise the defaults are `https://graph.microsoft.com` and `https://login.microsoftonline.com`. Unsuffixed variables for these two settings are not read. Environment suffixes are uppercase with non-alphanumeric characters replaced by underscores, so `gcc-high` uses `GCC_HIGH`. diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/EndpointHelperTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/EndpointHelperTests.cs index 21ba82c1..30d1a225 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/EndpointHelperTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/EndpointHelperTests.cs @@ -476,6 +476,51 @@ public void Agent365Endpoints_MalformedDiscoverOverride_FailsVisibly(string disc }); } + [Theory] + [InlineData(" ")] + [InlineData("not-a-url")] + [InlineData("/relative/path")] + [InlineData("http://endpoint.example/custom")] + [InlineData("https://user@endpoint.example/custom")] + [InlineData("https://endpoint.example/custom?version=2")] + [InlineData("https://endpoint.example/custom#section")] + public void Agent365Endpoints_MalformedExplicitOverride_FailsInsteadOfUsingDiscover(string endpoint) + { + WithEnvironmentVariables( + "GCC", + "https://discover.example/agents/v2/discoverMCPServers", + endpoint, + endpoint, + () => + { + FluentActions.Invoking(() => EndpointHelper.GetCreateEndpointUrl("gcc")) + .Should().Throw( + because: "an explicit create override must reject unsafe URL components before a bearer token is sent"); + FluentActions.Invoking(() => EndpointHelper.GetDeleteEndpointUrl("gcc")) + .Should().Throw( + because: "an explicit delete override must reject unsafe URL components rather than silently use discovery"); + }); + } + + [Fact] + public void Agent365Endpoints_ExplicitHttpsOverrides_PreserveCustomPathsAndNormalizeOrigins() + { + WithEnvironmentVariables( + "GCC", + discoverEndpoint: null, + " https://CREATE.EXAMPLE:443/custom/create ", + " https://DELETE.EXAMPLE:443/custom/delete ", + () => + { + EndpointHelper.GetCreateEndpointUrl("gcc").Should().Be( + "https://create.example/custom/create", + because: "valid explicit HTTPS overrides retain custom routes while normalizing the origin"); + EndpointHelper.GetDeleteEndpointUrl("gcc").Should().Be( + "https://delete.example/custom/delete", + because: "delete overrides follow the same URL contract as create overrides"); + }); + } + private static void WithEnvironmentVariables( string environmentKey, string? discoverEndpoint, From 1d6f849cd77f3baa8022c85c27d0c3d70e900366 Mon Sep 17 00:00:00 2001 From: Rick Brighenti <202984599+rbrighenti@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:18:03 +0000 Subject: [PATCH 11/12] Warn when blueprint recovery replaces a stale stored ID Preserve the requested display-name-first single-result recovery behavior. Warn with stored and selected object IDs before setup persists the replacement; keep ambiguous and malformed lookups fail-closed. Document the requirement and cover mismatch, matching/no stored ID, and ambiguous results. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + .../Services/BlueprintLookupService.cs | 8 +++ .../design.md | 2 + .../Services/BlueprintLookupServiceTests.cs | 64 +++++++++++++++++++ 4 files changed, 75 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98a04148..455aaf00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g - `a365 develop get-token --device-code` — forces device code auth for Microsoft Graph scopes the Windows WAM broker rejects (e.g. Exchange `MailboxSettings.ReadWrite`, `ExchangeMessageTrace.Read.All`). ### Fixed +- Setup now warns before replacing a stale stored blueprint ID with the sole application matching the configured display name (#478). - Messaging endpoint create and delete overrides now reject non-HTTPS URLs and URLs containing user information, query strings, or fragments (#478). - Graph authentication now keeps cached tokens separate for each authority host when switching clouds (#478). - Blueprint discovery now tolerates malformed unrelated results when locating a stored blueprint and stops safely when the selected result is invalid (#478). diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/BlueprintLookupService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/BlueprintLookupService.cs index 0c3f575c..3df1f02f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/BlueprintLookupService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/BlueprintLookupService.cs @@ -229,6 +229,14 @@ public async Task GetApplicationByDisplayNameAsync( var appId = appIdElement.GetString(); var foundDisplayName = displayNameElement.GetString(); + if (!string.IsNullOrWhiteSpace(preferredObjectId) && + !string.Equals(objectId, preferredObjectId, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "Stored blueprint object ID {StoredObjectId} did not match the sole application found for display name '{DisplayName}'. Continuing with object ID {SelectedObjectId}; setup will update the stored blueprint identifiers.", + preferredObjectId, foundDisplayName, objectId); + } + _logger.LogDebug("Found blueprint: {DisplayName} (ObjectId: {ObjectId}, AppId: {AppId})", foundDisplayName, objectId, appId); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/design.md b/src/Microsoft.Agents.A365.DevTools.Cli/design.md index c5f46fcf..0011dcce 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/design.md +++ b/src/Microsoft.Agents.A365.DevTools.Cli/design.md @@ -141,6 +141,8 @@ public class Agent365Config - `get; set` properties = Mutable = Dynamic state - `ConfigService` handles merge (load) and split (save) logic +Blueprint setup discovers applications by the configured display name. The stored object ID disambiguates multiple matches; if only one valid application matches but its ID differs, setup warns with both IDs and continues, updating the stored identifiers. Multiple unmatched results or malformed lookup responses remain failures rather than selecting an arbitrary application. + ### Environment Variable Overrides For security and flexibility, the CLI supports environment variable overrides: diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/BlueprintLookupServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/BlueprintLookupServiceTests.cs index 1ad4ae26..6c0778c7 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/BlueprintLookupServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/BlueprintLookupServiceTests.cs @@ -313,6 +313,70 @@ public async Task GetApplicationByDisplayNameAsync_WhenMultipleBlueprintsFound_P because: "a cached blueprint object ID must win when duplicate display names exist"); } + [Theory] + [InlineData(null, false)] + [InlineData(TestObjectId, false)] + [InlineData("44444444-4444-4444-4444-444444444444", true)] + public async Task GetApplicationByDisplayNameAsync_WithSingleMatch_WarnsOnlyWhenStoredIdDiffers( + string? preferredObjectId, bool expectsWarning) + { + using var doc = JsonDocument.Parse($$""" + {"value":[{"id":"{{TestObjectId}}","appId":"{{TestAppId}}","displayName":"{{TestDisplayName}}"}]} + """); + _graphApiService.GraphGetWithResponseAsync( + TestTenantId, Arg.Any(), false, Arg.Any?>(), Arg.Any()) + .Returns(new GraphApiService.GraphResponse { IsSuccess = true, StatusCode = 200, Json = doc }); + + var result = await _service.GetApplicationByDisplayNameAsync( + TestTenantId, TestDisplayName, preferredObjectId: preferredObjectId); + + result.Found.Should().BeTrue( + because: "display-name-first recovery must continue with a sole valid match even when stored state is stale"); + result.ObjectId.Should().Be(TestObjectId, + because: "the sole display-name match remains the selected blueprint"); + result.RequiresPersistence.Should().BeTrue( + because: "setup must persist the selected blueprint identifiers during stale-state recovery"); + result.ErrorMessage.Should().BeNullOrEmpty(); + + var warnings = _logger.ReceivedCalls() + .Where(call => call.GetMethodInfo().Name == nameof(ILogger.Log) && + call.GetArguments()[0] is LogLevel.Warning) + .Select(call => call.GetArguments()[2]?.ToString()) + .ToList(); + warnings.Should().HaveCount(expectsWarning ? 1 : 0, + because: "only a stored-ID mismatch requires a warning before continuing with a valid single result"); + if (expectsWarning) + { + warnings[0].Should().Contain(preferredObjectId!) + .And.Contain(TestObjectId) + .And.Contain("Continuing") + .And.Contain("update the stored blueprint identifiers", + because: "the warning must disclose both identities and the impending state change"); + } + } + + [Fact] + public async Task GetApplicationByDisplayNameAsync_WithMultipleUnmatchedResults_DoesNotRecover() + { + using var doc = JsonDocument.Parse($$""" + {"value":[ + {"id":"{{TestObjectId}}","appId":"{{TestAppId}}","displayName":"{{TestDisplayName}}"}, + {"id":"55555555-5555-5555-5555-555555555555","appId":"{{TestAppId}}","displayName":"{{TestDisplayName}}"}]} + """); + _graphApiService.GraphGetWithResponseAsync( + TestTenantId, Arg.Any(), false, Arg.Any?>(), Arg.Any()) + .Returns(new GraphApiService.GraphResponse { IsSuccess = true, StatusCode = 200, Json = doc }); + + var result = await _service.GetApplicationByDisplayNameAsync( + TestTenantId, TestDisplayName, preferredObjectId: "44444444-4444-4444-4444-444444444444"); + + result.Found.Should().BeFalse( + because: "warning-and-continue recovery applies only to a sole valid result, never ambiguous applications"); + result.ErrorMessage.Should().Contain("none matched", + because: "unmatched ambiguous results must remain an explicit discovery failure"); + result.RequiresPersistence.Should().BeFalse(); + } + [Fact] public async Task GetApplicationByDisplayNameAsync_WhenGraphRequestFails_ReturnsInconclusiveError() { From d87c30842f8f9c98256d250d802a322268015b7c Mon Sep 17 00:00:00 2001 From: Rick Brighenti <202984599+rbrighenti@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:50:30 +0000 Subject: [PATCH 12/12] Handle ambiguous government cloud configuration in instance creation Resolve the Observability resource inside existing cloud validation so AzureUSGovernment returns false with actionable guidance before Graph configuration or state writes. Cover all, identity and licenses runner modes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + .../Services/A365CreateInstanceRunner.cs | 5 ++- .../Services/A365CreateInstanceRunnerTests.cs | 42 +++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 455aaf00..94ccb70e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g - `a365 develop get-token --device-code` — forces device code auth for Microsoft Graph scopes the Windows WAM broker rejects (e.g. Exchange `MailboxSettings.ReadWrite`, `ExchangeMessageTrace.Read.All`). ### Fixed +- `a365 create-instance` now reports an ambiguous government-cloud environment as a configuration error with guidance to select a specific cloud (#478). - Setup now warns before replacing a stale stored blueprint ID with the sole application matching the configured display name (#478). - Messaging endpoint create and delete overrides now reject non-HTTPS URLs and URLs containing user information, query strings, or fragments (#478). - Graph authentication now keeps cached tokens separate for each authority host when switching clouds (#478). diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs index 00001ed3..b775c64e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs @@ -152,8 +152,10 @@ string GetConfig(string name) => // token acquisition target the correct national cloud (commercial by default). var configuredGraphBaseUrl = GetConfig("graphBaseUrl"); var configuredAuthorityHost = GetConfig("authorityHost"); + string observabilityResourceAppId; try { + observabilityResourceAppId = ConfigConstants.GetObservabilityApiAppId(environment); _graphService.GraphBaseUrl = ConfigConstants.GetGraphBaseUrl( environment, string.IsNullOrWhiteSpace(configuredGraphBaseUrl) ? null : configuredGraphBaseUrl); @@ -163,7 +165,7 @@ string GetConfig(string name) => } catch (ArgumentException ex) { - _logger.LogError(ex, "Invalid cloud endpoint configuration in {Path}: {Message}", configPath, ex.Message); + _logger.LogError(ex, "Invalid cloud configuration in {Path}: {Message}", configPath, ex.Message); return false; } @@ -171,7 +173,6 @@ string GetConfig(string name) => if (!string.IsNullOrWhiteSpace(configuredClientAppId)) _graphService.CustomClientAppId = configuredClientAppId; var mcpResourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(environment); - var observabilityResourceAppId = ConfigConstants.GetObservabilityApiAppId(environment); var usageLocation = GetConfig("agentUserUsageLocation"); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/A365CreateInstanceRunnerTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/A365CreateInstanceRunnerTests.cs index 651d03aa..9394a292 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/A365CreateInstanceRunnerTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/A365CreateInstanceRunnerTests.cs @@ -139,6 +139,48 @@ await File.WriteAllTextAsync( Arg.Any>()); } + [Theory] + [InlineData("all")] + [InlineData("identity")] + [InlineData("licenses")] + public async Task RunAsync_WhenGovernmentEnvironmentIsAmbiguous_LogsGuidanceWithoutChangingState(string step) + { + var configPath = Path.Combine(_testDirectory, "a365.config.json"); + var generatedConfigPath = Path.Combine(_testDirectory, "a365.generated.config.json"); + await File.WriteAllTextAsync(configPath, """ + { + "tenantId": "11111111-1111-1111-1111-111111111111", + "environment": "AzureUSGovernment" + } + """); + const string generatedConfig = """ + { + "agentBlueprintId": "22222222-2222-2222-2222-222222222222", + "agentBlueprintClientSecret": "test-secret" + } + """; + await File.WriteAllTextAsync(generatedConfigPath, generatedConfig); + var logger = Substitute.For>(); + var executor = Substitute.For(NullLogger.Instance); + var graph = Substitute.For(NullLogger.Instance, executor); + var runner = new A365CreateInstanceRunner(logger, executor, graph); + + var succeeded = await runner.RunAsync(configPath, generatedConfigPath, step: step); + + succeeded.Should().BeFalse( + because: "an ambiguous government environment must return a configuration failure rather than throw"); + (await File.ReadAllTextAsync(generatedConfigPath)).Should().Be(generatedConfig, + because: "cloud validation must finish before generated state is saved"); + graph.ReceivedCalls().Should().BeEmpty( + because: "an ambiguous cloud must not configure Graph or start resource operations"); + logger.Received().Log( + LogLevel.Error, + Arg.Any(), + Arg.Is(state => state.ToString()!.Contains("Set the environment to gcc, gcc-high, or dod.")), + Arg.Any(), + Arg.Any>()); + } + public void Dispose() { if (Directory.Exists(_testDirectory))