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