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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,10 @@ private static async Task<bool> 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))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -283,7 +283,10 @@ private static async Task<McpServerTokenResult> AcquireTokenAsync(
forceRefresh,
clientAppId,
useInteractiveBrowser: !useDeviceCode,
userId: loginHint);
userId: loginHint,
authorityHost: ConfigConstants.GetAuthorityHost(
ResolveEnvironment(setupConfig),
setupConfig?.AuthorityHost));

if (string.IsNullOrWhiteSpace(token))
{
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -82,6 +82,7 @@ private static Command CreateInheritanceSubcommand(
context.ExitCode = 1;
return;
}
graphApiService.ConfigureCloudEndpoints(setupConfig);

if (string.IsNullOrEmpty(setupConfig.AgentBlueprintId))
{
Expand Down Expand Up @@ -258,6 +259,7 @@ private static Command CreateBlueprintScopesSubcommand(
context.ExitCode = 1;
return;
}
graphApiService.ConfigureCloudEndpoints(setupConfig);

if (string.IsNullOrEmpty(setupConfig.AgentBlueprintId))
{
Expand Down Expand Up @@ -365,6 +367,7 @@ private static Command CreateInstanceScopesSubcommand(
ILogger<QueryEntraCommand> 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");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 —
Expand All @@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -627,23 +621,23 @@ 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)
{
var logFilePath = ConfigService.GetCommandLogPath(CommandNames.Setup);
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)
{
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)
Expand All @@ -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;
}
});
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -65,7 +66,8 @@ internal static partial class AzRestConsentRunner
string blueprintSpObjectId,
IReadOnlyList<ResourcePermissionSpec> specs,
ILogger logger,
CancellationToken ct)
CancellationToken ct,
string graphBaseUrl = GraphApiConstants.BaseUrl)
{
if (!GuidPattern().IsMatch(blueprintSpObjectId))
{
Expand Down Expand Up @@ -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;
Expand All @@ -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)
Expand All @@ -132,13 +138,14 @@ private static async Task<bool> 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);
Expand Down Expand Up @@ -166,7 +173,7 @@ private static async Task<bool> 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);
Expand Down Expand Up @@ -200,7 +207,7 @@ private static async Task<bool> 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);
Expand All @@ -224,7 +231,7 @@ private static async Task<bool> 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -52,7 +53,8 @@ internal static partial class AzRestS2SRunner
string blueprintSpObjectId,
IReadOnlyList<ResourcePermissionSpec> specs,
ILogger logger,
CancellationToken ct)
CancellationToken ct,
string graphBaseUrl = GraphApiConstants.BaseUrl)
{
if (!GuidPattern().IsMatch(blueprintSpObjectId))
{
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -132,12 +138,13 @@ private static async Task<bool> 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);
Expand Down Expand Up @@ -182,7 +189,7 @@ private static async Task<bool> 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);
Expand Down Expand Up @@ -212,12 +219,13 @@ private static async Task<bool> AssignOneAsync(
private static async Task<HashSet<(string, string)>?> 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);
Expand Down
Loading
Loading