From 9830d6b78d061d364a4fc2a06511ba70a189677d Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Thu, 4 Dec 2025 18:31:13 -0800 Subject: [PATCH 1/7] feat: Replace hardcoded Graph CLI app ID with user-configurable custom client app BREAKING CHANGE: Users must create a custom client app registration with 5 required delegated permissions before using a365 CLI. ## What Changed - Added required `clientAppId` field to a365.config.json - Replaced all references to Microsoft Graph Command Line Tools app ID (14d82eec...) with config.ClientAppId - Added ClientAppValidator service to validate app existence, permissions, and admin consent - Configuration wizard now validates client app during `a365 config init` (3 retry attempts) - Setup commands validate prerequisites upfront before execution ## Required Permissions (Delegated, not Application) 1. Application.ReadWrite.All 2. AgentIdentityBlueprint.ReadWrite.All 3. AgentIdentityBlueprint.UpdateAuthProperties.All 4. DelegatedPermissionGrant.ReadWrite.All 5. Directory.Read.All ## Migration for Existing Users 1. Create app registration in Azure Portal 2. Add 5 delegated permissions + grant admin consent 3. Run `a365 config init` to configure clientAppId 4. See: docs/guides/custom-client-app-registration.md ## Bug Fixes - Fixed critical parameter swap bug in BlueprintSubcommand causing "tenant not found" error - Fixed emoji violations in ConfigurationWizardService - Removed misleading authentication log messages ## Testing - 417/423 tests passing (6 skipped) - Added 153 new tests for validation logic - All existing tests updated to use clientAppId --- README.md | 18 + docs/commands/config-init.md | 87 ++- docs/guides/custom-client-app-registration.md | 126 ++++ scripts/cli/install-cli.ps1 | 18 +- src/DEVELOPER.md | 6 + .../Commands/DeployCommand.cs | 8 +- .../Commands/QueryEntraCommand.cs | 2 +- .../SetupSubcommands/AllSubcommand.cs | 78 ++- .../SetupSubcommands/BlueprintSubcommand.cs | 37 +- .../InfrastructureSubcommand.cs | 33 + .../SetupSubcommands/PermissionsSubcommand.cs | 46 ++ .../Commands/SetupSubcommands/SetupHelpers.cs | 7 +- .../Constants/AuthenticationConstants.cs | 30 +- .../Constants/ErrorCodes.cs | 1 + .../ClientAppValidationException.cs | 135 +++++ .../Exceptions/GraphTokenScopeException.cs | 25 +- .../Models/Agent365Config.cs | 37 ++ .../Services/AuthenticationService.cs | 15 +- .../Services/ClientAppValidator.cs | 517 ++++++++++++++++ .../Services/ConfigurationWizardService.cs | 121 +++- .../Services/DelegatedConsentService.cs | 8 +- .../Services/GraphApiService.cs | 3 +- .../Services/ISubCommand.cs | 22 + .../Services/InteractiveGraphAuthService.cs | 66 +- .../Commands/BlueprintSubcommandTests.cs | 42 ++ ...nfigCommandStaticDynamicSeparationTests.cs | 8 +- .../Commands/SubcommandValidationTests.cs | 304 ++++++++++ .../ClientAppValidationExceptionTests.cs | 267 ++++++++ .../Models/Agent365ConfigTests.cs | 154 ++++- .../Services/Agent365ConfigServiceTests.cs | 3 +- .../Services/ClientAppValidatorTests.cs | 570 ++++++++++++++++++ 31 files changed, 2724 insertions(+), 70 deletions(-) create mode 100644 docs/guides/custom-client-app-registration.md create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Services/ISubCommand.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SubcommandValidationTests.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs diff --git a/README.md b/README.md index 37a3e8aa..781c7960 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,24 @@ This project is currently in active development. The CLI is being actively devel ## Installation +### Prerequisites + +Before using the Agent365 CLI, you must create a custom Entra ID app registration with specific Microsoft Graph API permissions: + +1. **Custom Client App Registration**: Create an app in your Entra ID tenant +2. **Required Permissions**: Configure **delegated** permissions (NOT Application): + - `Application.ReadWrite.All` + - `AgentIdentityBlueprint.ReadWrite.All` + - `DelegatedPermissionGrant.ReadWrite.All` + - `Directory.Read.All` +3. **Admin Consent**: Grant admin consent for all permissions + +⚠️ **Important**: Use **Delegated** permissions (you sign in, CLI acts on your behalf), NOT Application permissions (for background services). + +📖 **Detailed Setup Guide**: [docs/guides/custom-client-app-registration.md](docs/guides/custom-client-app-registration.md) + +> **Why is this required?** The CLI needs elevated permissions to create and manage Agent Identity Blueprints in your tenant. You maintain control over which permissions are granted, and the app stays within your tenant's security boundaries. + ### Install the CLI From NuGet (Production): diff --git a/docs/commands/config-init.md b/docs/commands/config-init.md index 4f41e851..da8cfd02 100644 --- a/docs/commands/config-init.md +++ b/docs/commands/config-init.md @@ -61,7 +61,68 @@ Agent name [agent1114]: myagent **Smart Defaults**: If no existing config, defaults to `agent` + current date (e.g., `agent1114`) -### Step 3: Deployment Project Path +### Step 3: Client App ID + +Provide the Application (client) ID of your custom Entra ID app registration: + +``` +================================================================= +IMPORTANT: Custom Client App Required +================================================================= +The Agent365 CLI requires a custom client app registration in your +Entra ID tenant with specific Microsoft Graph API permissions. + +Required Delegated Permissions: + • Application.ReadWrite.All + • AgentIdentityBlueprint.ReadWrite.All + • DelegatedPermissionGrant.ReadWrite.All + • Directory.Read.All + +If you haven't created this app yet, see: + docs/guides/custom-client-app-registration.md + +================================================================= + +Client App ID (Application ID from Entra ID): a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6 + +Validating client app... +✓ Client app found in tenant +✓ Required permissions configured +✓ Admin consent granted +``` + +**Validation**: The CLI performs comprehensive validation: +- ✅ GUID format check +- ✅ App exists in your tenant +- ✅ All four required permissions are configured +- ✅ Admin consent has been granted + +**If Validation Fails**: You'll see specific error messages and have up to 3 attempts: +``` +✗ Validation failed: + - Missing permission: Application.ReadWrite.All + - Admin consent not granted for: DelegatedPermissionGrant.ReadWrite.All + +Common issues: + • Not all required permissions have been added to the app + • Admin consent has not been granted (click "Grant admin consent" in Azure Portal) + • Using the wrong GUID (use Application ID, not Object ID) + +See troubleshooting guide: + docs/guides/custom-client-app-registration.md#troubleshooting + +Retry (2 attempts remaining)? (Y/n): +``` + +**Prerequisites**: Before running config init, create your custom client app: +1. Follow the guide: [Custom Client App Registration](../guides/custom-client-app-registration.md) +2. Copy the **Application (client) ID** from Azure Portal +3. Ensure admin consent is granted for all permissions +4. Enter the ID when prompted during config init + +**Security Note**: This app stays within your tenant's security boundaries and you control which permissions are granted. + +### Step 4: Deployment Project Path Specify the path to your agent project: @@ -76,7 +137,7 @@ Detected DotNet project - Detects project platform (.NET, Node.js, Python) - Warns if no supported project type detected -### Step 4: Resource Group Selection +### Step 5: Resource Group Selection Choose from existing resource groups or create a new one: @@ -94,7 +155,7 @@ Select resource group (1-3) [1]: 1 - Option to create new resource group - Defaults to existing config value if available -### Step 5: App Service Plan Selection +### Step 6: App Service Plan Selection Choose from existing app service plans in the selected resource group: @@ -111,7 +172,7 @@ Select app service plan (1-2) [1]: 1 - Option to create new plan - Defaults to existing config value -### Step 6: Manager Email +### Step 7: Manager Email Provide the email address of the agent's manager: @@ -121,7 +182,7 @@ Manager email [agent365demo.manager1@a365preview001.onmicrosoft.com]: **Validation**: Ensures valid email format -### Step 7: Azure Location +### Step 8: Azure Location Choose the Azure region for deployment: @@ -131,7 +192,7 @@ Azure location [westus]: **Smart Defaults**: Uses location from existing config or Azure account -### Step 8: Configuration Summary +### Step 9: Configuration Summary Review all settings before saving: @@ -140,6 +201,7 @@ Review all settings before saving: Configuration Summary ================================================================= Agent Name : myagent +Client App ID : a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6 Web App Name : myagent-webapp-11140916 Agent Identity Name : myagent Identity Agent Blueprint Name : myagent Blueprint @@ -156,7 +218,7 @@ Tenant : adfa4542-3e1e-46f5-9c70-3df0b15b3f6c Do you want to customize any derived names? (y/N): ``` -### Step 9: Name Customization (Optional) +### Step 10: Name Customization (Optional) Optionally customize generated names: @@ -170,7 +232,7 @@ Agent User Principal Name [agent.myagent.11140916@yourdomain.onmicrosoft.com]: Agent User Display Name [myagent Agent User]: ``` -### Step 10: Confirmation +### Step 11: Confirmation Final confirmation to save: @@ -188,6 +250,14 @@ You can now run: The wizard automatically populates these fields: +### Authentication (Required for Microsoft Graph API) + +| Field | Description | Source | Example | +|-------|-------------|--------|---------| +| **clientAppId** | Custom Entra ID app registration Application (client) ID | User provides after creating app | `a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6` | + +**Important**: This must be configured before setup. See [Custom Client App Registration Guide](../guides/custom-client-app-registration.md) for detailed setup instructions. + ### Azure Infrastructure (Auto-detected from Azure CLI) | Field | Description | Source | Example | @@ -240,6 +310,7 @@ After completing the wizard, `a365.config.json` is created: ```json { "tenantId": "adfa4542-3e1e-46f5-9c70-3df0b15b3f6c", + "clientAppId": "a1b2c3d4-e5f6-g7h8-i9j0-k1l2m3n4o5p6", "subscriptionId": "e09e22f2-9193-4f54-a335-01f59575eefd", "resourceGroup": "a365demorg", "location": "westus", diff --git a/docs/guides/custom-client-app-registration.md b/docs/guides/custom-client-app-registration.md new file mode 100644 index 00000000..65944683 --- /dev/null +++ b/docs/guides/custom-client-app-registration.md @@ -0,0 +1,126 @@ +# Custom Client App Registration Guide + +## Overview + +The Agent365 CLI requires a custom client app registration in your Entra ID tenant to authenticate and manage Agent Identity Blueprints. This guide covers the Agent365-specific requirements. + +**For general app registration steps**, see Microsoft's official documentation: +- [Quickstart: Register an application](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) +- [Grant tenant-wide admin consent](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent) + +### CRITICAL: Delegated vs Application Permissions + +**You MUST use Delegated permissions (NOT Application permissions)** for all five required permissions. + +| Permission Type | When to Use | How Agent365 Uses It | +|----------------|-------------|---------------------| +| **Delegated** ("Scope") | User signs in interactively | **Agent365 CLI uses this** - You sign in, CLI acts on your behalf | +| **Application** ("Role") | Service runs without user | **Don't use** - For background services/daemons only | + +**Why Delegated?** +- You sign in interactively (`az login`, browser authentication) +- CLI performs actions **as you** (audit trails show your identity) +- More secure - limited by your actual permissions +- Ensures accountability and compliance + +**Common mistake**: Adding `Directory.Read.All` as **Application** instead of **Delegated**. + +## Quick Setup + +### 1. Register Application + +Follow [Microsoft's quickstart guide](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) to create an app registration with: + +- **Name**: `Agent365 CLI` (or your preferred name) +- **Supported account types**: **Single tenant** (Accounts in this organizational directory only) +- **Redirect URI**: **Public client/native (mobile & desktop)** → `http://localhost:8400/` + +> **Note**: The CLI uses port 8400 for the OAuth callback. Ensure this port is not blocked by your firewall. + +### 2. Copy Application (client) ID + +From the app's **Overview** page, copy the **Application (client) ID**. You'll enter this during `a365 config init`. + +### 3. Configure API Permissions + +**Add as DELEGATED permissions (NOT Application)**: + +In Azure Portal: **API permissions** → **Add a permission** → **Microsoft Graph** → **Delegated permissions** + +| Permission | Purpose | +|-----------|---------| +| `Application.ReadWrite.All` | Create and manage applications and Agent Blueprints | +| `AgentIdentityBlueprint.ReadWrite.All` | Manage Agent Blueprint configurations (beta API) | +| `AgentIdentityBlueprint.UpdateAuthProperties.All` | Update Agent Blueprint inheritable permissions (required for MCP setup) | +| `DelegatedPermissionGrant.ReadWrite.All` | Grant permissions for agent blueprints | +| `Directory.Read.All` | Read directory data for validation | + +**Important**: +- Use **Delegated permissions** (NOT Application permissions) +- See [Microsoft Graph permissions reference](https://learn.microsoft.com/en-us/graph/permissions-reference) for permission details +- All five permissions are required for Agent Blueprint operations +- The two `AgentIdentityBlueprint.*` permissions are beta APIs and may not be visible in all tenants yet + +### 4. Grant Admin Consent + +**CRITICAL**: [Grant tenant-wide admin consent](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent) for all five permissions. + +The CLI validates that admin consent has been granted before allowing blueprint operations. + +### 5. Use in Agent365 CLI + +Run the configuration wizard and enter your Application (client) ID when prompted: + +```powershell +a365 config init +``` + +The CLI automatically validates: +- App exists in your tenant +- Required permissions are configured +- Admin consent has been granted + +## Troubleshooting + +### Permissions "AgentIdentityBlueprint.*" Not Found + +The two `AgentIdentityBlueprint` permissions are **beta API permissions** that may not yet be available in all tenants: +- `AgentIdentityBlueprint.ReadWrite.All` +- `AgentIdentityBlueprint.UpdateAuthProperties.All` + +**Solution**: +1. Ensure you're adding **Microsoft Graph** delegated permissions (not Application permissions) +2. Contact Microsoft support if these permissions aren't visible in your tenant + +### Validation Errors + +The CLI automatically validates your client app when running `a365 setup all` or `a365 config init`. + +Common issues: +- **App not found**: Verify you copied the **Application (client) ID** (not Object ID) +- **Missing permissions**: Add all five required permissions +- **Admin consent not granted**: Click "Grant admin consent" in Azure Portal +- **Wrong permission type**: Use Delegated permissions, not Application permissions + +For detailed troubleshooting, see [Microsoft's app registration documentation](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app). + +## Security Best Practices + +**Do**: +- Use single-tenant registration +- Grant only the five required delegated permissions +- Audit permissions regularly +- Remove the app when no longer needed + +**Don't**: +- Grant Application permissions (use Delegated only) +- Share the Client ID publicly +- Grant additional unnecessary permissions +- Use the app for other purposes + +## Additional Resources + +- [Microsoft Graph Permissions Reference](https://learn.microsoft.com/en-us/graph/permissions-reference) +- [Entra ID App Registration](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) +- [Grant Admin Consent](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent) +- [Agent365 CLI Documentation](../README.md) diff --git a/scripts/cli/install-cli.ps1 b/scripts/cli/install-cli.ps1 index e371215b..4cf21210 100644 --- a/scripts/cli/install-cli.ps1 +++ b/scripts/cli/install-cli.ps1 @@ -16,6 +16,11 @@ $outputDir = Join-Path $PSScriptRoot 'nupkg' if (-not (Test-Path $outputDir)) { New-Item -ItemType Directory -Path $outputDir | Out-Null } + +# Clean old packages to ensure fresh build +Write-Host "Cleaning old packages from $outputDir..." +Get-ChildItem -Path $outputDir -Filter '*.nupkg' | Remove-Item -Force + # Build the project first to ensure NuGet restore and build outputs exist Write-Host "Building CLI tool (Release configuration)..." dotnet build $projectPath -c Release @@ -39,14 +44,19 @@ if (-not $nupkg) { Write-Host "Installing Agent 365 CLI from local package: $($nupkg.Name)" -# Uninstall any existing global CLI tool +# Uninstall any existing global CLI tool (force to handle version conflicts) +Write-Host "Uninstalling existing CLI tool..." try { - dotnet tool uninstall -g Microsoft.Agents.A365.DevTools.Cli + dotnet tool uninstall -g Microsoft.Agents.A365.DevTools.Cli 2>$null + Write-Host "Existing CLI uninstalled successfully." -ForegroundColor Green } catch { - Write-Host "No existing CLI found or uninstall failed. Proceeding with install." -ForegroundColor Yellow + Write-Host "No existing CLI found. Proceeding with fresh install." -ForegroundColor Yellow } -dotnet tool install -g Microsoft.Agents.A365.DevTools.Cli --add-source $outputDir --prerelease +# Install with specific version from local source +Write-Host "Installing CLI tool..." +$version = $nupkg.Name -replace 'Microsoft\.Agents\.A365\.DevTools\.Cli\.(.*)\.nupkg','$1' +dotnet tool install -g Microsoft.Agents.A365.DevTools.Cli --add-source $outputDir --version $version if ($LASTEXITCODE -ne 0) { Write-Error "ERROR: CLI installation failed. Check output above for details." exit 1 diff --git a/src/DEVELOPER.md b/src/DEVELOPER.md index 2b1ca230..4328ae15 100644 --- a/src/DEVELOPER.md +++ b/src/DEVELOPER.md @@ -299,6 +299,12 @@ The CLI configures three layers of permissions for agent blueprints: **Unified Configuration:** `SetupHelpers.EnsureResourcePermissionsAsync` handles all three layers plus verification with retry logic (exponential backoff: 2s → 4s → 8s). +1. **OAuth2 Grants** - Admin consent via Graph API `/oauth2PermissionGrants` +2. **Required Resource Access** - Portal-visible permissions (Entra ID "API permissions") +3. **Inheritable Permissions** - Blueprint-level permissions that instances inherit automatically + +**Unified Configuration:** `SetupHelpers.EnsureResourcePermissionsAsync` handles all three layers plus verification with retry logic (exponential backoff: 2s → 4s → 8s → 16s → 32s, max 5 retries). + **Per-Resource Tracking:** `ResourceConsent` model tracks inheritance state per resource (Agent 365 Tools, Messaging Bot API, Observability API). Check global status with `config.IsInheritanceConfigured()`. **Best Practice:** Agent instances automatically inherit permissions from blueprint - no additional admin consent required. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs index 96130517..648f8a7f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs @@ -400,12 +400,16 @@ private static async Task EnsureMcpInheritablePermissionsAsync( var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); + // Use custom client app auth for inheritable permissions - Azure CLI doesn't support this operation + var requiredPermissions = new[] { "AgentIdentityBlueprint.UpdateAuthProperties.All", "Application.ReadWrite.All" }; + var (ok, alreadyExists, err) = await graphService.SetInheritablePermissionsAsync( - config.TenantId, config.AgentBlueprintId, resourceAppId, scopes, new List() { "AgentIdentityBlueprint.ReadWrite.All" }, ct); + config.TenantId, config.AgentBlueprintId, resourceAppId, scopes, requiredScopes: requiredPermissions, ct); if (!ok && !alreadyExists) { - throw new InvalidOperationException("Failed to set inheritable permissions: " + err); + throw new InvalidOperationException("Failed to set inheritable permissions: " + err + + ". Ensure you have AgentIdentityBlueprint.UpdateAuthProperties.All and Application.ReadWrite.All permissions in your custom client app."); } logger.LogInformation(" - Inheritable permissions completed: blueprint {Blueprint} to resourceAppId {ResourceAppId} scopes [{Scopes}]", diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs index eabb56ad..7d10e968 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs @@ -402,7 +402,7 @@ private static string GetWellKnownResourceName(string? resourceAppId) { return resourceAppId switch { - "00000003-0000-0000-c000-000000000000" => "Microsoft Graph", + AuthenticationConstants.MicrosoftGraphResourceAppId => "Microsoft Graph", "00000002-0000-0000-c000-000000000000" => "Azure Active Directory Graph", "797f4846-ba00-4fd7-ba43-dac1f8f63013" => "Azure Service Management", "00000001-0000-0000-c000-000000000000" => "Azure ESTS Service", diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs index 226113e3..76c78fdb 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs @@ -104,12 +104,77 @@ public static Command CreateCommand( // Load configuration var setupConfig = await configService.LoadAsync(config.FullName); - // Validate Azure authentication + // PHASE 1: VALIDATE ALL PREREQUISITES UPFRONT + logger.LogInformation("Validating all prerequisites..."); + logger.LogInformation(""); + + var allErrors = new List(); + + // Validate Azure CLI authentication first + logger.LogInformation("Validating Azure CLI authentication..."); if (!await azureValidator.ValidateAllAsync(setupConfig.SubscriptionId)) { + allErrors.Add("Azure CLI authentication failed or subscription not set correctly"); + logger.LogError("Azure CLI authentication validation failed"); + } + else + { + logger.LogInformation("Azure CLI authentication: OK"); + } + + // Validate Infrastructure prerequisites + if (!skipInfrastructure && setupConfig.NeedDeployment) + { + logger.LogInformation("Validating Infrastructure prerequisites..."); + var infraErrors = await InfrastructureSubcommand.ValidateAsync(setupConfig, azureValidator, CancellationToken.None); + if (infraErrors.Count > 0) + { + allErrors.AddRange(infraErrors.Select(e => $"Infrastructure: {e}")); + foreach (var error in infraErrors) + { + logger.LogError(" - {Error}", error); + } + } + else + { + logger.LogInformation("Infrastructure prerequisites: OK"); + } + } + + // Validate Blueprint prerequisites + logger.LogInformation("Validating Blueprint prerequisites..."); + var blueprintErrors = await BlueprintSubcommand.ValidateAsync(setupConfig, azureValidator, CancellationToken.None); + if (blueprintErrors.Count > 0) + { + allErrors.AddRange(blueprintErrors.Select(e => $"Blueprint: {e}")); + foreach (var error in blueprintErrors) + { + logger.LogError(" - {Error}", error); + } + } + else + { + logger.LogInformation("Blueprint prerequisites: OK"); + } + + // Stop if any validation failed + if (allErrors.Count > 0) + { + logger.LogError(""); + logger.LogError("Setup cannot proceed due to validation failures:"); + foreach (var error in allErrors) + { + logger.LogError(" - {Error}", error); + } + logger.LogError(""); + logger.LogError("Please fix the errors above and try again"); + setupResults.Errors.AddRange(allErrors); ExceptionHandler.ExitWithCleanup(1); + return; } + logger.LogInformation(""); + logger.LogInformation("All validations passed. Starting setup execution..."); logger.LogInformation(""); var generatedConfigPath = Path.Combine( @@ -171,6 +236,14 @@ public static Command CreateCommand( setupResults.BlueprintCreated = blueprintCreated; setupResults.MessagingEndpointRegistered = blueprintCreated; + if (!blueprintCreated) + { + throw new GraphApiException( + operation: "Create Agent Blueprint", + reason: "Blueprint creation failed. This typically indicates missing permissions or insufficient privileges.", + isPermissionIssue: true); + } + if (blueprintCreated) { // CRITICAL: Wait for file system to ensure config file is fully written @@ -266,9 +339,8 @@ public static Command CreateCommand( logger.LogWarning("Bot permissions failed: {Message}. Setup will continue, but Bot API permissions must be configured manually", botPermEx.Message); } - // Display verification info and summary + // Display setup summary logger.LogInformation(""); - await SetupHelpers.DisplayVerificationInfoAsync(config, logger); SetupHelpers.DisplaySetupSummary(setupResults, logger); } catch (Agent365Exception ex) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs index da856377..3a65b1a1 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs @@ -27,7 +27,25 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; /// internal static class BlueprintSubcommand { - private const string MicrosoftGraphCommandLineToolsAppId = "14d82eec-204b-4c2f-b7e8-296a70dab67e"; + /// + /// Validates blueprint prerequisites without performing any actions. + /// + public static Task> ValidateAsync( + Models.Agent365Config config, + IAzureValidator azureValidator, + CancellationToken cancellationToken = default) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(config.ClientAppId)) + { + errors.Add("clientAppId is required in configuration"); + errors.Add("Please configure a custom client app in your tenant with required permissions"); + errors.Add("See docs/guides/custom-client-app-registration.md for setup instructions"); + } + + return Task.FromResult(errors); + } public static Command CreateCommand( ILogger logger, @@ -170,6 +188,7 @@ public static async Task CreateBlueprintImplementationAsync( var consentResult = await EnsureDelegatedConsentWithRetriesAsync( delegatedConsentService, + setupConfig.ClientAppId, setupConfig.TenantId, logger); @@ -284,6 +303,7 @@ await RegisterEndpointAndSyncAsync( /// public static async Task EnsureDelegatedConsentWithRetriesAsync( DelegatedConsentService delegatedConsentService, + string clientAppId, string tenantId, ILogger logger, CancellationToken cancellationToken = default) @@ -296,7 +316,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( async ct => { return await delegatedConsentService.EnsureAgentApplicationCreateConsentAsync( - MicrosoftGraphCommandLineToolsAppId, + clientAppId, tenantId, ct); }, @@ -347,7 +367,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( { logger.LogInformation("Creating Agent Blueprint using Microsoft Graph SDK..."); - using GraphServiceClient graphClient = await GetAuthenticatedGraphClientAsync(logger, tenantId, ct); + using GraphServiceClient graphClient = await GetAuthenticatedGraphClientAsync(logger, setupConfig, tenantId, ct); // Get current user for sponsors field (mimics PowerShell script behavior) string? sponsorUserId = null; @@ -385,7 +405,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( // Create the application using Microsoft Graph SDK using var httpClient = new HttpClient(); - var graphToken = await GetTokenFromGraphClient(logger, graphClient, tenantId); + var graphToken = await GetTokenFromGraphClient(logger, graphClient, tenantId, setupConfig.ClientAppId); if (string.IsNullOrEmpty(graphToken)) { logger.LogError("Failed to extract access token from Graph client"); @@ -643,7 +663,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( /// Extracts the access token from a GraphServiceClient for use in direct HTTP calls. /// This uses InteractiveBrowserCredential directly which is simpler and more reliable. /// - private static async Task GetTokenFromGraphClient(ILogger logger, GraphServiceClient graphClient, string tenantId) + private static async Task GetTokenFromGraphClient(ILogger logger, GraphServiceClient graphClient, string tenantId, string clientAppId) { try { @@ -652,7 +672,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( var credential = new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions { TenantId = tenantId, - ClientId = "14d82eec-204b-4c2f-b7e8-296a70dab67e" // Microsoft Graph PowerShell app ID + ClientId = clientAppId }); var tokenRequestContext = new TokenRequestContext(new[] { "https://graph.microsoft.com/.default" }); @@ -671,7 +691,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( /// Creates and authenticates a GraphServiceClient using InteractiveGraphAuthService. /// This common method consolidates the authentication logic used across multiple methods. /// - private async static Task GetAuthenticatedGraphClientAsync(ILogger logger,string tenantId, CancellationToken ct) + private async static Task GetAuthenticatedGraphClientAsync(ILogger logger, Models.Agent365Config setupConfig, string tenantId, CancellationToken ct) { logger.LogInformation("Authenticating to Microsoft Graph using interactive browser authentication..."); logger.LogInformation("IMPORTANT: Agent Blueprint operations require Application.ReadWrite.All permission."); @@ -682,7 +702,8 @@ private async static Task GetAuthenticatedGraphClientAsync(I // Use InteractiveGraphAuthService to get proper authentication using var cleanLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); var interactiveAuth = new InteractiveGraphAuthService( - cleanLoggerFactory.CreateLogger()); + cleanLoggerFactory.CreateLogger(), + setupConfig.ClientAppId); try { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs index 5af2890e..a0a780f8 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs @@ -3,6 +3,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Exceptions; +using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging; using System.CommandLine; @@ -18,6 +19,38 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; /// public static class InfrastructureSubcommand { + /// + /// Validates infrastructure prerequisites without performing any actions. + /// + public static Task> ValidateAsync( + Agent365Config config, + IAzureValidator azureValidator, + CancellationToken cancellationToken = default) + { + var errors = new List(); + + if (!config.NeedDeployment) + { + return Task.FromResult(errors); + } + + if (string.IsNullOrWhiteSpace(config.SubscriptionId)) + errors.Add("subscriptionId is required for Azure hosting"); + + if (string.IsNullOrWhiteSpace(config.ResourceGroup)) + errors.Add("resourceGroup is required for Azure hosting"); + + if (string.IsNullOrWhiteSpace(config.AppServicePlanName)) + errors.Add("appServicePlanName is required for Azure hosting"); + + if (string.IsNullOrWhiteSpace(config.WebAppName)) + errors.Add("webAppName is required for Azure hosting"); + + if (string.IsNullOrWhiteSpace(config.Location)) + errors.Add("location is required for Azure hosting"); + + return Task.FromResult(errors); + } public static Command CreateCommand( ILogger logger, IConfigService configService, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs index a5b776b1..ef07d3a2 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs @@ -4,6 +4,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Exceptions; 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; @@ -17,6 +18,51 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; /// internal static class PermissionsSubcommand { + /// + /// Validates MCP permissions prerequisites without performing any actions. + /// + public static Task> ValidateMcpAsync( + Agent365Config config, + CancellationToken cancellationToken = default) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) + { + errors.Add("Blueprint ID not found. Run 'a365 setup blueprint' first"); + } + + if (string.IsNullOrWhiteSpace(config.DeploymentProjectPath)) + { + errors.Add("deploymentProjectPath is required to read toolingManifest.json"); + return Task.FromResult(errors); + } + + var manifestPath = Path.Combine(config.DeploymentProjectPath, "toolingManifest.json"); + if (!File.Exists(manifestPath)) + { + errors.Add($"toolingManifest.json not found at {manifestPath}"); + } + + return Task.FromResult(errors); + } + + /// + /// Validates Bot permissions prerequisites without performing any actions. + /// + public static Task> ValidateBotAsync( + Agent365Config config, + CancellationToken cancellationToken = default) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(config.AgentBlueprintId)) + { + errors.Add("Blueprint ID not found. Run 'a365 setup blueprint' first"); + } + + return Task.FromResult(errors); + } public static Command CreateCommand( ILogger logger, IConfigService configService, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs index 2b150d31..d416aab6 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs @@ -252,13 +252,16 @@ public static async Task EnsureResourcePermissionsAsync( logger.LogInformation(" - Inheritable permissions: blueprint {Blueprint} to resourceAppId {ResourceAppId} scopes [{Scopes}]", config.AgentBlueprintId, resourceAppId, string.Join(' ', scopes)); + // Use custom client app auth for inheritable permissions - Azure CLI doesn't support this operation + var requiredPermissions = new[] { "AgentIdentityBlueprint.UpdateAuthProperties.All", "Application.ReadWrite.All" }; + var (ok, alreadyExists, err) = await graph.SetInheritablePermissionsAsync( - config.TenantId, config.AgentBlueprintId, resourceAppId, scopes, new List() { "AgentIdentityBlueprint.ReadWrite.All" }, ct); + config.TenantId, config.AgentBlueprintId, resourceAppId, scopes, requiredScopes: requiredPermissions, ct); if (!ok && !alreadyExists) { throw new SetupValidationException($"Failed to set inheritable permissions: {err}. " + - "Ensure you have Application.ReadWrite.All permissions and the blueprint supports inheritable permissions."); + "Ensure you have AgentIdentityBlueprint.UpdateAuthProperties.All and Application.ReadWrite.All permissions in your custom client app."); } inheritanceConfigured = true; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs index 7ff25302..ff123dd4 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs @@ -22,9 +22,12 @@ public static class AuthenticationConstants public const string CommonTenantId = "common"; /// - /// Localhost redirect URI for interactive browser authentication + /// Localhost redirect URI for interactive browser authentication. + /// Uses a fixed port (8400) to ensure consistent OAuth callbacks across multiple + /// authentication attempts. Users must configure this exact URI in their custom + /// client app registration: http://localhost:8400/ /// - public const string LocalhostRedirectUri = "http://localhost"; + public const string LocalhostRedirectUri = "http://localhost:8400/"; /// /// Application name for cache directory @@ -42,4 +45,27 @@ public static class AuthenticationConstants /// to prevent using tokens that expire during a request /// public const int TokenExpirationBufferMinutes = 5; + + /// + /// Microsoft Graph resource app ID (well-known constant) + /// Used to identify Microsoft Graph API in permission requests + /// + public const string MicrosoftGraphResourceAppId = "00000003-0000-0000-c000-000000000000"; + + /// + /// Required delegated permissions for the custom client app used by a365 CLI. + /// These permissions enable the CLI to manage Entra ID applications and agent blueprints. + /// All permissions require admin consent. + /// + /// Permission GUIDs are resolved dynamically at runtime from Microsoft Graph to ensure + /// compatibility across different tenants and API versions. + /// + public static readonly string[] RequiredClientAppPermissions = new[] + { + "Application.ReadWrite.All", + "AgentIdentityBlueprint.ReadWrite.All", + "AgentIdentityBlueprint.UpdateAuthProperties.All", + "DelegatedPermissionGrant.ReadWrite.All", + "Directory.Read.All" + }; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs index 55f3df53..4893285e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ErrorCodes.cs @@ -18,5 +18,6 @@ public static class ErrorCodes public const string HighPrivilegeScopeDetected = "HIGH_PRIVILEGE_SCOPE_DETECTED"; public const string SetupValidationFailed = "SETUP_VALIDATION_FAILED"; public const string RetryExhausted = "RETRY_EXHAUSTED"; + public const string ClientAppValidationFailed = "CLIENT_APP_VALIDATION_FAILED"; } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs new file mode 100644 index 00000000..b1cb5943 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; + +namespace Microsoft.Agents.A365.DevTools.Cli.Exceptions; + +/// +/// Exception thrown when client app validation fails. +/// This indicates the configured client app does not exist or lacks required permissions. +/// +public sealed class ClientAppValidationException : Agent365Exception +{ + public ClientAppValidationException( + string issueDescription, + List errorDetails, + List mitigationSteps, + Dictionary? context = null) + : base( + errorCode: ErrorCodes.ClientAppValidationFailed, + issueDescription: issueDescription, + errorDetails: errorDetails, + mitigationSteps: mitigationSteps, + context: context) + { + } + + /// + /// Creates exception for when client app is not found in tenant. + /// + public static ClientAppValidationException AppNotFound(string clientAppId, string tenantId) + { + return new ClientAppValidationException( + issueDescription: "Client app not found in tenant", + errorDetails: new List + { + $"Client app with ID '{clientAppId}' does not exist in tenant '{tenantId}'", + "The app may not be registered, or you may be using the wrong ID" + }, + mitigationSteps: new List + { + "Verify the clientAppId in your a365.config.json is correct", + "Check you're using the Application (client) ID, not the Object ID", + "Ensure the app is registered in the correct tenant", + "Follow the setup guide: docs/guides/custom-client-app-registration.md" + }, + context: new Dictionary + { + ["clientAppId"] = clientAppId, + ["tenantId"] = tenantId + }); + } + + /// + /// Creates exception for missing permissions. + /// + public static ClientAppValidationException MissingPermissions( + string clientAppId, + List missingPermissions) + { + return new ClientAppValidationException( + issueDescription: "Client app is missing required API permissions", + errorDetails: new List + { + $"Missing permissions: {string.Join(", ", missingPermissions)}" + }, + mitigationSteps: new List + { + "Go to Azure Portal > App registrations > Your app", + "Navigate to API permissions", + "Add the missing Microsoft Graph delegated permissions", + "Grant admin consent after adding permissions", + "See detailed guide: docs/guides/custom-client-app-registration.md#step-3-add-api-permissions" + }, + context: new Dictionary + { + ["clientAppId"] = clientAppId, + ["missingPermissions"] = string.Join(", ", missingPermissions) + }); + } + + /// + /// Creates exception for missing admin consent. + /// + public static ClientAppValidationException MissingAdminConsent(string clientAppId) + { + return new ClientAppValidationException( + issueDescription: "Admin consent not granted for client app", + errorDetails: new List + { + "The required permissions are configured but admin consent is missing", + "Admin consent must be granted by a Global Administrator" + }, + mitigationSteps: new List + { + "Go to Azure Portal > App registrations > Your app", + "Navigate to API permissions", + "Click 'Grant admin consent for [Your Tenant]'", + "Confirm the consent dialog", + "Wait a few seconds for consent to propagate", + "See detailed guide: docs/guides/custom-client-app-registration.md#step-4-grant-admin-consent" + }, + context: new Dictionary + { + ["clientAppId"] = clientAppId + }); + } + + /// + /// Creates exception for general validation failures with custom details. + /// + public static ClientAppValidationException ValidationFailed( + string issueDescription, + List errorDetails, + string? clientAppId = null) + { + var context = new Dictionary(); + if (!string.IsNullOrWhiteSpace(clientAppId)) + { + context["clientAppId"] = clientAppId; + } + + return new ClientAppValidationException( + issueDescription: issueDescription, + errorDetails: errorDetails, + mitigationSteps: new List + { + "Check the error details above", + "Ensure you are logged in with 'az login'", + "Verify your client app configuration in Azure Portal", + "See setup guide: docs/guides/custom-client-app-registration.md" + }, + context: context); + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/GraphTokenScopeException.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/GraphTokenScopeException.cs index 2d581761..7bb4bcf5 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/GraphTokenScopeException.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/GraphTokenScopeException.cs @@ -12,20 +12,29 @@ public class GraphTokenScopeException : Agent365Exception { private const string IssueDescriptionText = "Graph token contains high-privilege scopes"; - public GraphTokenScopeException(string scope) + public GraphTokenScopeException(string scope, string? clientAppId = null) : base( errorCode: ErrorCodes.HighPrivilegeScopeDetected, issueDescription: IssueDescriptionText, errorDetails: new List { $"Disallowed scope detected in token: {scope}" }, - mitigationSteps: new List - { - "Check Microsoft Graph Command Line Tools app permissions: Azure portal ? App registrations ? 'Microsoft Graph Command Line Tools' (App ID: 14d82eec-204b-4c2f-b7e8-296a70dab67e) ? API permissions.", - "Look for 'Directory.AccessAsUser.All' and remove it or replace it with a least-privilege alternative (for example 'Directory.Read.All') if appropriate.", - "Re-run the CLI and, when the browser consent prompt appears, approve only the scopes requested by the CLI.", - "Note: Removing tenant-wide admin consent for this permission may impact other tools or automation that rely on it. Verify impact before removal." - }) + mitigationSteps: BuildMitigationSteps(clientAppId)) { } + private static List BuildMitigationSteps(string? clientAppId) + { + var appReference = string.IsNullOrWhiteSpace(clientAppId) + ? "[Your App]" + : $"[App ID: {clientAppId}]"; + + return new List + { + $"Check your custom client app permissions in Azure Portal > App registrations > {appReference} > API permissions.", + "Look for 'Directory.AccessAsUser.All' and remove it or replace it with a least-privilege alternative (for example 'Directory.Read.All') if appropriate.", + "Re-run the CLI and, when the browser consent prompt appears, approve only the scopes requested by the CLI.", + "Note: Removing tenant-wide admin consent for this permission may impact other tools or automation that rely on it. Verify impact before removal." + }; + } + public override int ExitCode => 2; // Configuration / permission error } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs index e5601f8f..1da5ec74 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs @@ -26,6 +26,14 @@ public List Validate() var errors = new List(); if (string.IsNullOrWhiteSpace(TenantId)) errors.Add("tenantId is required."); + if (string.IsNullOrWhiteSpace(ClientAppId)) + { + errors.Add("clientAppId is required. This must be a client app you create in your tenant with specific permissions. See docs/guides/custom-client-app-registration.md for setup instructions."); + } + else + { + ValidateGuid(ClientAppId, nameof(ClientAppId), errors); + } if (string.IsNullOrWhiteSpace(SubscriptionId)) errors.Add("subscriptionId is required."); if (string.IsNullOrWhiteSpace(ResourceGroup)) errors.Add("resourceGroup is required."); @@ -47,6 +55,18 @@ public List Validate() return errors; } + + /// + /// Helper method to validate GUID format + /// + private static void ValidateGuid(string value, string fieldName, List errors) + { + if (!Guid.TryParse(value, out _)) + { + errors.Add($"{fieldName} must be a valid GUID format."); + } + } + // ======================================================================== // STATIC PROPERTIES (init-only) - from a365.config.json // Developer-managed, immutable after construction @@ -104,6 +124,23 @@ public List Validate() #endregion + #region Authentication Configuration + + /// + /// Client Application ID for interactive authentication with Microsoft Graph. + /// This must be a client app registration you create in your Entra ID tenant with the following delegated permissions: + /// - Application.ReadWrite.All + /// - AgentIdentityBlueprint.ReadWrite.All + /// - DelegatedPermissionGrant.ReadWrite.All + /// - Directory.Read.All + /// All permissions require admin consent. + /// See docs/guides/custom-client-app-registration.md for setup instructions. + /// + [JsonPropertyName("clientAppId")] + public string ClientAppId { get; init; } = string.Empty; + + #endregion + #region App Service Configuration /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs index 4afe3817..c47a0705 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs @@ -104,14 +104,12 @@ private async Task AuthenticateInteractivelyAsync(string resourceUrl, // Determine which scope to use based on the resource URL or App ID string scope; - string environmentName; // Check if this is the production App ID if (resourceUrl == McpConstants.Agent365ToolsProdAppId) { scope = $"{resourceUrl}/.default"; - environmentName = "PRODUCTION"; - _logger.LogInformation("Using Agent 365 Tools (PRODUCTION) for authentication"); + _logger.LogInformation("Authenticating to Agent 365 Tools"); } // Check for Agent 365 endpoint URLs (legacy support) else if (resourceUrl.Contains("agent365", StringComparison.OrdinalIgnoreCase)) @@ -123,13 +121,11 @@ private async Task AuthenticateInteractivelyAsync(string resourceUrl, if (appId != McpConstants.Agent365ToolsProdAppId) { - environmentName = "CUSTOM"; _logger.LogInformation("Using custom Agent 365 Tools App ID from A365_MCP_APP_ID environment variable"); } else { - environmentName = "PRODUCTION"; - _logger.LogInformation("Using Agent 365 Tools (PRODUCTION) App ID for endpoint URL"); + _logger.LogInformation("Authenticating to Agent 365 Tools"); } scope = $"{appId}/.default"; @@ -141,7 +137,6 @@ private async Task AuthenticateInteractivelyAsync(string resourceUrl, scope = resourceUrl.EndsWith("/.default", StringComparison.OrdinalIgnoreCase) ? resourceUrl : $"{resourceUrl}/.default"; - environmentName = "CUSTOM"; _logger.LogInformation("Using custom resource for authentication: {Resource}", resourceUrl); } @@ -150,13 +145,17 @@ private async Task AuthenticateInteractivelyAsync(string resourceUrl, // For Power Platform API authentication, use device code flow to avoid URL length issues // InteractiveBrowserCredential with Power Platform scopes can create URLs that exceed browser limits - _logger.LogInformation("Opening browser for authentication ({Environment} environment)...", environmentName); + _logger.LogInformation("Using device code authentication..."); _logger.LogInformation("Please sign in with your Microsoft account"); TokenCredential credential = new DeviceCodeCredential(new DeviceCodeCredentialOptions { TenantId = effectiveTenantId, ClientId = AuthenticationConstants.PowershellClientId, + TokenCachePersistenceOptions = new TokenCachePersistenceOptions + { + Name = "Microsoft.Agents.A365.DevTools.Cli" + }, DeviceCodeCallback = (code, cancellation) => { Console.WriteLine(); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs new file mode 100644 index 00000000..000fb6eb --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs @@ -0,0 +1,517 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; +using Microsoft.Extensions.Logging; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Validates that a client app exists and has the required permissions for a365 CLI operations. +/// +public sealed class ClientAppValidator +{ + private readonly ILogger _logger; + private readonly CommandExecutor _executor; + + private const string GraphApiBaseUrl = "https://graph.microsoft.com/v1.0"; + private const string GraphTokenResource = "https://graph.microsoft.com"; + + public ClientAppValidator(ILogger logger, CommandExecutor executor) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + /// + /// Ensures the client app exists and has required permissions granted. + /// Throws ClientAppValidationException if validation fails. + /// Logs validation progress and results automatically. + /// + /// The client app ID to validate + /// The tenant ID where the app should exist + /// Cancellation token + /// Thrown when validation fails + public async Task EnsureValidClientAppAsync( + string clientAppId, + string tenantId, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(clientAppId); + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + + _logger.LogInformation(""); + _logger.LogInformation("==> Validating Client App Configuration"); + + var result = await ValidateClientAppAsync(clientAppId, tenantId, ct); + + if (!result.IsValid) + { + ThrowAppropriateException(result, clientAppId, tenantId); + } + } + + /// + /// Validates that the client app exists and has required permissions granted. + /// Returns validation result with error details for programmatic handling. + /// + /// The client app ID to validate + /// The tenant ID where the app should exist + /// Cancellation token + /// Validation result with structured error information + public async Task ValidateClientAppAsync( + string clientAppId, + string tenantId, + CancellationToken ct = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(clientAppId); + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); + + // Step 1: Validate GUID format + if (!Guid.TryParse(clientAppId, out _)) + { + return ValidationResult.Failure( + ValidationFailureType.InvalidFormat, + $"clientAppId must be a valid GUID format (received: {clientAppId})"); + } + + if (!Guid.TryParse(tenantId, out _)) + { + return ValidationResult.Failure( + ValidationFailureType.InvalidFormat, + $"tenantId must be a valid GUID format (received: {tenantId})"); + } + + try + { + // Step 2: Acquire Graph token + var graphToken = await AcquireGraphTokenAsync(ct); + if (string.IsNullOrWhiteSpace(graphToken)) + { + return ValidationResult.Failure( + ValidationFailureType.AuthenticationFailed, + "Failed to acquire Microsoft Graph access token. Ensure you are logged in with 'az login'"); + } + + // Step 3: Verify app exists + var appInfo = await GetClientAppInfoAsync(clientAppId, graphToken, ct); + if (appInfo == null) + { + return ValidationResult.Failure( + ValidationFailureType.AppNotFound, + $"Client app with ID '{clientAppId}' not found in tenant '{tenantId}'", + "Please create the app registration in Azure Portal and ensure the app ID is correct"); + } + + _logger.LogInformation("Found client app: {DisplayName} ({AppId})", appInfo.DisplayName, clientAppId); + + // Step 4: Validate permissions in manifest + var missingPermissions = await ValidatePermissionsConfiguredAsync(appInfo, graphToken, ct); + if (missingPermissions.Count > 0) + { + return ValidationResult.Failure( + ValidationFailureType.MissingPermissions, + $"Client app is missing required delegated permissions: {string.Join(", ", missingPermissions)}", + "Please add these permissions as DELEGATED (not Application) in Azure Portal > App Registrations > API permissions\nSee: https://github.com/microsoft/Agent365-devTools/blob/main/docs/guides/custom-client-app-registration.md"); + } + + // Step 5: Verify admin consent + var consentResult = await ValidateAdminConsentAsync(clientAppId, graphToken, ct); + if (!consentResult.IsValid) + { + return consentResult; + } + + _logger.LogInformation("Client app validation successful!"); + return ValidationResult.Success(); + } + catch (JsonException ex) + { + _logger.LogError(ex, "JSON parsing error during validation"); + return ValidationResult.Failure( + ValidationFailureType.InvalidResponse, + $"Failed to parse Microsoft Graph response: {ex.Message}"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Validation error"); + return ValidationResult.Failure( + ValidationFailureType.UnexpectedError, + $"Unexpected error during client app validation: {ex.Message}"); + } + } + + #region Private Helper Methods + + private async Task AcquireGraphTokenAsync(CancellationToken ct) + { + _logger.LogInformation("Acquiring Microsoft Graph token for validation..."); + + var tokenResult = await _executor.ExecuteAsync( + "az", + $"account get-access-token --resource {GraphTokenResource} --query accessToken -o tsv", + cancellationToken: ct); + + if (!tokenResult.Success || string.IsNullOrWhiteSpace(tokenResult.StandardOutput)) + { + _logger.LogError("Token acquisition failed: {Error}", tokenResult.StandardError); + return null; + } + + return tokenResult.StandardOutput.Trim(); + } + + private async Task GetClientAppInfoAsync(string clientAppId, string graphToken, CancellationToken ct) + { + _logger.LogInformation("Checking if client app exists in tenant..."); + + var appCheckResult = await _executor.ExecuteAsync( + "az", + $"rest --method GET --url \"{GraphApiBaseUrl}/applications?$filter=appId eq '{clientAppId}'&$select=id,appId,displayName,requiredResourceAccess\" --headers \"Authorization=Bearer {graphToken}\"", + cancellationToken: ct); + + if (!appCheckResult.Success) + { + // Check for Continuous Access Evaluation (CAE) token issues + if (appCheckResult.StandardError.Contains("TokenCreatedWithOutdatedPolicies", StringComparison.OrdinalIgnoreCase) || + appCheckResult.StandardError.Contains("InvalidAuthenticationToken", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("Azure CLI token is stale due to Continuous Access Evaluation. Refreshing token automatically..."); + + // Force token refresh + var refreshResult = await _executor.ExecuteAsync( + "az", + $"account get-access-token --resource {GraphTokenResource} --query accessToken -o tsv", + cancellationToken: ct); + + if (refreshResult.Success && !string.IsNullOrWhiteSpace(refreshResult.StandardOutput)) + { + var freshToken = refreshResult.StandardOutput.Trim(); + _logger.LogInformation("Token refreshed successfully, retrying..."); + + // Retry with fresh token + var retryResult = await _executor.ExecuteAsync( + "az", + $"rest --method GET --url \"{GraphApiBaseUrl}/applications?$filter=appId eq '{clientAppId}'&$select=id,appId,displayName,requiredResourceAccess\" --headers \"Authorization=Bearer {freshToken}\"", + cancellationToken: ct); + + if (retryResult.Success) + { + appCheckResult = retryResult; + } + else + { + _logger.LogError("App query failed after token refresh: {Error}", retryResult.StandardError); + return null; + } + } + } + + if (!appCheckResult.Success) + { + _logger.LogError("App query failed: {Error}", appCheckResult.StandardError); + return null; + } + } + + var appResponse = JsonNode.Parse(appCheckResult.StandardOutput); + var apps = appResponse?["value"]?.AsArray(); + + if (apps == null || apps.Count == 0) + { + return null; + } + + var app = apps[0]!.AsObject(); + return new ClientAppInfo( + app["id"]?.GetValue() ?? string.Empty, + app["displayName"]?.GetValue() ?? string.Empty, + app["requiredResourceAccess"]?.AsArray()); + } + + private async Task> ValidatePermissionsConfiguredAsync( + ClientAppInfo appInfo, + string graphToken, + CancellationToken ct) + { + var missingPermissions = new List(); + + if (appInfo.RequiredResourceAccess == null || appInfo.RequiredResourceAccess.Count == 0) + { + return AuthenticationConstants.RequiredClientAppPermissions.ToList(); + } + + // Find Microsoft Graph resource in required permissions + JsonObject? graphResource = null; + foreach (var resource in appInfo.RequiredResourceAccess) + { + var resourceObj = resource?.AsObject(); + var resourceAppId = resourceObj?["resourceAppId"]?.GetValue(); + if (resourceAppId == AuthenticationConstants.MicrosoftGraphResourceAppId) + { + graphResource = resourceObj; + break; + } + } + + if (graphResource == null) + { + return AuthenticationConstants.RequiredClientAppPermissions.ToList(); + } + + var resourceAccess = graphResource["resourceAccess"]?.AsArray(); + if (resourceAccess == null || resourceAccess.Count == 0) + { + return AuthenticationConstants.RequiredClientAppPermissions.ToList(); + } + + // Build set of configured permission IDs + var configuredPermissionIds = new HashSet(); + foreach (var access in resourceAccess) + { + var accessObj = access?.AsObject(); + var permissionId = accessObj?["id"]?.GetValue(); + var permissionType = accessObj?["type"]?.GetValue(); + + if (permissionType == "Scope" && !string.IsNullOrWhiteSpace(permissionId)) + { + configuredPermissionIds.Add(permissionId); + } + } + + // Resolve ALL permission IDs dynamically from Microsoft Graph + // This ensures compatibility across different tenants and API versions + var permissionNameToIdMap = await ResolvePermissionIdsAsync(graphToken, ct); + + // Check each required permission + foreach (var permissionName in AuthenticationConstants.RequiredClientAppPermissions) + { + if (permissionNameToIdMap.TryGetValue(permissionName, out var permissionId)) + { + if (!configuredPermissionIds.Contains(permissionId)) + { + missingPermissions.Add(permissionName); + } + _logger.LogDebug("Validated permission {PermissionName} (ID: {PermissionId})", permissionName, permissionId); + } + else + { + _logger.LogWarning("Could not resolve permission ID for: {PermissionName}", permissionName); + _logger.LogWarning("This permission may be a beta API or unavailable in your tenant. Validation cannot verify its presence."); + // Don't add to missing list - we can't verify it + } + } + + return missingPermissions; + } + + /// + /// Resolves permission names to their GUIDs by querying Microsoft Graph's published permission definitions. + /// This approach is tenant-agnostic and works across different API versions. + /// + private async Task> ResolvePermissionIdsAsync(string graphToken, CancellationToken ct) + { + var permissionNameToIdMap = new Dictionary(); + + try + { + var graphSpResult = await _executor.ExecuteAsync( + "az", + $"rest --method GET --url \"{GraphApiBaseUrl}/servicePrincipals?$filter=appId eq '{AuthenticationConstants.MicrosoftGraphResourceAppId}'&$select=id,oauth2PermissionScopes\" --headers \"Authorization=Bearer {graphToken}\"", + cancellationToken: ct); + + if (!graphSpResult.Success) + { + _logger.LogWarning("Failed to query Microsoft Graph for permission definitions"); + return permissionNameToIdMap; + } + + var graphSpResponse = JsonNode.Parse(graphSpResult.StandardOutput); + var graphSps = graphSpResponse?["value"]?.AsArray(); + + if (graphSps == null || graphSps.Count == 0) + { + _logger.LogWarning("No Microsoft Graph service principal found"); + return permissionNameToIdMap; + } + + var graphSp = graphSps[0]!.AsObject(); + var oauth2PermissionScopes = graphSp["oauth2PermissionScopes"]?.AsArray(); + + if (oauth2PermissionScopes == null) + { + _logger.LogWarning("No permission scopes found in Microsoft Graph service principal"); + return permissionNameToIdMap; + } + + // Build map of all available permissions (name -> GUID) + foreach (var scopeNode in oauth2PermissionScopes) + { + var scopeObj = scopeNode?.AsObject(); + var scopeValue = scopeObj?["value"]?.GetValue(); + var scopeId = scopeObj?["id"]?.GetValue(); + + if (!string.IsNullOrWhiteSpace(scopeValue) && !string.IsNullOrWhiteSpace(scopeId)) + { + permissionNameToIdMap[scopeValue] = scopeId; + } + } + + _logger.LogDebug("Retrieved {Count} permission definitions from Microsoft Graph", permissionNameToIdMap.Count); + } + catch (Exception ex) + { + _logger.LogWarning("Could not retrieve Microsoft Graph permission definitions: {Message}", ex.Message); + } + + return permissionNameToIdMap; + } + + private async Task ValidateAdminConsentAsync(string clientAppId, string graphToken, CancellationToken ct) + { + _logger.LogInformation("Checking admin consent status..."); + + // Get service principal for the app + var spCheckResult = await _executor.ExecuteAsync( + "az", + $"rest --method GET --url \"{GraphApiBaseUrl}/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id,appId\" --headers \"Authorization=Bearer {graphToken}\"", + cancellationToken: ct); + + if (!spCheckResult.Success) + { + _logger.LogWarning("Could not verify service principal (may not exist yet): {Error}", spCheckResult.StandardError); + _logger.LogWarning("Admin consent will be verified during first interactive authentication"); + return ValidationResult.Success(); // Best-effort check + } + + var spResponse = JsonNode.Parse(spCheckResult.StandardOutput); + var servicePrincipals = spResponse?["value"]?.AsArray(); + + if (servicePrincipals == null || servicePrincipals.Count == 0) + { + _logger.LogWarning("Service principal not created yet for this app"); + _logger.LogWarning("Admin consent will be verified during first interactive authentication"); + return ValidationResult.Success(); // Best-effort check + } + + var sp = servicePrincipals[0]!.AsObject(); + var spObjectId = sp["id"]?.GetValue(); + + // Check OAuth2 permission grants + var grantsCheckResult = await _executor.ExecuteAsync( + "az", + $"rest --method GET --url \"{GraphApiBaseUrl}/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'\" --headers \"Authorization=Bearer {graphToken}\"", + cancellationToken: ct); + + if (!grantsCheckResult.Success) + { + _logger.LogWarning("Could not verify admin consent status: {Error}", grantsCheckResult.StandardError); + _logger.LogWarning("Please ensure admin consent has been granted for the configured permissions"); + return ValidationResult.Success(); // Best-effort check + } + + var grantsResponse = JsonNode.Parse(grantsCheckResult.StandardOutput); + var grants = grantsResponse?["value"]?.AsArray(); + + if (grants == null || grants.Count == 0) + { + return ValidationResult.Failure( + ValidationFailureType.AdminConsentMissing, + "Admin consent has not been granted for this client app", + "Please grant admin consent in Azure Portal > App Registrations > API permissions > Grant admin consent"); + } + + // Check if there's a grant for Microsoft Graph with required scopes + bool hasGraphGrant = false; + foreach (var grant in grants) + { + var grantObj = grant?.AsObject(); + var scope = grantObj?["scope"]?.GetValue(); + + if (!string.IsNullOrWhiteSpace(scope)) + { + var grantedScopes = scope.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var foundPermissions = AuthenticationConstants.RequiredClientAppPermissions + .Intersect(grantedScopes, StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (foundPermissions.Count > 0) + { + hasGraphGrant = true; + _logger.LogInformation("Admin consent verified for {Count} permissions", foundPermissions.Count); + break; + } + } + } + + if (!hasGraphGrant) + { + return ValidationResult.Failure( + ValidationFailureType.AdminConsentMissing, + "Admin consent appears to be missing or incomplete", + "Please grant admin consent in Azure Portal > App Registrations > API permissions > Grant admin consent"); + } + + return ValidationResult.Success(); + } + + private void ThrowAppropriateException(ValidationResult result, string clientAppId, string tenantId) + { + switch (result.FailureType) + { + case ValidationFailureType.AppNotFound: + throw ClientAppValidationException.AppNotFound(clientAppId, tenantId); + + case ValidationFailureType.MissingPermissions: + var missingPerms = result.Errors[0] + .Replace("Client app is missing required delegated permissions: ", "") + .Split(',', StringSplitOptions.TrimEntries) + .ToList(); + throw ClientAppValidationException.MissingPermissions(clientAppId, missingPerms); + + case ValidationFailureType.AdminConsentMissing: + throw ClientAppValidationException.MissingAdminConsent(clientAppId); + + default: + throw ClientAppValidationException.ValidationFailed( + result.Errors[0], + result.Errors.Skip(1).ToList(), + clientAppId); + } + } + + #endregion + + #region Helper Types + + private record ClientAppInfo(string ObjectId, string DisplayName, JsonArray? RequiredResourceAccess); + + public record ValidationResult( + bool IsValid, + ValidationFailureType FailureType, + List Errors) + { + public static ValidationResult Success() => + new(true, ValidationFailureType.None, new List()); + + public static ValidationResult Failure(ValidationFailureType type, params string[] errors) => + new(false, type, errors.ToList()); + } + + public enum ValidationFailureType + { + None, + InvalidFormat, + AuthenticationFailed, + AppNotFound, + MissingPermissions, + AdminConsentMissing, + InvalidResponse, + UnexpectedError + } + + #endregion +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs index acd673a7..b0c510ec 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; namespace Microsoft.Agents.A365.DevTools.Cli.Services; @@ -81,7 +82,15 @@ private static string ExtractDomainFromAccount(AzureAccountInfo accountInfo) Console.WriteLine("NOTE: Defaulted from current Azure account. To use a different Azure subscription, run 'az login' and then 'az account set --subscription ' before running this command."); Console.WriteLine(); - // Step 3: Get unique agent name + // Step 3: Get and validate Client App ID (required for authentication) + var clientAppId = await PromptForClientAppIdAsync(existingConfig, accountInfo.TenantId); + if (string.IsNullOrWhiteSpace(clientAppId)) + { + _logger.LogError("Client App ID is required. Configuration cancelled"); + return null; + } + + // Step 4: Get unique agent name var agentName = PromptForAgentName(existingConfig); if (string.IsNullOrWhiteSpace(agentName)) { @@ -148,6 +157,7 @@ private static string ExtractDomainFromAccount(AzureAccountInfo accountInfo) Console.WriteLine("================================================================="); Console.WriteLine(" Configuration Summary"); Console.WriteLine("================================================================="); + Console.WriteLine($"Client App ID : {clientAppId}"); Console.WriteLine($"Agent Name : {agentName}"); if (string.IsNullOrWhiteSpace(messagingEndpoint)) @@ -190,6 +200,7 @@ private static string ExtractDomainFromAccount(AzureAccountInfo accountInfo) var config = new Agent365Config { TenantId = accountInfo.TenantId, + ClientAppId = clientAppId, SubscriptionId = accountInfo.Id, ResourceGroup = resourceGroup, Location = location, @@ -621,4 +632,112 @@ private string GetUsageLocationFromAccount(AzureAccountInfo accountInfo) // Default to US for now - could be enhanced to detect from account location return "US"; } + + private async Task PromptForClientAppIdAsync(Agent365Config? existingConfig, string tenantId) + { + Console.WriteLine(); + Console.WriteLine("================================================================="); + Console.WriteLine(" Client App Configuration (REQUIRED)"); + Console.WriteLine("================================================================="); + Console.WriteLine("The a365 CLI requires a custom client app registration in your"); + Console.WriteLine("Entra ID tenant with specific permissions for authentication."); + Console.WriteLine(); + Console.WriteLine("CRITICAL: Add these as DELEGATED permissions (NOT Application):"); + foreach (var permission in AuthenticationConstants.RequiredClientAppPermissions) + { + Console.WriteLine($" - {permission}"); + } + Console.WriteLine(); + Console.WriteLine("Why Delegated? You sign in interactively, CLI acts on your behalf."); + Console.WriteLine("Application permissions are for background services only."); + Console.WriteLine(); + Console.WriteLine("See: https://github.com/microsoft/Agent365-devTools/blob/main/docs/guides/custom-client-app-registration.md"); + Console.WriteLine("================================================================="); + Console.WriteLine(); + + string? clientAppId = null; + int attemptCount = 0; + const int maxAttempts = 3; + + while (attemptCount < maxAttempts) + { + attemptCount++; + + // Prompt for Client App ID + var defaultValue = existingConfig?.ClientAppId ?? string.Empty; + clientAppId = PromptWithDefault( + "Client App ID (GUID format)", + defaultValue, + input => + { + if (string.IsNullOrWhiteSpace(input)) + return (false, "Client App ID is required"); + + if (!Guid.TryParse(input, out _)) + return (false, "Must be a valid GUID format (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)"); + + return (true, ""); + }); + + if (string.IsNullOrWhiteSpace(clientAppId)) + { + Console.WriteLine("Client App ID is required. Setup cannot continue without it."); + continue; + } + + // Validate the client app + Console.WriteLine(); + Console.WriteLine("Validating client app configuration..."); + Console.WriteLine("This may take a few seconds..."); + + using var validationLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); + var executor = new CommandExecutor(validationLoggerFactory.CreateLogger()); + var validator = new ClientAppValidator(validationLoggerFactory.CreateLogger(), executor); + + var validationResult = await validator.ValidateClientAppAsync(clientAppId, tenantId, CancellationToken.None); + + if (validationResult.IsValid) + { + Console.WriteLine("Client app validation successful!"); + Console.WriteLine(); + return clientAppId; + } + + // Validation failed - show errors + Console.WriteLine(); + Console.WriteLine("Client app validation FAILED:"); + foreach (var error in validationResult.Errors) + { + Console.WriteLine($" - {error}"); + } + Console.WriteLine(); + + if (attemptCount < maxAttempts) + { + Console.WriteLine($"Please fix the issues and try again. (Attempt {attemptCount}/{maxAttempts})"); + Console.WriteLine("Press Enter to retry, or type 'cancel' to abort setup."); + var response = Console.ReadLine()?.Trim().ToLowerInvariant(); + if (response == "cancel") + { + return null; + } + } + else + { + Console.WriteLine($"Validation failed after {maxAttempts} attempts."); + Console.WriteLine("Please fix the client app configuration and run 'a365 config init' again."); + Console.WriteLine(); + Console.WriteLine("Common issues:"); + Console.WriteLine(" 1. App not created in Azure Portal > Entra ID > App registrations"); + Console.WriteLine(" 2. Permissions added as 'Application' instead of 'Delegated' type"); + Console.WriteLine(" 3. Required API permissions not added"); + Console.WriteLine(" 4. Admin consent not granted"); + Console.WriteLine(); + Console.WriteLine("See: https://github.com/microsoft/Agent365-devTools/blob/main/docs/guides/custom-client-app-registration.md"); + return null; + } + } + + return clientAppId; + } } \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs index 98291dbb..e6e50745 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs @@ -3,6 +3,7 @@ using System.Net.Http.Headers; using System.Text.Json; +using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; using Microsoft.Extensions.Logging; @@ -19,7 +20,6 @@ public sealed class DelegatedConsentService private readonly GraphApiService _graphService; // Constants from PowerShell script - private const string GraphAppId = "00000003-0000-0000-c000-000000000000"; // Microsoft Graph private const string TargetScope = "AgentApplication.Create Application.ReadWrite.All"; private const string AllPrincipalsConsentType = "AllPrincipals"; @@ -32,10 +32,10 @@ public DelegatedConsentService( } /// - /// Ensures AgentApplication.Create permission is granted to Microsoft Graph Command Line Tools + /// Ensures AgentApplication.Create permission is granted to the custom client application /// This is required before creating Agent Blueprints /// - /// Application ID of Microsoft Graph Command Line Tools (14d82eec-204b-4c2f-b7e8-296a70dab67e) + /// Application ID of the custom client app from configuration /// Tenant ID where the permission grant will be created /// Cancellation token /// True if grant was created or updated successfully @@ -90,7 +90,7 @@ public async Task EnsureAgentApplicationCreateConsentAsync( // Step 2: Get Microsoft Graph service principal _logger.LogInformation(" Looking up Microsoft Graph service principal"); - var graphSp = await GetServicePrincipalAsync(httpClient, GraphAppId, cancellationToken); + var graphSp = await GetServicePrincipalAsync(httpClient, AuthenticationConstants.MicrosoftGraphResourceAppId, cancellationToken); if (graphSp == null) { _logger.LogError("Failed to get Microsoft Graph service principal"); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index 5a3a4182..46f44361 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -6,6 +6,7 @@ using System.Text; using System.Text.Json; using System.Linq; +using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -423,7 +424,7 @@ private async Task CreateFederatedIdentityCredentialAsync( { try { - const string msGraphAppId = "00000003-0000-0000-c000-000000000000"; + string msGraphAppId = AuthenticationConstants.MicrosoftGraphResourceAppId; var url = $"https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '{msGraphAppId}'&$select=id,appId,displayName"; var response = await _httpClient.GetAsync(url, cancellationToken); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ISubCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ISubCommand.cs new file mode 100644 index 00000000..bcf4298d --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ISubCommand.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Models; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Interface for subcommands that require validation before execution. +/// Implements separation of validation and execution phases to fail fast on configuration issues. +/// +public interface ISubCommand +{ + /// + /// Validates prerequisites for the subcommand without performing any actions. + /// This should check configuration, authentication, and environment requirements. + /// + /// The Agent365 configuration + /// Cancellation token + /// List of validation errors, empty if validation passes + Task> ValidateAsync(Agent365Config config, CancellationToken cancellationToken = default); +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs index 3fec3a68..575c581d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs @@ -3,6 +3,7 @@ using Azure.Core; using Azure.Identity; +using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Exceptions; using Microsoft.Extensions.Logging; using Microsoft.Graph; @@ -11,7 +12,7 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services; /// /// Provides interactive authentication to Microsoft Graph using browser authentication. -/// This mimics the behavior of Connect-MgGraph in PowerShell which allows creating Agent Blueprints. +/// Uses a custom client app registration created by the user in their tenant. /// /// The key difference from Azure CLI authentication: /// - Azure CLI tokens are delegated (user acting on behalf of themselves) @@ -23,30 +24,56 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services; public sealed class InteractiveGraphAuthService { private readonly ILogger _logger; - - // Microsoft Graph PowerShell app ID (first-party Microsoft app with elevated privileges) - private const string PowerShellAppId = "14d82eec-204b-4c2f-b7e8-296a70dab67e"; + private readonly string _clientAppId; + private GraphServiceClient? _cachedClient; + private string? _cachedTenantId; // Scopes required for Agent Blueprint creation and inheritable permissions configuration private static readonly string[] RequiredScopes = new[] { "https://graph.microsoft.com/Application.ReadWrite.All", - "https://graph.microsoft.com/AgentIdentityBlueprint.ReadWrite.All" + "https://graph.microsoft.com/AgentIdentityBlueprint.ReadWrite.All", + "https://graph.microsoft.com/AgentIdentityBlueprint.UpdateAuthProperties.All" }; - public InteractiveGraphAuthService(ILogger logger) + public InteractiveGraphAuthService( + ILogger logger, + string clientAppId) { - _logger = logger; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + if (string.IsNullOrWhiteSpace(clientAppId)) + { + throw new ArgumentNullException( + nameof(clientAppId), + "Client App ID is required. Configure clientAppId in a365.config.json. See docs/guides/custom-client-app-registration.md for setup instructions."); + } + + if (!Guid.TryParse(clientAppId, out _)) + { + throw new ArgumentException( + $"Client App ID must be a valid GUID format (received: {clientAppId})", + nameof(clientAppId)); + } + + _clientAppId = clientAppId; } /// /// Gets an authenticated GraphServiceClient using interactive browser authentication. - /// This uses the Microsoft Graph PowerShell app ID to get the same elevated privileges. + /// Caches the client instance to avoid repeated authentication prompts. /// public Task GetAuthenticatedGraphClientAsync( string tenantId, CancellationToken cancellationToken = default) { + // Return cached client if available for the same tenant + if (_cachedClient != null && _cachedTenantId == tenantId) + { + _logger.LogDebug("Reusing cached Graph client for tenant {TenantId}", tenantId); + return Task.FromResult(_cachedClient); + } + _logger.LogInformation("Attempting to authenticate to Microsoft Graph interactively..."); _logger.LogInformation("This requires Application.ReadWrite.All and AgentIdentityBlueprint.ReadWrite.All permissions for Agent Blueprint operations."); _logger.LogInformation(""); @@ -63,10 +90,13 @@ public Task GetAuthenticatedGraphClientAsync( var browserCredential = new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions { TenantId = tenantId, - ClientId = PowerShellAppId, + ClientId = _clientAppId, AuthorityHost = AzureAuthorityHosts.AzurePublicCloud, - // MSAL will start local server on http://localhost:{random_port} - // This matches Microsoft Graph PowerShell app registration + RedirectUri = new Uri(AuthenticationConstants.LocalhostRedirectUri), + TokenCachePersistenceOptions = new TokenCachePersistenceOptions + { + Name = AuthenticationConstants.ApplicationName + } }); _logger.LogInformation("Opening browser for authentication..."); @@ -80,6 +110,10 @@ public Task GetAuthenticatedGraphClientAsync( _logger.LogInformation("Successfully authenticated to Microsoft Graph!"); _logger.LogInformation(""); + + // Cache the client for reuse + _cachedClient = graphClient; + _cachedTenantId = tenantId; return Task.FromResult(graphClient); } @@ -124,8 +158,12 @@ public Task GetAuthenticatedGraphClientAsync( var deviceCodeCredential = new DeviceCodeCredential(new DeviceCodeCredentialOptions { TenantId = tenantId, - ClientId = PowerShellAppId, + ClientId = _clientAppId, AuthorityHost = AzureAuthorityHosts.AzurePublicCloud, + TokenCachePersistenceOptions = new TokenCachePersistenceOptions + { + Name = AuthenticationConstants.ApplicationName + }, DeviceCodeCallback = (code, cancellation) => { _logger.LogInformation(""); @@ -149,6 +187,10 @@ public Task GetAuthenticatedGraphClientAsync( _logger.LogInformation("Successfully authenticated to Microsoft Graph!"); _logger.LogInformation(""); + + // Cache the client for reuse + _cachedClient = graphClient; + _cachedTenantId = tenantId; return Task.FromResult(graphClient); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs index c0ee05df..05d5aa51 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs @@ -255,6 +255,7 @@ public async Task CreateBlueprintImplementation_WithAzureValidationFailure_Shoul var config = new Agent365Config { TenantId = "00000000-0000-0000-0000-000000000000", + ClientAppId = "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6", // Required for validation SubscriptionId = "test-sub", AgentBlueprintDisplayName = "Test Blueprint" }; @@ -1010,4 +1011,45 @@ await BlueprintSubcommand.RegisterEndpointAndSyncAsync( } #endregion + + #region EnsureDelegatedConsentWithRetriesAsync Parameter Order Documentation + + [Fact] + public void DocumentParameterOrder_EnsureDelegatedConsentWithRetriesAsync() + { + // This test documents the correct parameter order for EnsureDelegatedConsentWithRetriesAsync + // to prevent the bug where clientAppId and tenantId were accidentally swapped. + // + // Bug History: + // - Parameters were accidentally swapped: (service, tenantId, clientAppId, logger) + // - This caused Azure CLI to authenticate to tenant= (a non-existent tenant) + // - Error: "AADSTS90002: Tenant 'e2af597c-49d3-42e8-b0ff-6c2cbf818ec7' not found" + // - Root cause: Client app ID was passed where tenant ID was expected + // + // Correct Parameter Order: + // await EnsureDelegatedConsentWithRetriesAsync( + // delegatedConsentService, + // setupConfig.ClientAppId, // <-- clientAppId FIRST + // setupConfig.TenantId, // <-- tenantId SECOND + // logger); + // + // The method then calls: + // await delegatedConsentService.EnsureAgentApplicationCreateConsentAsync( + // clientAppId, // <-- Receives setupConfig.ClientAppId + // tenantId, // <-- Receives setupConfig.TenantId + // ct); + // + // Code Reviewers: Verify that BlueprintSubcommand.cs line ~189 follows this pattern. + + var testClientAppId = "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6"; + var testTenantId = "12345678-1234-1234-1234-123456789012"; + + // Assert that test GUIDs are valid and different + Assert.True(Guid.TryParse(testClientAppId, out _), "Test clientAppId should be a valid GUID"); + Assert.True(Guid.TryParse(testTenantId, out _), "Test tenantId should be a valid GUID"); + testClientAppId.Should().NotBe(testTenantId, + "ClientAppId and TenantId must be different to catch parameter swapping bugs"); + } + + #endregion } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/ConfigCommandStaticDynamicSeparationTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/ConfigCommandStaticDynamicSeparationTests.cs index 076bd0dc..445dd570 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/ConfigCommandStaticDynamicSeparationTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/ConfigCommandStaticDynamicSeparationTests.cs @@ -6,6 +6,7 @@ using System.Text.Json; using System.Threading.Tasks; using Microsoft.Agents.A365.DevTools.Cli.Commands; +using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging; @@ -78,7 +79,7 @@ public async Task ConfigInit_WithWizard_OnlySavesStaticPropertiesToConfigFile() wizardResult.ResourceConsents.Add(new ResourceConsent { ResourceName = "Microsoft Graph", - ResourceAppId = "00000003-0000-0000-c000-000000000000", + ResourceAppId = AuthenticationConstants.MicrosoftGraphResourceAppId, ConsentGranted = true }); wizardResult.Completed = true; @@ -189,6 +190,7 @@ public async Task ConfigInit_WithImport_OnlySavesStaticPropertiesToConfigFile() { // Static properties (ALL required fields for validation to pass) TenantId = "import-tenant-123", + ClientAppId = "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6", // Required clientAppId SubscriptionId = "import-sub-456", ResourceGroup = "import-rg", Location = "westus", @@ -290,7 +292,7 @@ public void GetStaticConfig_OnlyReturnsInitOnlyProperties() config.ResourceConsents.Add(new ResourceConsent { ResourceName = "Microsoft Graph", - ResourceAppId = "00000003-0000-0000-c000-000000000000", + ResourceAppId = AuthenticationConstants.MicrosoftGraphResourceAppId, ConsentGranted = true }); config.Completed = true; @@ -340,7 +342,7 @@ public void GetGeneratedConfig_OnlyReturnsMutableProperties() config.ResourceConsents.Add(new ResourceConsent { ResourceName = "Microsoft Graph", - ResourceAppId = "00000003-0000-0000-c000-000000000000", + ResourceAppId = AuthenticationConstants.MicrosoftGraphResourceAppId, ConsentGranted = true }); config.Completed = true; diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SubcommandValidationTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SubcommandValidationTests.cs new file mode 100644 index 00000000..f213268c --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SubcommandValidationTests.cs @@ -0,0 +1,304 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +/// +/// Tests for subcommand validation logic. +/// Ensures prerequisites are validated before execution. +/// +public class SubcommandValidationTests +{ + private readonly IAzureValidator _mockAzureValidator; + + public SubcommandValidationTests() + { + _mockAzureValidator = Substitute.For(); + } + + #region InfrastructureSubcommand Validation Tests + + [Fact] + public async Task InfrastructureSubcommand_WithValidConfig_PassesValidation() + { + // Arrange + var config = new Agent365Config + { + NeedDeployment = true, + SubscriptionId = "test-sub-id", + ResourceGroup = "test-rg", + AppServicePlanName = "test-plan", + WebAppName = "test-webapp", + Location = "westus" + }; + + // Act + var errors = await InfrastructureSubcommand.ValidateAsync(config, _mockAzureValidator); + + // Assert + errors.Should().BeEmpty(); + } + + [Fact] + public async Task InfrastructureSubcommand_WithMissingSubscriptionId_FailsValidation() + { + // Arrange + var config = new Agent365Config + { + NeedDeployment = true, + SubscriptionId = "", + ResourceGroup = "test-rg", + AppServicePlanName = "test-plan", + WebAppName = "test-webapp", + Location = "westus" + }; + + // Act + var errors = await InfrastructureSubcommand.ValidateAsync(config, _mockAzureValidator); + + // Assert + errors.Should().ContainSingle() + .Which.Should().Contain("subscriptionId"); + } + + [Fact] + public async Task InfrastructureSubcommand_WithMissingResourceGroup_FailsValidation() + { + // Arrange + var config = new Agent365Config + { + NeedDeployment = true, + SubscriptionId = "test-sub-id", + ResourceGroup = "", + AppServicePlanName = "test-plan", + WebAppName = "test-webapp", + Location = "westus" + }; + + // Act + var errors = await InfrastructureSubcommand.ValidateAsync(config, _mockAzureValidator); + + // Assert + errors.Should().ContainSingle() + .Which.Should().Contain("resourceGroup"); + } + + [Fact] + public async Task InfrastructureSubcommand_WithMultipleMissingFields_ReturnsAllErrors() + { + // Arrange + var config = new Agent365Config + { + NeedDeployment = true, + SubscriptionId = "", + ResourceGroup = "", + AppServicePlanName = "", + WebAppName = "test-webapp", + Location = "westus" + }; + + // Act + var errors = await InfrastructureSubcommand.ValidateAsync(config, _mockAzureValidator); + + // Assert + errors.Should().HaveCount(3); + errors.Should().Contain(e => e.Contains("subscriptionId")); + errors.Should().Contain(e => e.Contains("resourceGroup")); + errors.Should().Contain(e => e.Contains("appServicePlanName")); + } + + [Fact] + public async Task InfrastructureSubcommand_WhenNeedDeploymentFalse_SkipsValidation() + { + // Arrange + var config = new Agent365Config + { + NeedDeployment = false, + SubscriptionId = "", + ResourceGroup = "", + AppServicePlanName = "", + WebAppName = "", + Location = "" + }; + + // Act + var errors = await InfrastructureSubcommand.ValidateAsync(config, _mockAzureValidator); + + // Assert + errors.Should().BeEmpty(); + } + + #endregion + + #region BlueprintSubcommand Validation Tests + + [Fact] + public async Task BlueprintSubcommand_WithValidConfig_PassesValidation() + { + // Arrange + var config = new Agent365Config + { + ClientAppId = "12345678-1234-1234-1234-123456789012" + }; + + // Act + var errors = await BlueprintSubcommand.ValidateAsync(config, _mockAzureValidator); + + // Assert + errors.Should().BeEmpty(); + } + + [Fact] + public async Task BlueprintSubcommand_WithMissingClientAppId_FailsValidation() + { + // Arrange + var config = new Agent365Config + { + ClientAppId = "" + }; + + // Act + var errors = await BlueprintSubcommand.ValidateAsync(config, _mockAzureValidator); + + // Assert + errors.Should().HaveCountGreaterThan(0); + errors.Should().Contain(e => e.Contains("clientAppId")); + errors.Should().Contain(e => e.Contains("custom-client-app-registration.md")); + } + + #endregion + + #region PermissionsSubcommand Validation Tests + + [Fact] + public async Task PermissionsSubcommand_ValidateMcp_WithValidConfig_PassesValidation() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + var manifestPath = Path.Combine(tempDir, "toolingManifest.json"); + await File.WriteAllTextAsync(manifestPath, "{}"); + + try + { + var config = new Agent365Config + { + AgentBlueprintId = "test-blueprint-id", + DeploymentProjectPath = tempDir + }; + + // Act + var errors = await PermissionsSubcommand.ValidateMcpAsync(config); + + // Assert + errors.Should().BeEmpty(); + } + finally + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task PermissionsSubcommand_ValidateMcp_WithMissingBlueprintId_FailsValidation() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + var manifestPath = Path.Combine(tempDir, "toolingManifest.json"); + await File.WriteAllTextAsync(manifestPath, "{}"); + + try + { + var config = new Agent365Config + { + AgentBlueprintId = "", + DeploymentProjectPath = tempDir + }; + + // Act + var errors = await PermissionsSubcommand.ValidateMcpAsync(config); + + // Assert + errors.Should().ContainSingle() + .Which.Should().Contain("Blueprint ID"); + } + finally + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task PermissionsSubcommand_ValidateMcp_WithMissingManifest_FailsValidation() + { + // Arrange + var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDir); + + try + { + var config = new Agent365Config + { + AgentBlueprintId = "test-blueprint-id", + DeploymentProjectPath = tempDir + }; + + // Act + var errors = await PermissionsSubcommand.ValidateMcpAsync(config); + + // Assert + errors.Should().ContainSingle() + .Which.Should().Contain("toolingManifest.json"); + } + finally + { + if (Directory.Exists(tempDir)) + Directory.Delete(tempDir, true); + } + } + + [Fact] + public async Task PermissionsSubcommand_ValidateBot_WithValidConfig_PassesValidation() + { + // Arrange + var config = new Agent365Config + { + AgentBlueprintId = "test-blueprint-id" + }; + + // Act + var errors = await PermissionsSubcommand.ValidateBotAsync(config); + + // Assert + errors.Should().BeEmpty(); + } + + [Fact] + public async Task PermissionsSubcommand_ValidateBot_WithMissingBlueprintId_FailsValidation() + { + // Arrange + var config = new Agent365Config + { + AgentBlueprintId = "" + }; + + // Act + var errors = await PermissionsSubcommand.ValidateBotAsync(config); + + // Assert + errors.Should().ContainSingle() + .Which.Should().Contain("Blueprint ID"); + } + + #endregion +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs new file mode 100644 index 00000000..a828bb23 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs @@ -0,0 +1,267 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Exceptions; + +/// +/// Unit tests for ClientAppValidationException factory methods. +/// +public class ClientAppValidationExceptionTests +{ + private const string TestClientAppId = "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6"; + private const string TestTenantId = "12345678-1234-1234-1234-123456789012"; + + #region AppNotFound Tests + + [Fact] + public void AppNotFound_CreatesExceptionWithCorrectProperties() + { + // Act + var exception = ClientAppValidationException.AppNotFound(TestClientAppId, TestTenantId); + + // Assert + exception.Should().NotBeNull(); + exception.ErrorCode.Should().Be(ErrorCodes.ClientAppValidationFailed); + exception.IssueDescription.Should().Be("Client app not found in tenant"); + exception.ErrorDetails.Should().HaveCount(2); + exception.ErrorDetails[0].Should().Contain(TestClientAppId); + exception.ErrorDetails[0].Should().Contain(TestTenantId); + exception.MitigationSteps.Should().HaveCount(4); + exception.MitigationSteps.Should().Contain(s => s.Contains("a365.config.json")); + exception.Context.Should().ContainKey("clientAppId"); + exception.Context.Should().ContainKey("tenantId"); + exception.Context["clientAppId"].Should().Be(TestClientAppId); + exception.Context["tenantId"].Should().Be(TestTenantId); + } + + [Fact] + public void AppNotFound_IncludesDocumentationReference() + { + // Act + var exception = ClientAppValidationException.AppNotFound(TestClientAppId, TestTenantId); + + // Assert + exception.MitigationSteps.Should().Contain(s => + s.Contains("docs/guides/custom-client-app-registration.md")); + } + + #endregion + + #region MissingPermissions Tests + + [Fact] + public void MissingPermissions_WithSinglePermission_CreatesExceptionWithCorrectProperties() + { + // Arrange + var missingPermissions = new List { "Application.ReadWrite.All" }; + + // Act + var exception = ClientAppValidationException.MissingPermissions(TestClientAppId, missingPermissions); + + // Assert + exception.Should().NotBeNull(); + exception.ErrorCode.Should().Be(ErrorCodes.ClientAppValidationFailed); + exception.IssueDescription.Should().Be("Client app is missing required API permissions"); + exception.ErrorDetails.Should().HaveCount(1); + exception.ErrorDetails[0].Should().Contain("Application.ReadWrite.All"); + exception.MitigationSteps.Should().HaveCount(5); + exception.MitigationSteps.Should().Contain(s => s.Contains("Azure Portal")); + exception.Context.Should().ContainKey("clientAppId"); + exception.Context.Should().ContainKey("missingPermissions"); + exception.Context["clientAppId"].Should().Be(TestClientAppId); + } + + [Fact] + public void MissingPermissions_WithMultiplePermissions_ListsAllMissingPermissions() + { + // Arrange + var missingPermissions = new List + { + "Application.ReadWrite.All", + "Directory.Read.All", + "DelegatedPermissionGrant.ReadWrite.All" + }; + + // Act + var exception = ClientAppValidationException.MissingPermissions(TestClientAppId, missingPermissions); + + // Assert + exception.ErrorDetails[0].Should().Contain("Application.ReadWrite.All"); + exception.ErrorDetails[0].Should().Contain("Directory.Read.All"); + exception.ErrorDetails[0].Should().Contain("DelegatedPermissionGrant.ReadWrite.All"); + exception.Context["missingPermissions"].Should().Contain("Application.ReadWrite.All"); + exception.Context["missingPermissions"].Should().Contain("Directory.Read.All"); + exception.Context["missingPermissions"].Should().Contain("DelegatedPermissionGrant.ReadWrite.All"); + } + + [Fact] + public void MissingPermissions_IncludesDetailedSetupInstructions() + { + // Arrange + var missingPermissions = new List { "Application.ReadWrite.All" }; + + // Act + var exception = ClientAppValidationException.MissingPermissions(TestClientAppId, missingPermissions); + + // Assert + exception.MitigationSteps.Should().Contain(s => s.Contains("API permissions")); + exception.MitigationSteps.Should().Contain(s => s.Contains("admin consent")); + exception.MitigationSteps.Should().Contain(s => + s.Contains("docs/guides/custom-client-app-registration.md")); + } + + #endregion + + #region MissingAdminConsent Tests + + [Fact] + public void MissingAdminConsent_CreatesExceptionWithCorrectProperties() + { + // Act + var exception = ClientAppValidationException.MissingAdminConsent(TestClientAppId); + + // Assert + exception.Should().NotBeNull(); + exception.ErrorCode.Should().Be(ErrorCodes.ClientAppValidationFailed); + exception.IssueDescription.Should().Be("Admin consent not granted for client app"); + exception.ErrorDetails.Should().HaveCount(2); + exception.ErrorDetails[0].Should().Contain("permissions are configured"); + exception.ErrorDetails[1].Should().Contain("Global Administrator"); + exception.MitigationSteps.Should().HaveCount(6); + exception.Context.Should().ContainKey("clientAppId"); + exception.Context["clientAppId"].Should().Be(TestClientAppId); + } + + [Fact] + public void MissingAdminConsent_IncludesConsentGrantInstructions() + { + // Act + var exception = ClientAppValidationException.MissingAdminConsent(TestClientAppId); + + // Assert + exception.MitigationSteps.Should().Contain(s => s.Contains("Grant admin consent")); + exception.MitigationSteps.Should().Contain(s => s.Contains("Confirm the consent dialog")); + exception.MitigationSteps.Should().Contain(s => + s.Contains("docs/guides/custom-client-app-registration.md#step-4-grant-admin-consent")); + } + + #endregion + + #region ValidationFailed Tests + + [Fact] + public void ValidationFailed_WithClientAppId_CreatesExceptionWithCorrectProperties() + { + // Arrange + var issueDescription = "Custom validation issue"; + var errorDetails = new List { "Error 1", "Error 2" }; + + // Act + var exception = ClientAppValidationException.ValidationFailed( + issueDescription, + errorDetails, + TestClientAppId); + + // Assert + exception.Should().NotBeNull(); + exception.ErrorCode.Should().Be(ErrorCodes.ClientAppValidationFailed); + exception.IssueDescription.Should().Be(issueDescription); + exception.ErrorDetails.Should().HaveCount(2); + exception.ErrorDetails[0].Should().Be("Error 1"); + exception.ErrorDetails[1].Should().Be("Error 2"); + exception.MitigationSteps.Should().HaveCount(4); + exception.Context.Should().ContainKey("clientAppId"); + exception.Context["clientAppId"].Should().Be(TestClientAppId); + } + + [Fact] + public void ValidationFailed_WithoutClientAppId_CreatesExceptionWithoutContext() + { + // Arrange + var issueDescription = "Custom validation issue"; + var errorDetails = new List { "Error 1" }; + + // Act + var exception = ClientAppValidationException.ValidationFailed( + issueDescription, + errorDetails, + clientAppId: null); + + // Assert + exception.Should().NotBeNull(); + exception.ErrorCode.Should().Be(ErrorCodes.ClientAppValidationFailed); + exception.IssueDescription.Should().Be(issueDescription); + exception.ErrorDetails.Should().HaveCount(1); + exception.Context.Should().BeEmpty(); + } + + [Fact] + public void ValidationFailed_IncludesGenericMitigationSteps() + { + // Arrange + var issueDescription = "Custom validation issue"; + var errorDetails = new List { "Error 1" }; + + // Act + var exception = ClientAppValidationException.ValidationFailed( + issueDescription, + errorDetails); + + // Assert + exception.MitigationSteps.Should().Contain(s => s.Contains("az login")); + exception.MitigationSteps.Should().Contain(s => s.Contains("Azure Portal")); + exception.MitigationSteps.Should().Contain(s => + s.Contains("docs/guides/custom-client-app-registration.md")); + } + + #endregion + + #region General Exception Properties Tests + + [Fact] + public void AllFactoryMethods_UseConsistentErrorCode() + { + // Arrange & Act + var appNotFound = ClientAppValidationException.AppNotFound(TestClientAppId, TestTenantId); + var missingPermissions = ClientAppValidationException.MissingPermissions( + TestClientAppId, + new List { "Application.ReadWrite.All" }); + var missingConsent = ClientAppValidationException.MissingAdminConsent(TestClientAppId); + var validationFailed = ClientAppValidationException.ValidationFailed( + "Issue", + new List { "Detail" }); + + // Assert + appNotFound.ErrorCode.Should().Be(ErrorCodes.ClientAppValidationFailed); + missingPermissions.ErrorCode.Should().Be(ErrorCodes.ClientAppValidationFailed); + missingConsent.ErrorCode.Should().Be(ErrorCodes.ClientAppValidationFailed); + validationFailed.ErrorCode.Should().Be(ErrorCodes.ClientAppValidationFailed); + } + + [Fact] + public void AllFactoryMethods_ProvideNonEmptyMitigationSteps() + { + // Arrange & Act + var appNotFound = ClientAppValidationException.AppNotFound(TestClientAppId, TestTenantId); + var missingPermissions = ClientAppValidationException.MissingPermissions( + TestClientAppId, + new List { "Application.ReadWrite.All" }); + var missingConsent = ClientAppValidationException.MissingAdminConsent(TestClientAppId); + var validationFailed = ClientAppValidationException.ValidationFailed( + "Issue", + new List { "Detail" }); + + // Assert + appNotFound.MitigationSteps.Should().NotBeEmpty(); + missingPermissions.MitigationSteps.Should().NotBeEmpty(); + missingConsent.MitigationSteps.Should().NotBeEmpty(); + validationFailed.MitigationSteps.Should().NotBeEmpty(); + } + + #endregion +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Models/Agent365ConfigTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Models/Agent365ConfigTests.cs index 9b1f9d48..99d84aae 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Models/Agent365ConfigTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Models/Agent365ConfigTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Models; using System.Text.Json; using Xunit; @@ -23,6 +24,7 @@ public void StaticProperties_CanBeInitialized() var config = new Agent365Config { TenantId = "12345678-1234-1234-1234-123456789012", + ClientAppId = "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6", SubscriptionId = "87654321-4321-4321-4321-210987654321", ResourceGroup = "rg-test", Location = "eastus", @@ -37,6 +39,7 @@ public void StaticProperties_CanBeInitialized() // Assert Assert.Equal("12345678-1234-1234-1234-123456789012", config.TenantId); + Assert.Equal("a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6", config.ClientAppId); Assert.Equal("87654321-4321-4321-4321-210987654321", config.SubscriptionId); Assert.Equal("rg-test", config.ResourceGroup); Assert.Equal("eastus", config.Location); @@ -107,7 +110,7 @@ public void DynamicProperties_AreMutable() config.ResourceConsents.Add(new ResourceConsent { ResourceName = "Microsoft Graph", - ResourceAppId = "00000003-0000-0000-c000-000000000000", + ResourceAppId = AuthenticationConstants.MicrosoftGraphResourceAppId, ConsentGranted = true, ConsentTimestamp = DateTime.Parse("2025-10-14T12:00:00Z") }); @@ -293,7 +296,7 @@ public void DeserializeFromJson_HandlesDateTimeValues() ""resourceConsents"": [ { ""resourceName"": ""Microsoft Graph"", - ""resourceAppId"": ""00000003-0000-0000-c000-000000000000"", + ""resourceAppId"": ""{AuthenticationConstants.MicrosoftGraphResourceAppId}"", ""consentGranted"": true, ""consentTimestamp"": ""2025-10-14T12:34:56Z"" } @@ -374,6 +377,7 @@ public void Validate_WithMessagingEndpoint_DoesNotRequireAppServiceFields() var config = new Agent365Config { TenantId = "00000000-0000-0000-0000-000000000000", + ClientAppId = "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6", // Added required clientAppId SubscriptionId = "11111111-1111-1111-1111-111111111111", ResourceGroup = "test-rg", Location = "eastus", @@ -460,4 +464,150 @@ public void Validate_WithMessagingEndpoint_StillRequiresBaseFields() } #endregion + + #region ClientAppId Validation Tests + + [Fact] + public void Validate_WithMissingClientAppId_ReturnsError() + { + // Arrange + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", + // ClientAppId is missing + SubscriptionId = "11111111-1111-1111-1111-111111111111", + ResourceGroup = "test-rg", + Location = "eastus", + AgentIdentityDisplayName = "Test Agent", + DeploymentProjectPath = ".", + MessagingEndpoint = "https://test.com/api/messages" + }; + + // Act + var errors = config.Validate(); + + // Assert + errors.Should().Contain(e => e.Contains("clientAppId is required")); + errors.Should().Contain(e => e.Contains("custom-client-app-registration.md")); + } + + [Fact] + public void Validate_WithEmptyClientAppId_ReturnsError() + { + // Arrange + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", + ClientAppId = "", // Empty string + SubscriptionId = "11111111-1111-1111-1111-111111111111", + ResourceGroup = "test-rg", + Location = "eastus", + AgentIdentityDisplayName = "Test Agent", + DeploymentProjectPath = ".", + MessagingEndpoint = "https://test.com/api/messages" + }; + + // Act + var errors = config.Validate(); + + // Assert + errors.Should().Contain(e => e.Contains("clientAppId is required")); + } + + [Fact] + public void Validate_WithWhitespaceClientAppId_ReturnsError() + { + // Arrange + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", + ClientAppId = " ", // Whitespace only + SubscriptionId = "11111111-1111-1111-1111-111111111111", + ResourceGroup = "test-rg", + Location = "eastus", + AgentIdentityDisplayName = "Test Agent", + DeploymentProjectPath = ".", + MessagingEndpoint = "https://test.com/api/messages" + }; + + // Act + var errors = config.Validate(); + + // Assert + errors.Should().Contain(e => e.Contains("clientAppId is required")); + } + + [Fact] + public void Validate_WithInvalidClientAppIdFormat_ReturnsError() + { + // Arrange + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", + ClientAppId = "not-a-valid-guid", // Invalid GUID format + SubscriptionId = "11111111-1111-1111-1111-111111111111", + ResourceGroup = "test-rg", + Location = "eastus", + AgentIdentityDisplayName = "Test Agent", + DeploymentProjectPath = ".", + MessagingEndpoint = "https://test.com/api/messages" + }; + + // Act + var errors = config.Validate(); + + // Assert + errors.Should().Contain(e => e.Contains("ClientAppId") && e.Contains("valid GUID")); + } + + [Fact] + public void Validate_WithValidClientAppId_NoClientAppIdErrors() + { + // Arrange + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", + ClientAppId = "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6", // Valid GUID + SubscriptionId = "11111111-1111-1111-1111-111111111111", + ResourceGroup = "test-rg", + Location = "eastus", + AgentIdentityDisplayName = "Test Agent", + DeploymentProjectPath = ".", + MessagingEndpoint = "https://test.com/api/messages" + }; + + // Act + var errors = config.Validate(); + + // Assert + errors.Should().NotContain(e => e.Contains("clientAppId")); + } + + [Theory] + [InlineData("A1B2C3D4-E5F6-A7B8-C9D0-E1F2A3B4C5D6")] // Uppercase + [InlineData("a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6")] // Lowercase + [InlineData("A1b2C3d4-e5F6-a7B8-C9d0-E1f2A3b4C5d6")] // Mixed case + public void Validate_WithValidClientAppIdFormats_NoErrors(string clientAppId) + { + // Arrange + var config = new Agent365Config + { + TenantId = "00000000-0000-0000-0000-000000000000", + ClientAppId = clientAppId, + SubscriptionId = "11111111-1111-1111-1111-111111111111", + ResourceGroup = "test-rg", + Location = "eastus", + AgentIdentityDisplayName = "Test Agent", + DeploymentProjectPath = ".", + MessagingEndpoint = "https://test.com/api/messages" + }; + + // Act + var errors = config.Validate(); + + // Assert + errors.Should().NotContain(e => e.Contains("clientAppId")); + } + + #endregion } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ConfigServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ConfigServiceTests.cs index 1beb0396..4a4ada3a 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ConfigServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ConfigServiceTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Text.Json; +using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; using Xunit; @@ -155,7 +156,7 @@ public async Task SaveStateAsync_SavesOnlyDynamicProperties() config.ResourceConsents.Add(new ResourceConsent { ResourceName = "Microsoft Graph", - ResourceAppId = "00000003-0000-0000-c000-000000000000", + ResourceAppId = AuthenticationConstants.MicrosoftGraphResourceAppId, ConsentGranted = true }); 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 new file mode 100644 index 00000000..63a66b6d --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs @@ -0,0 +1,570 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; + +/// +/// Unit tests for ClientAppValidator service. +/// Tests validation logic for client app existence, permissions, and admin consent. +/// +public class ClientAppValidatorTests +{ + private readonly ILogger _logger; + private readonly CommandExecutor _executor; + private readonly ClientAppValidator _validator; + + private const string ValidClientAppId = "a1b2c3d4-e5f6-a7b8-c9d0-e1f2a3b4c5d6"; + private const string ValidTenantId = "12345678-1234-1234-1234-123456789012"; + private const string InvalidGuid = "not-a-guid"; + + public ClientAppValidatorTests() + { + _logger = Substitute.For>(); + + // CommandExecutor requires a logger in its constructor for NSubstitute to create a proxy + var executorLogger = Substitute.For>(); + _executor = Substitute.ForPartsOf(executorLogger); + + _validator = new ClientAppValidator(_logger, _executor); + } + + #region Constructor Tests + + [Fact] + public void Constructor_WithNullLogger_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => + new ClientAppValidator(null!, _executor)); + + exception.ParamName.Should().Be("logger"); + } + + [Fact] + public void Constructor_WithNullExecutor_ThrowsArgumentNullException() + { + // Act & Assert + var exception = Assert.Throws(() => + new ClientAppValidator(_logger, null!)); + + exception.ParamName.Should().Be("executor"); + } + + #endregion + + #region ValidateClientAppAsync - Input Validation Tests + + [Fact] + public async Task ValidateClientAppAsync_WithNullClientAppId_ThrowsArgumentException() + { + // Act & Assert + await Assert.ThrowsAsync(() => + _validator.ValidateClientAppAsync(null!, ValidTenantId)); + } + + [Fact] + public async Task ValidateClientAppAsync_WithEmptyClientAppId_ThrowsArgumentException() + { + // Act & Assert + await Assert.ThrowsAsync(() => + _validator.ValidateClientAppAsync(string.Empty, ValidTenantId)); + } + + [Fact] + public async Task ValidateClientAppAsync_WithInvalidClientAppIdFormat_ReturnsInvalidFormatFailure() + { + // Act + var result = await _validator.ValidateClientAppAsync(InvalidGuid, ValidTenantId); + + // Assert + result.IsValid.Should().BeFalse(); + result.FailureType.Should().Be(ClientAppValidator.ValidationFailureType.InvalidFormat); + result.Errors.Should().ContainSingle() + .Which.Should().Contain("must be a valid GUID format"); + } + + [Fact] + public async Task ValidateClientAppAsync_WithInvalidTenantIdFormat_ReturnsInvalidFormatFailure() + { + // Act + var result = await _validator.ValidateClientAppAsync(ValidClientAppId, InvalidGuid); + + // Assert + result.IsValid.Should().BeFalse(); + result.FailureType.Should().Be(ClientAppValidator.ValidationFailureType.InvalidFormat); + result.Errors.Should().ContainSingle() + .Which.Should().Contain("must be a valid GUID format"); + } + + #endregion + + #region ValidateClientAppAsync - Token Acquisition Tests + + [Fact] + public async Task ValidateClientAppAsync_WhenTokenAcquisitionFails_ReturnsAuthenticationFailed() + { + // Arrange + _executor.ExecuteAsync( + Arg.Is(s => s == "az"), + Arg.Is(s => s.Contains("account get-access-token")), + cancellationToken: Arg.Any()) + .Returns(new CommandResult { ExitCode = 1, StandardOutput = string.Empty, StandardError = "Not logged in" }); + + // Act + var result = await _validator.ValidateClientAppAsync(ValidClientAppId, ValidTenantId); + + // Assert + result.IsValid.Should().BeFalse(); + result.FailureType.Should().Be(ClientAppValidator.ValidationFailureType.AuthenticationFailed); + result.Errors.Should().ContainSingle() + .Which.Should().Contain("Failed to acquire Microsoft Graph access token"); + } + + [Fact] + public async Task ValidateClientAppAsync_WhenTokenIsEmpty_ReturnsAuthenticationFailed() + { + // Arrange + _executor.ExecuteAsync( + Arg.Is(s => s == "az"), + Arg.Is(s => s.Contains("account get-access-token")), + cancellationToken: Arg.Any()) + .Returns(new CommandResult { ExitCode = 0, StandardOutput = " ", StandardError = string.Empty }); + + // Act + var result = await _validator.ValidateClientAppAsync(ValidClientAppId, ValidTenantId); + + // Assert + result.IsValid.Should().BeFalse(); + result.FailureType.Should().Be(ClientAppValidator.ValidationFailureType.AuthenticationFailed); + } + + #endregion + + #region ValidateClientAppAsync - App Existence Tests + + [Fact] + public async Task ValidateClientAppAsync_WhenAppDoesNotExist_ReturnsAppNotFound() + { + // Arrange + var token = "fake-token-123"; + _executor.ExecuteAsync( + Arg.Is(s => s == "az"), + Arg.Is(s => s.Contains("account get-access-token")), + cancellationToken: Arg.Any()) + .Returns(new CommandResult { ExitCode = 0, StandardOutput = token, StandardError = string.Empty }); + + _executor.ExecuteAsync( + Arg.Is(s => s == "az"), + Arg.Is(s => s.Contains("rest --method GET") && s.Contains("/applications")), + cancellationToken: Arg.Any()) + .Returns(new CommandResult { ExitCode = 0, StandardOutput = "{\"value\": []}", StandardError = string.Empty }); + + // Act + var result = await _validator.ValidateClientAppAsync(ValidClientAppId, ValidTenantId); + + // Assert + result.IsValid.Should().BeFalse(); + result.FailureType.Should().Be(ClientAppValidator.ValidationFailureType.AppNotFound); + result.Errors.Should().Contain(e => e.Contains($"Client app with ID '{ValidClientAppId}' not found")); + } + + [Fact] + public async Task ValidateClientAppAsync_WhenGraphQueryFails_ReturnsInvalidResponse() + { + // Arrange + var token = "fake-token-123"; + _executor.ExecuteAsync( + Arg.Is(s => s == "az"), + Arg.Is(s => s.Contains("account get-access-token")), + cancellationToken: Arg.Any()) + .Returns(new CommandResult { ExitCode = 0, StandardOutput = token, StandardError = string.Empty }); + + _executor.ExecuteAsync( + Arg.Is(s => s == "az"), + Arg.Is(s => s.Contains("rest --method GET") && s.Contains("/applications")), + cancellationToken: Arg.Any()) + .Returns(new CommandResult { ExitCode = 1, StandardOutput = string.Empty, StandardError = "Graph API error" }); + + // Act + var result = await _validator.ValidateClientAppAsync(ValidClientAppId, ValidTenantId); + + // Assert + result.IsValid.Should().BeFalse(); + result.FailureType.Should().Be(ClientAppValidator.ValidationFailureType.AppNotFound); + } + + #endregion + + #region ValidateClientAppAsync - Permission Validation Tests + + [Fact] + public async Task ValidateClientAppAsync_WhenAppHasNoRequiredResourceAccess_ReturnsMissingPermissions() + { + // Arrange + var token = "fake-token-123"; + SetupTokenAcquisition(token); + SetupAppExists(ValidClientAppId, "Test App", requiredResourceAccess: null); + + // Act + var result = await _validator.ValidateClientAppAsync(ValidClientAppId, ValidTenantId); + + // Assert + result.IsValid.Should().BeFalse(); + result.FailureType.Should().Be(ClientAppValidator.ValidationFailureType.MissingPermissions); + result.Errors.Should().Contain(e => e.Contains("missing required delegated permissions")); + } + + [Fact] + public async Task ValidateClientAppAsync_WhenAppMissingGraphPermissions_ReturnsMissingPermissions() + { + // Arrange + var token = "fake-token-123"; + SetupTokenAcquisition(token); + + var requiredResourceAccess = $$""" + [ + { + "resourceAppId": "some-other-app-id", + "resourceAccess": [] + } + ] + """; + + SetupAppExists(ValidClientAppId, "Test App", requiredResourceAccess); + + // Act + var result = await _validator.ValidateClientAppAsync(ValidClientAppId, ValidTenantId); + + // Assert + result.IsValid.Should().BeFalse(); + result.FailureType.Should().Be(ClientAppValidator.ValidationFailureType.MissingPermissions); + } + + [Fact] + public async Task ValidateClientAppAsync_WhenAppMissingSomePermissions_ReturnsMissingPermissions() + { + // Arrange + var token = "fake-token-123"; + SetupTokenAcquisition(token); + SetupGraphPermissionResolution(token); + + // Only include Application.ReadWrite.All, missing others + var requiredResourceAccess = $$""" + [ + { + "resourceAppId": "{{AuthenticationConstants.MicrosoftGraphResourceAppId}}", + "resourceAccess": [ + { + "id": "1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9", + "type": "Scope" + } + ] + } + ] + """; + + SetupAppExists(ValidClientAppId, "Test App", requiredResourceAccess); + + // Act + var result = await _validator.ValidateClientAppAsync(ValidClientAppId, ValidTenantId); + + // Assert + result.IsValid.Should().BeFalse(); + result.FailureType.Should().Be(ClientAppValidator.ValidationFailureType.MissingPermissions); + result.Errors.Should().Contain(e => e.Contains("missing required delegated permissions")) + .And.Contain(e => e.Contains("DelegatedPermissionGrant.ReadWrite.All") || e.Contains("Directory.Read.All")); + } + + #endregion + + #region ValidateClientAppAsync - Success Tests + + [Fact] + public async Task ValidateClientAppAsync_WhenAllValidationsPass_ReturnsSuccess() + { + // Arrange + var token = "fake-token-123"; + SetupTokenAcquisition(token); + SetupAppExistsWithAllPermissions(ValidClientAppId, "Test App"); + SetupAdminConsentGranted(ValidClientAppId); + + // Act + var result = await _validator.ValidateClientAppAsync(ValidClientAppId, ValidTenantId); + + // Assert + result.IsValid.Should().BeTrue(); + result.FailureType.Should().Be(ClientAppValidator.ValidationFailureType.None); + result.Errors.Should().BeEmpty(); + } + + #endregion + + #region EnsureValidClientAppAsync Tests + + [Fact] + public async Task EnsureValidClientAppAsync_WhenValidationPasses_DoesNotThrow() + { + // Arrange + var token = "fake-token-123"; + SetupTokenAcquisition(token); + SetupAppExistsWithAllPermissions(ValidClientAppId, "Test App"); + SetupAdminConsentGranted(ValidClientAppId); + + // Act & Assert - should not throw + await _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId); + } + + [Fact] + public async Task EnsureValidClientAppAsync_WhenAppNotFound_ThrowsClientAppValidationException() + { + // Arrange + var token = "fake-token-123"; + SetupTokenAcquisition(token); + _executor.ExecuteAsync( + Arg.Is(s => s == "az"), + Arg.Is(s => s.Contains("rest --method GET") && s.Contains("/applications")), + cancellationToken: Arg.Any()) + .Returns(new CommandResult { ExitCode = 0, StandardOutput = "{\"value\": []}", StandardError = string.Empty }); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId)); + + exception.ErrorCode.Should().Be(ErrorCodes.ClientAppValidationFailed); + exception.IssueDescription.Should().Contain("not found in tenant"); + } + + [Fact] + public async Task EnsureValidClientAppAsync_WhenMissingPermissions_ThrowsClientAppValidationException() + { + // Arrange + var token = "fake-token-123"; + SetupTokenAcquisition(token); + SetupAppExists(ValidClientAppId, "Test App", requiredResourceAccess: "[]"); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId)); + + exception.ErrorCode.Should().Be(ErrorCodes.ClientAppValidationFailed); + exception.IssueDescription.Should().Contain("missing required API permissions"); + } + + [Fact] + public async Task EnsureValidClientAppAsync_WhenMissingAdminConsent_ThrowsClientAppValidationException() + { + // Arrange + var token = "fake-token-123"; + SetupTokenAcquisition(token); + SetupAppExistsWithAllPermissions(ValidClientAppId, "Test App"); + SetupAdminConsentNotGranted(ValidClientAppId); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId)); + + exception.ErrorCode.Should().Be(ErrorCodes.ClientAppValidationFailed); + exception.IssueDescription.Should().Contain("Admin consent"); + } + + #endregion + + #region Helper Methods + + private void SetupTokenAcquisition(string token) + { + _executor.ExecuteAsync( + Arg.Is(s => s == "az"), + Arg.Is(s => s.Contains("account get-access-token")), + cancellationToken: Arg.Any()) + .Returns(new CommandResult { ExitCode = 0, StandardOutput = token, StandardError = string.Empty }); + } + + private void SetupAppExists(string appId, string displayName, string? requiredResourceAccess) + { + var resourceAccessJson = requiredResourceAccess ?? "[]"; + var appJson = $$""" + { + "value": [ + { + "id": "object-id-123", + "appId": "{{appId}}", + "displayName": "{{displayName}}", + "requiredResourceAccess": {{resourceAccessJson}} + } + ] + } + """; + + _executor.ExecuteAsync( + Arg.Is(s => s == "az"), + Arg.Is(s => s.Contains("rest --method GET") && s.Contains("/applications")), + cancellationToken: Arg.Any()) + .Returns(new CommandResult { ExitCode = 0, StandardOutput = appJson, StandardError = string.Empty }); + } + + private void SetupAppExistsWithAllPermissions(string appId, string displayName) + { + var requiredResourceAccess = $$""" + [ + { + "resourceAppId": "{{AuthenticationConstants.MicrosoftGraphResourceAppId}}", + "resourceAccess": [ + { + "id": "1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9", + "type": "Scope", + "comment": "Application.ReadWrite.All" + }, + { + "id": "8e8e4742-1d95-4f68-9d56-6ee75648c72a", + "type": "Scope", + "comment": "Directory.Read.All" + }, + { + "id": "06da0dbc-49e2-44d2-8312-53f166ab848a", + "type": "Scope", + "comment": "DelegatedPermissionGrant.ReadWrite.All" + }, + { + "id": "00000000-0000-0000-0000-000000000001", + "type": "Scope", + "comment": "AgentIdentityBlueprint.ReadWrite.All (placeholder GUID for test)" + }, + { + "id": "00000000-0000-0000-0000-000000000002", + "type": "Scope", + "comment": "AgentIdentityBlueprint.UpdateAuthProperties.All (placeholder GUID for test)" + } + ] + } + ] + """; + + SetupAppExists(appId, displayName, requiredResourceAccess); + } + + private void SetupAdminConsentGranted(string clientAppId) + { + // Setup service principal query + var spJson = $$""" + { + "value": [ + { + "id": "sp-object-id-123", + "appId": "{{clientAppId}}" + } + ] + } + """; + + _executor.ExecuteAsync( + Arg.Is(s => s == "az"), + Arg.Is(s => s.Contains("rest --method GET") && s.Contains("/servicePrincipals")), + cancellationToken: Arg.Any()) + .Returns(new CommandResult { ExitCode = 0, StandardOutput = spJson, StandardError = string.Empty }); + + // Setup OAuth2 grants with required scopes (all 5 permissions) + var grantsJson = """ + { + "value": [ + { + "id": "grant-id-123", + "scope": "Application.ReadWrite.All AgentIdentityBlueprint.ReadWrite.All AgentIdentityBlueprint.UpdateAuthProperties.All DelegatedPermissionGrant.ReadWrite.All Directory.Read.All" + } + ] + } + """; + + _executor.ExecuteAsync( + Arg.Is(s => s == "az"), + Arg.Is(s => s.Contains("rest --method GET") && s.Contains("/oauth2PermissionGrants")), + cancellationToken: Arg.Any()) + .Returns(new CommandResult { ExitCode = 0, StandardOutput = grantsJson, StandardError = string.Empty }); + } + + private void SetupAdminConsentNotGranted(string clientAppId) + { + // Setup service principal query + var spJson = $$""" + { + "value": [ + { + "id": "sp-object-id-123", + "appId": "{{clientAppId}}" + } + ] + } + """; + + _executor.ExecuteAsync( + Arg.Is(s => s == "az"), + Arg.Is(s => s.Contains("rest --method GET") && s.Contains("/servicePrincipals")), + cancellationToken: Arg.Any()) + .Returns(new CommandResult { ExitCode = 0, StandardOutput = spJson, StandardError = string.Empty }); + + // Setup empty grants (no consent) + var grantsJson = """ + { + "value": [] + } + """; + + _executor.ExecuteAsync( + Arg.Is(s => s == "az"), + Arg.Is(s => s.Contains("rest --method GET") && s.Contains("/oauth2PermissionGrants")), + cancellationToken: Arg.Any()) + .Returns(new CommandResult { ExitCode = 0, StandardOutput = grantsJson, StandardError = string.Empty }); + } + + private void SetupGraphPermissionResolution(string token) + { + // Mock the Graph API call to retrieve Microsoft Graph's published permission definitions + var graphPermissionsJson = """ + { + "value": [ + { + "id": "graph-sp-id-123", + "oauth2PermissionScopes": [ + { + "id": "1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9", + "value": "Application.ReadWrite.All" + }, + { + "id": "8e8e4742-1d95-4f68-9d56-6ee75648c72a", + "value": "Directory.Read.All" + }, + { + "id": "06da0dbc-49e2-44d2-8312-53f166ab848a", + "value": "DelegatedPermissionGrant.ReadWrite.All" + }, + { + "id": "00000000-0000-0000-0000-000000000001", + "value": "AgentIdentityBlueprint.ReadWrite.All" + }, + { + "id": "00000000-0000-0000-0000-000000000002", + "value": "AgentIdentityBlueprint.UpdateAuthProperties.All" + } + ] + } + ] + } + """; + + _executor.ExecuteAsync( + Arg.Is(s => s == "az"), + Arg.Is(s => s.Contains("rest --method GET") && s.Contains($"/servicePrincipals") && s.Contains($"appId eq '{AuthenticationConstants.MicrosoftGraphResourceAppId}'")), + cancellationToken: Arg.Any()) + .Returns(new CommandResult { ExitCode = 0, StandardOutput = graphPermissionsJson, StandardError = string.Empty }); + } + + #endregion +} From 5175f599203ccf7c0e5b79b9fa513a41a6f0a8d9 Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Fri, 5 Dec 2025 13:04:26 -0800 Subject: [PATCH 2/7] fix: Configure Graph inheritable permissions in blueprint creation Fixes AADSTS65001 error where agent instances couldn't access Microsoft Graph resources. Root cause: Blueprint was granting Graph admin consent but never setting inheritable permissions. Additionally, BlueprintSubcommand was creating a new GraphApiService without the MicrosoftGraphTokenProvider, causing 403 authorization errors when trying to configure permissions. Changes: - Use DI-provided GraphApiService in BlueprintSubcommand (matches MCP/Bot/Observability pattern) - Add Graph inheritable permissions configuration after admin consent - Add scopes parameter to GraphGetAsync/VerifyInheritablePermissionsAsync for custom client app auth - Fix undefined variable in SetupHelpers.EnsureResourcePermissionsAsync - Update all tests to pass mock GraphApiService All 4 permission groups (Graph, MCP, Bot, Observability) now use identical DI pattern. --- .../Commands/SetupCommand.cs | 2 +- .../SetupSubcommands/AllSubcommand.cs | 3 +- .../SetupSubcommands/BlueprintSubcommand.cs | 45 ++++++++++++++-- .../Commands/SetupSubcommands/SetupHelpers.cs | 2 +- .../Services/GraphApiService.cs | 17 +++--- .../Commands/BlueprintSubcommandTests.cs | 53 +++++++++++++------ 6 files changed, 89 insertions(+), 33 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index 4a22e6f2..8ee80904 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -41,7 +41,7 @@ public static Command CreateCommand( logger, configService, azureValidator, webAppCreator, platformDetector, executor)); command.AddCommand(BlueprintSubcommand.CreateCommand( - logger, configService, executor, azureValidator, webAppCreator, platformDetector, botConfigurator)); + logger, configService, executor, azureValidator, webAppCreator, platformDetector, botConfigurator, graphApiService)); command.AddCommand(PermissionsSubcommand.CreateCommand( logger, configService, executor, graphApiService)); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs index 76c78fdb..4e909a4a 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs @@ -230,7 +230,8 @@ public static Command CreateCommand( true, configService, botConfigurator, - platformDetector + platformDetector, + graphApiService ); setupResults.BlueprintCreated = blueprintCreated; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs index 3a65b1a1..4693d36a 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs @@ -54,7 +54,8 @@ public static Command CreateCommand( IAzureValidator azureValidator, AzureWebAppCreator webAppCreator, PlatformDetector platformDetector, - IBotConfigurator botConfigurator) + IBotConfigurator botConfigurator, + GraphApiService graphApiService) { var command = new Command("blueprint", "Create agent blueprint (Entra ID application registration)\n" + @@ -101,7 +102,8 @@ await CreateBlueprintImplementationAsync( false, configService, botConfigurator, - platformDetector + platformDetector, + graphApiService ); }, configOption, verboseOption, dryRunOption); @@ -120,6 +122,7 @@ public static async Task CreateBlueprintImplementationAsync( IConfigService configService, IBotConfigurator botConfigurator, PlatformDetector platformDetector, + GraphApiService graphApiService, CancellationToken cancellationToken = default) { logger.LogInformation(""); @@ -169,9 +172,8 @@ public static async Task CreateBlueprintImplementationAsync( cleanLoggerFactory.CreateLogger(), executor)); - var graphService = new GraphApiService( - cleanLoggerFactory.CreateLogger(), - executor); + // Use DI-provided GraphApiService which already has MicrosoftGraphTokenProvider configured + var graphService = graphApiService; // ======================================================================== // Phase 2.1: Delegated Consent @@ -216,6 +218,7 @@ public static async Task CreateBlueprintImplementationAsync( var blueprintResult = await CreateAgentBlueprintAsync( logger, executor, + graphService, setupConfig.TenantId, setupConfig.AgentBlueprintDisplayName, setupConfig.AgentIdentityDisplayName, @@ -354,6 +357,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( public static async Task<(bool success, string? appId, string? objectId, string? servicePrincipalId)> CreateAgentBlueprintAsync( ILogger logger, CommandExecutor executor, + GraphApiService graphApiService, string tenantId, string displayName, string? agentIdentityDisplayName, @@ -628,6 +632,37 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( logger.LogWarning("Graph API admin consent may not have completed"); } + // Set inheritable permissions for Microsoft Graph so agent instances can access Graph on behalf of users + if (consentSuccess) + { + logger.LogInformation("Configuring inheritable permissions for Microsoft Graph..."); + try + { + // Update config with blueprint ID so EnsureResourcePermissionsAsync can use it + setupConfig.AgentBlueprintId = appId; + + await SetupHelpers.EnsureResourcePermissionsAsync( + graph: graphApiService, + config: setupConfig, + resourceAppId: AuthenticationConstants.MicrosoftGraphResourceAppId, + resourceName: "Microsoft Graph", + scopes: applicationScopes.ToArray(), + logger: logger, + addToRequiredResourceAccess: false, + setInheritablePermissions: true, + setupResults: null, + ct: ct); + + logger.LogInformation("Microsoft Graph inheritable permissions configured successfully"); + } + catch (Exception ex) + { + logger.LogWarning("Failed to configure Microsoft Graph inheritable permissions: {Message}", ex.Message); + logger.LogWarning("Agent instances may not be able to access Microsoft Graph resources"); + logger.LogWarning("You can configure these manually later with: a365 setup permissions"); + } + } + // Add Graph API consent to the resource consents collection var resourceConsents = new JsonArray(); resourceConsents.Add(new JsonObject diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs index d416aab6..c8798698 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs @@ -277,7 +277,7 @@ public static async Task EnsureResourcePermissionsAsync( operation: async (ct) => { var (exists, verifiedScopes, verifyError) = await graph.VerifyInheritablePermissionsAsync( - config.TenantId, config.AgentBlueprintId, resourceAppId, ct); + config.TenantId, config.AgentBlueprintId, resourceAppId, ct, requiredPermissions); return (exists, verifiedScopes, verifyError); }, shouldRetry: (result) => diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index 46f44361..204f65c2 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -671,9 +671,9 @@ private async Task EnsureGraphHeadersAsync(string tenantId, CancellationTo return true; } - public async Task GraphGetAsync(string tenantId, string relativePath, CancellationToken ct = default) + public async Task GraphGetAsync(string tenantId, string relativePath, CancellationToken ct = default, IEnumerable? scopes = null) { - if (!await EnsureGraphHeadersAsync(tenantId, ct)) return null; + if (!await EnsureGraphHeadersAsync(tenantId, ct, scopes)) return null; var url = relativePath.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? relativePath : $"https://graph.microsoft.com{relativePath}"; @@ -871,12 +871,12 @@ public async Task CreateOrUpdateOauth2PermissionGrantAsync( // Try GET for inheritablePermissions - if it fails, attempt to lookup application by appId var getPath = $"/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintObjectId}/inheritablePermissions"; - var existingDoc = await GraphGetAsync(tenantId, getPath, ct); + var existingDoc = await GraphGetAsync(tenantId, getPath, ct, requiredScopes); if (existingDoc == null) { // Attempt to resolve as appId -> application object id - var apps = await GraphGetAsync(tenantId, $"/v1.0/applications?$filter=appId eq '{blueprintAppId}'&$select=id", ct); + var apps = await GraphGetAsync(tenantId, $"/v1.0/applications?$filter=appId eq '{blueprintAppId}'&$select=id", ct, requiredScopes); if (apps != null && apps.RootElement.TryGetProperty("value", out var arr) && arr.GetArrayLength() > 0) { var appObj = arr[0]; @@ -988,18 +988,19 @@ public async Task CreateOrUpdateOauth2PermissionGrantAsync( string tenantId, string blueprintAppId, string resourceAppId, - CancellationToken ct = default) + CancellationToken ct = default, + IEnumerable? requiredScopes = null) { try { string blueprintObjectId = blueprintAppId; var getPath = $"/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintObjectId}/inheritablePermissions"; - var existingDoc = await GraphGetAsync(tenantId, getPath, ct); + var existingDoc = await GraphGetAsync(tenantId, getPath, ct, requiredScopes); if (existingDoc == null) { // Try to resolve as appId -> application object id - var apps = await GraphGetAsync(tenantId, $"/v1.0/applications?$filter=appId eq '{blueprintAppId}'&$select=id", ct); + var apps = await GraphGetAsync(tenantId, $"/v1.0/applications?$filter=appId eq '{blueprintAppId}'&$select=id", ct, requiredScopes); if (apps != null && apps.RootElement.TryGetProperty("value", out var arr) && arr.GetArrayLength() > 0) { var appObj = arr[0]; @@ -1007,7 +1008,7 @@ public async Task CreateOrUpdateOauth2PermissionGrantAsync( { blueprintObjectId = idEl.GetString() ?? blueprintAppId; getPath = $"/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintObjectId}/inheritablePermissions"; - existingDoc = await GraphGetAsync(tenantId, getPath, ct); + existingDoc = await GraphGetAsync(tenantId, getPath, ct, requiredScopes); } } } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs index 05d5aa51..0664a546 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs @@ -27,6 +27,7 @@ public class BlueprintSubcommandTests private readonly AzureWebAppCreator _mockWebAppCreator; private readonly PlatformDetector _mockPlatformDetector; private readonly IBotConfigurator _mockBotConfigurator; + private readonly GraphApiService _mockGraphApiService; public BlueprintSubcommandTests() { @@ -39,6 +40,7 @@ public BlueprintSubcommandTests() var mockPlatformDetectorLogger = Substitute.For>(); _mockPlatformDetector = Substitute.ForPartsOf(mockPlatformDetectorLogger); _mockBotConfigurator = Substitute.For(); + _mockGraphApiService = Substitute.ForPartsOf(Substitute.For>(), _mockExecutor); } @@ -53,7 +55,8 @@ public void CreateCommand_ShouldHaveCorrectName() _mockAzureValidator, _mockWebAppCreator, _mockPlatformDetector, - _mockBotConfigurator); + _mockBotConfigurator, + _mockGraphApiService); // Assert command.Name.Should().Be("blueprint"); @@ -70,7 +73,8 @@ public void CreateCommand_ShouldHaveDescription() _mockAzureValidator, _mockWebAppCreator, _mockPlatformDetector, - _mockBotConfigurator); + _mockBotConfigurator, + _mockGraphApiService); // Assert command.Description.Should().NotBeNullOrEmpty(); @@ -88,7 +92,8 @@ public void CreateCommand_ShouldHaveConfigOption() _mockAzureValidator, _mockWebAppCreator, _mockPlatformDetector, - _mockBotConfigurator); + _mockBotConfigurator, + _mockGraphApiService); // Assert var configOption = command.Options.FirstOrDefault(o => o.Name == "config"); @@ -108,7 +113,8 @@ public void CreateCommand_ShouldHaveVerboseOption() _mockAzureValidator, _mockWebAppCreator, _mockPlatformDetector, - _mockBotConfigurator); + _mockBotConfigurator, + _mockGraphApiService); // Assert var verboseOption = command.Options.FirstOrDefault(o => o.Name == "verbose"); @@ -128,7 +134,8 @@ public void CreateCommand_ShouldHaveDryRunOption() _mockAzureValidator, _mockWebAppCreator, _mockPlatformDetector, - _mockBotConfigurator); + _mockBotConfigurator, + _mockGraphApiService); // Assert var dryRunOption = command.Options.FirstOrDefault(o => o.Name == "dry-run"); @@ -156,7 +163,8 @@ public async Task DryRun_ShouldLoadConfigAndNotExecute() _mockAzureValidator, _mockWebAppCreator, _mockPlatformDetector, - _mockBotConfigurator); + _mockBotConfigurator, + _mockGraphApiService); var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole(); @@ -190,7 +198,8 @@ public async Task DryRun_ShouldDisplayBlueprintInformation() _mockAzureValidator, _mockWebAppCreator, _mockPlatformDetector, - _mockBotConfigurator); + _mockBotConfigurator, + _mockGraphApiService); var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole(); @@ -294,7 +303,8 @@ public void CommandDescription_ShouldMentionRequiredPermissions() _mockAzureValidator, _mockWebAppCreator, _mockPlatformDetector, - _mockBotConfigurator); + _mockBotConfigurator, + _mockGraphApiService); // Assert command.Description.Should().Contain("Agent ID Developer"); @@ -321,7 +331,8 @@ public async Task DryRun_WithCustomConfigPath_ShouldLoadCorrectFile() _mockAzureValidator, _mockWebAppCreator, _mockPlatformDetector, - _mockBotConfigurator); + _mockBotConfigurator, + _mockGraphApiService); var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole(); @@ -356,7 +367,8 @@ public async Task DryRun_ShouldNotCreateServicePrincipal() _mockAzureValidator, _mockWebAppCreator, _mockPlatformDetector, - _mockBotConfigurator); + _mockBotConfigurator, + _mockGraphApiService); var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole(); @@ -383,7 +395,8 @@ public void CreateCommand_ShouldHandleAllOptions() _mockAzureValidator, _mockWebAppCreator, _mockPlatformDetector, - _mockBotConfigurator); + _mockBotConfigurator, + _mockGraphApiService); // Assert - Verify all expected options are present command.Options.Should().HaveCountGreaterOrEqualTo(3); @@ -408,7 +421,8 @@ public async Task DryRun_WithMissingConfig_ShouldHandleGracefully() _mockAzureValidator, _mockWebAppCreator, _mockPlatformDetector, - _mockBotConfigurator); + _mockBotConfigurator, + _mockGraphApiService); var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole(); @@ -429,7 +443,8 @@ public void CreateCommand_DefaultConfigPath_ShouldBeA365ConfigJson() _mockAzureValidator, _mockWebAppCreator, _mockPlatformDetector, - _mockBotConfigurator); + _mockBotConfigurator, + _mockGraphApiService); // Assert - Verify the config option exists and has expected aliases var configOption = command.Options.First(o => o.Name == "config"); @@ -490,7 +505,8 @@ public void CommandDescription_ShouldBeInformativeAndActionable() _mockAzureValidator, _mockWebAppCreator, _mockPlatformDetector, - _mockBotConfigurator); + _mockBotConfigurator, + _mockGraphApiService); // Assert - Verify description provides context and guidance command.Description.Should().NotBeNullOrEmpty(); @@ -517,7 +533,8 @@ public async Task DryRun_WithVerboseFlag_ShouldSucceed() _mockAzureValidator, _mockWebAppCreator, _mockPlatformDetector, - _mockBotConfigurator); + _mockBotConfigurator, + _mockGraphApiService); var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole(); @@ -550,7 +567,8 @@ public async Task DryRun_ShouldShowWhatWouldBeDone() _mockAzureValidator, _mockWebAppCreator, _mockPlatformDetector, - _mockBotConfigurator); + _mockBotConfigurator, + _mockGraphApiService); var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole(); @@ -581,7 +599,8 @@ public void CreateCommand_ShouldBeUsableInCommandPipeline() _mockAzureValidator, _mockWebAppCreator, _mockPlatformDetector, - _mockBotConfigurator); + _mockBotConfigurator, + _mockGraphApiService); // Assert - Verify command can be added to a parser var parser = new CommandLineBuilder(command).Build(); From 162767ffd3327b35274b930e08e644af4c25ea7e Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Fri, 5 Dec 2025 15:21:55 -0800 Subject: [PATCH 3/7] fix: Address PR #72 review comments - improve code quality and documentation consistency - Refactor imperative foreach loops to functional LINQ patterns in ClientAppValidator * Graph resource lookup: .Select().FirstOrDefault() * Permission ID mapping: .Select().Where().ToDictionary() * Admin consent validation: .Select().Where().Any() * Configured permission extraction: .Select().Where().ToHashSet() - Remove redundant conditional check in AllSubcommand after exception throw - Consolidate duplicate permission architecture documentation in DEVELOPER.md - Enhance custom-client-app-registration.md with comprehensive API-based setup * Add prerequisites section (Azure role, Azure CLI) * Provide two clear paths: Portal UI vs Microsoft Graph API * Include complete API workflow with permission IDs and verification steps * Document beta permission behavior and Portal consent limitations - Fix test assertions to validate Microsoft Learn URLs - Fix missing GraphApiService parameter in BlueprintSubcommand test calls - Added Agent365CliDocumentationUrl No behavioral changes or breaking changes introduced. --- README.md | 6 +- docs/commands/config-init.md | 12 +- docs/guides/custom-client-app-registration.md | 165 +++++++++++++++--- src/DEVELOPER.md | 6 - .../SetupSubcommands/AllSubcommand.cs | 35 ++-- .../SetupSubcommands/BlueprintSubcommand.cs | 2 +- .../Constants/ConfigConstants.cs | 5 + .../ClientAppValidationException.cs | 8 +- .../Models/Agent365Config.cs | 13 +- .../Services/ClientAppValidator.cs | 75 ++++---- .../Services/ConfigurationWizardService.cs | 2 +- .../Services/InteractiveGraphAuthService.cs | 12 +- .../Commands/BlueprintSubcommandTests.cs | 9 +- .../Commands/SubcommandValidationTests.cs | 2 +- .../ClientAppValidationExceptionTests.cs | 8 +- .../Models/Agent365ConfigTests.cs | 2 +- 16 files changed, 228 insertions(+), 134 deletions(-) diff --git a/README.md b/README.md index 781c7960..c53d9593 100644 --- a/README.md +++ b/README.md @@ -36,11 +36,7 @@ This project is currently in active development. The CLI is being actively devel Before using the Agent365 CLI, you must create a custom Entra ID app registration with specific Microsoft Graph API permissions: 1. **Custom Client App Registration**: Create an app in your Entra ID tenant -2. **Required Permissions**: Configure **delegated** permissions (NOT Application): - - `Application.ReadWrite.All` - - `AgentIdentityBlueprint.ReadWrite.All` - - `DelegatedPermissionGrant.ReadWrite.All` - - `Directory.Read.All` +2. **Required Permissions**: Configure **delegated** permissions (NOT Application) as defined in `AuthenticationConstants.RequiredClientAppPermissions` in the codebase 3. **Admin Consent**: Grant admin consent for all permissions ⚠️ **Important**: Use **Delegated** permissions (you sign in, CLI acts on your behalf), NOT Application permissions (for background services). diff --git a/docs/commands/config-init.md b/docs/commands/config-init.md index da8cfd02..9a0c8a01 100644 --- a/docs/commands/config-init.md +++ b/docs/commands/config-init.md @@ -73,13 +73,11 @@ The Agent365 CLI requires a custom client app registration in your Entra ID tenant with specific Microsoft Graph API permissions. Required Delegated Permissions: - • Application.ReadWrite.All - • AgentIdentityBlueprint.ReadWrite.All - • DelegatedPermissionGrant.ReadWrite.All - • Directory.Read.All + See AuthenticationConstants.RequiredClientAppPermissions in the codebase + for the complete list of required permissions. If you haven't created this app yet, see: - docs/guides/custom-client-app-registration.md + https://learn.microsoft.com/en-us/microsoft-agent-365/developer/agent-365-cli ================================================================= @@ -94,7 +92,7 @@ Validating client app... **Validation**: The CLI performs comprehensive validation: - ✅ GUID format check - ✅ App exists in your tenant -- ✅ All four required permissions are configured +- ✅ All required permissions are configured (see AuthenticationConstants.RequiredClientAppPermissions) - ✅ Admin consent has been granted **If Validation Fails**: You'll see specific error messages and have up to 3 attempts: @@ -109,7 +107,7 @@ Common issues: • Using the wrong GUID (use Application ID, not Object ID) See troubleshooting guide: - docs/guides/custom-client-app-registration.md#troubleshooting + https://learn.microsoft.com/en-us/microsoft-agent-365/developer/agent-365-cli Retry (2 attempts remaining)? (Y/n): ``` diff --git a/docs/guides/custom-client-app-registration.md b/docs/guides/custom-client-app-registration.md index 65944683..c980b00e 100644 --- a/docs/guides/custom-client-app-registration.md +++ b/docs/guides/custom-client-app-registration.md @@ -27,47 +27,166 @@ The Agent365 CLI requires a custom client app registration in your Entra ID tena ## Quick Setup +### Prerequisites + +- **Azure role**: Global Administrator or Application Administrator +- **Azure CLI**: Installed and signed in (`az login`) +- **Tenant access**: Entra ID tenant where you'll deploy Agent365 + ### 1. Register Application -Follow [Microsoft's quickstart guide](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) to create an app registration with: +Follow [Microsoft's quickstart guide](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) to create an app registration: -- **Name**: `Agent365 CLI` (or your preferred name) -- **Supported account types**: **Single tenant** (Accounts in this organizational directory only) -- **Redirect URI**: **Public client/native (mobile & desktop)** → `http://localhost:8400/` +1. Go to **Azure Portal** → **Entra ID** → **App registrations** → **New registration** +2. Enter: + - **Name**: `Agent365 CLI` (or your preferred name) + - **Supported account types**: **Single tenant** (Accounts in this organizational directory only) + - **Redirect URI**: Select **Public client/native (mobile & desktop)** → Enter `http://localhost:8400/` +3. Click **Register** > **Note**: The CLI uses port 8400 for the OAuth callback. Ensure this port is not blocked by your firewall. ### 2. Copy Application (client) ID -From the app's **Overview** page, copy the **Application (client) ID**. You'll enter this during `a365 config init`. +From the app's **Overview** page, copy the **Application (client) ID** (GUID format). You'll enter this during `a365 config init`. + +> **Tip**: Don't confuse this with **Object ID** - you need the **Application (client) ID**. ### 3. Configure API Permissions -**Add as DELEGATED permissions (NOT Application)**: +**Choose Your Method**: The two `AgentIdentityBlueprint.*` permissions are beta APIs and may not be visible in the Azure Portal UI. You can either: +- **Option A**: Use Azure Portal for all permissions (if beta permissions are visible) +- **Option B**: Use Microsoft Graph API to add all permissions (recommended if beta permissions not visible) + +#### Option A: Azure Portal (Standard Method) -In Azure Portal: **API permissions** → **Add a permission** → **Microsoft Graph** → **Delegated permissions** +**If beta permissions are visible in your tenant**: + +1. In your app registration, go to **API permissions** +2. Click **Add a permission** → **Microsoft Graph** → **Delegated permissions** +3. Search for and add these 5 permissions: | Permission | Purpose | |-----------|---------| | `Application.ReadWrite.All` | Create and manage applications and Agent Blueprints | | `AgentIdentityBlueprint.ReadWrite.All` | Manage Agent Blueprint configurations (beta API) | -| `AgentIdentityBlueprint.UpdateAuthProperties.All` | Update Agent Blueprint inheritable permissions (required for MCP setup) | +| `AgentIdentityBlueprint.UpdateAuthProperties.All` | Update Agent Blueprint inheritable permissions (beta API) | | `DelegatedPermissionGrant.ReadWrite.All` | Grant permissions for agent blueprints | | `Directory.Read.All` | Read directory data for validation | -**Important**: -- Use **Delegated permissions** (NOT Application permissions) -- See [Microsoft Graph permissions reference](https://learn.microsoft.com/en-us/graph/permissions-reference) for permission details -- All five permissions are required for Agent Blueprint operations -- The two `AgentIdentityBlueprint.*` permissions are beta APIs and may not be visible in all tenants yet +4. Click **Grant admin consent for [Your Tenant]** (requires Global Admin or Application Admin role) +5. Verify all permissions show green checkmarks under "Status" + +**Important**: Use **Delegated permissions** (NOT Application permissions). The CLI requires delegated permissions because you sign in interactively. + +If the beta permissions (`AgentIdentityBlueprint.*`) are **not visible**, proceed to **Option B** below. + +#### Option B: Microsoft Graph API (For Beta Permissions) + +**Use this method if `AgentIdentityBlueprint.*` permissions are not visible in Azure Portal**. + +##### Step 1: Add permissions to app manifest + +First, ensure you're signed in with admin privileges: + +```bash +az login +``` + +Update the app registration's `requiredResourceAccess` to include all 5 permissions: + +```bash +# Replace YOUR_CLIENT_APP_ID with your Application (client) ID from Step 2 +az ad app update --id YOUR_CLIENT_APP_ID --required-resource-accesses @- < **Permission ID mapping**: +> - `1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9` = `Application.ReadWrite.All` +> - `e1fe6dd8-ba31-4d61-89e7-88639da4683d` = `Directory.Read.All` +> - `06b708a9-e830-4db3-a914-8e69da51d44f` = `DelegatedPermissionGrant.ReadWrite.All` +> - `8f6a01e7-0391-4ee5-aa22-a3af122cef27` = `AgentIdentityBlueprint.ReadWrite.All` +> - `06da0dbc-49e2-44d2-8312-53f166ab848a` = `AgentIdentityBlueprint.UpdateAuthProperties.All` -### 4. Grant Admin Consent +##### Step 2: Create service principal (if not exists) -**CRITICAL**: [Grant tenant-wide admin consent](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent) for all five permissions. +```bash +# This creates the enterprise app / service principal for your app registration +az ad sp create --id YOUR_CLIENT_APP_ID +``` + +If the service principal already exists, this command will return its details (safe to run). + +##### Step 3: Grant admin consent via API + +Get the service principal object ID: + +```bash +SP_OBJECT_ID=$(az ad sp list --filter "appId eq 'YOUR_CLIENT_APP_ID'" --query "[0].id" -o tsv) +echo "Service Principal Object ID: $SP_OBJECT_ID" +``` + +Get Microsoft Graph service principal ID: + +```bash +GRAPH_SP_ID=$(az ad sp list --filter "appId eq '00000003-0000-0000-c000-000000000000'" --query "[0].id" -o tsv) +echo "Microsoft Graph SP ID: $GRAPH_SP_ID" +``` + +Create the admin consent grant: + +```bash +az rest --method POST \ + --url "https://graph.microsoft.com/v1.0/oauth2PermissionGrants" \ + --body "{ + \"clientId\": \"$SP_OBJECT_ID\", + \"consentType\": \"AllPrincipals\", + \"principalId\": null, + \"resourceId\": \"$GRAPH_SP_ID\", + \"scope\": \"Application.ReadWrite.All Directory.Read.All DelegatedPermissionGrant.ReadWrite.All AgentIdentityBlueprint.ReadWrite.All AgentIdentityBlueprint.UpdateAuthProperties.All\" + }" +``` + +**Verification**: Run this command to verify the grant was created: -The CLI validates that admin consent has been granted before allowing blueprint operations. +```bash +az rest --method GET \ + --url "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?\$filter=clientId eq '$SP_OBJECT_ID'" \ + --query "value[0].scope" -o tsv +``` + +You should see all 5 permission names listed. -### 5. Use in Agent365 CLI +**Critical**: **Do NOT click "Grant admin consent" in Azure Portal** after using the API method. This will remove the beta permissions in tenants where they're not visible in the UI. + +### 4. Use in Agent365 CLI Run the configuration wizard and enter your Application (client) ID when prompted: @@ -82,15 +201,13 @@ The CLI automatically validates: ## Troubleshooting -### Permissions "AgentIdentityBlueprint.*" Not Found +### Beta Permissions Disappear After Portal Admin Consent + +**Symptom**: You used the API method (Option B) to add beta permissions, but they disappeared after clicking "Grant admin consent" in Azure Portal. -The two `AgentIdentityBlueprint` permissions are **beta API permissions** that may not yet be available in all tenants: -- `AgentIdentityBlueprint.ReadWrite.All` -- `AgentIdentityBlueprint.UpdateAuthProperties.All` +**Root cause**: Azure Portal doesn't show beta permissions in the UI, so when you click "Grant admin consent" in Portal, it only grants the *visible* permissions and overwrites the API-granted consent. -**Solution**: -1. Ensure you're adding **Microsoft Graph** delegated permissions (not Application permissions) -2. Contact Microsoft support if these permissions aren't visible in your tenant +**Solution**: Never use Portal admin consent after API method. The API method already grants admin consent (consentType: "AllPrincipals"). ### Validation Errors diff --git a/src/DEVELOPER.md b/src/DEVELOPER.md index 4328ae15..5e1d2030 100644 --- a/src/DEVELOPER.md +++ b/src/DEVELOPER.md @@ -297,12 +297,6 @@ The CLI configures three layers of permissions for agent blueprints: 2. **Required Resource Access** - Portal-visible permissions (Entra ID "API permissions") 3. **Inheritable Permissions** - Blueprint-level permissions that instances inherit automatically -**Unified Configuration:** `SetupHelpers.EnsureResourcePermissionsAsync` handles all three layers plus verification with retry logic (exponential backoff: 2s → 4s → 8s). - -1. **OAuth2 Grants** - Admin consent via Graph API `/oauth2PermissionGrants` -2. **Required Resource Access** - Portal-visible permissions (Entra ID "API permissions") -3. **Inheritable Permissions** - Blueprint-level permissions that instances inherit automatically - **Unified Configuration:** `SetupHelpers.EnsureResourcePermissionsAsync` handles all three layers plus verification with retry logic (exponential backoff: 2s → 4s → 8s → 16s → 32s, max 5 retries). **Per-Resource Tracking:** `ResourceConsent` model tracks inheritance state per resource (Agent 365 Tools, Messaging Bot API, Observability API). Check global status with `config.IsInheritanceConfigured()`. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs index 4e909a4a..807219b8 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs @@ -245,26 +245,23 @@ public static Command CreateCommand( isPermissionIssue: true); } - if (blueprintCreated) + // CRITICAL: Wait for file system to ensure config file is fully written + // Blueprint creation writes directly to disk and may not be immediately readable + logger.LogInformation("Ensuring configuration file is synchronized..."); + await Task.Delay(2000); // 2 second delay to ensure file write is complete + + // Reload config to get blueprint ID + // Use full path to ensure we're reading from the correct location + var fullConfigPath = Path.GetFullPath(config.FullName); + setupConfig = await configService.LoadAsync(fullConfigPath); + setupResults.BlueprintId = setupConfig.AgentBlueprintId; + + // Validate blueprint ID was properly saved + if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId)) { - // CRITICAL: Wait for file system to ensure config file is fully written - // Blueprint creation writes directly to disk and may not be immediately readable - logger.LogInformation("Ensuring configuration file is synchronized..."); - await Task.Delay(2000); // 2 second delay to ensure file write is complete - - // Reload config to get blueprint ID - // Use full path to ensure we're reading from the correct location - var fullConfigPath = Path.GetFullPath(config.FullName); - setupConfig = await configService.LoadAsync(fullConfigPath); - setupResults.BlueprintId = setupConfig.AgentBlueprintId; - - // Validate blueprint ID was properly saved - if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId)) - { - throw new SetupValidationException( - "Blueprint creation completed but AgentBlueprintId was not saved to configuration. " + - "This is required for the next steps (MCP permissions and Bot permissions)."); - } + throw new SetupValidationException( + "Blueprint creation completed but AgentBlueprintId was not saved to configuration. " + + "This is required for the next steps (MCP permissions and Bot permissions)."); } } catch (Agent365Exception blueprintEx) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs index 4693d36a..7e762299 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs @@ -41,7 +41,7 @@ public static Task> ValidateAsync( { errors.Add("clientAppId is required in configuration"); errors.Add("Please configure a custom client app in your tenant with required permissions"); - errors.Add("See docs/guides/custom-client-app-registration.md for setup instructions"); + errors.Add($"See {ConfigConstants.Agent365CliDocumentationUrl} for setup instructions"); } return Task.FromResult(errors); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs index 5d7882d9..95cd27b8 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs @@ -23,6 +23,11 @@ public static class ConfigConstants /// public const string ExampleConfigFileName = "a365.config.example.json"; + /// + /// Microsoft Learn documentation URL for Agent 365 CLI setup and usage + /// + public const string Agent365CliDocumentationUrl = "https://learn.microsoft.com/en-us/microsoft-agent-365/developer/agent-365-cli"; + /// /// Production Agent 365 Tools Discover endpoint URL /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs index b1cb5943..81d72eae 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ClientAppValidationException.cs @@ -42,7 +42,7 @@ public static ClientAppValidationException AppNotFound(string clientAppId, strin "Verify the clientAppId in your a365.config.json is correct", "Check you're using the Application (client) ID, not the Object ID", "Ensure the app is registered in the correct tenant", - "Follow the setup guide: docs/guides/custom-client-app-registration.md" + $"Follow the setup guide: {ConfigConstants.Agent365CliDocumentationUrl}" }, context: new Dictionary { @@ -70,7 +70,7 @@ public static ClientAppValidationException MissingPermissions( "Navigate to API permissions", "Add the missing Microsoft Graph delegated permissions", "Grant admin consent after adding permissions", - "See detailed guide: docs/guides/custom-client-app-registration.md#step-3-add-api-permissions" + $"See detailed guide: {ConfigConstants.Agent365CliDocumentationUrl}" }, context: new Dictionary { @@ -98,7 +98,7 @@ public static ClientAppValidationException MissingAdminConsent(string clientAppI "Click 'Grant admin consent for [Your Tenant]'", "Confirm the consent dialog", "Wait a few seconds for consent to propagate", - "See detailed guide: docs/guides/custom-client-app-registration.md#step-4-grant-admin-consent" + $"See detailed guide: {ConfigConstants.Agent365CliDocumentationUrl}" }, context: new Dictionary { @@ -128,7 +128,7 @@ public static ClientAppValidationException ValidationFailed( "Check the error details above", "Ensure you are logged in with 'az login'", "Verify your client app configuration in Azure Portal", - "See setup guide: docs/guides/custom-client-app-registration.md" + $"See setup guide: {ConfigConstants.Agent365CliDocumentationUrl}" }, context: context); } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs index 1da5ec74..7e775e67 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs @@ -28,7 +28,7 @@ public List Validate() if (string.IsNullOrWhiteSpace(TenantId)) errors.Add("tenantId is required."); if (string.IsNullOrWhiteSpace(ClientAppId)) { - errors.Add("clientAppId is required. This must be a client app you create in your tenant with specific permissions. See docs/guides/custom-client-app-registration.md for setup instructions."); + errors.Add($"clientAppId is required. This must be a client app you create in your tenant with specific permissions. See {ConfigConstants.Agent365CliDocumentationUrl} for setup instructions."); } else { @@ -128,13 +128,12 @@ private static void ValidateGuid(string value, string fieldName, List er /// /// Client Application ID for interactive authentication with Microsoft Graph. - /// This must be a client app registration you create in your Entra ID tenant with the following delegated permissions: - /// - Application.ReadWrite.All - /// - AgentIdentityBlueprint.ReadWrite.All - /// - DelegatedPermissionGrant.ReadWrite.All - /// - Directory.Read.All + /// This must be a client app registration you create in your Entra ID tenant. + /// + /// Required delegated permissions are defined in . /// All permissions require admin consent. - /// See docs/guides/custom-client-app-registration.md for setup instructions. + /// + /// For setup instructions, see the Agent 365 CLI documentation at . /// [JsonPropertyName("clientAppId")] public string ClientAppId { get; init; } = string.Empty; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs index 000fb6eb..6fdc3960 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs @@ -115,7 +115,7 @@ public async Task ValidateClientAppAsync( return ValidationResult.Failure( ValidationFailureType.MissingPermissions, $"Client app is missing required delegated permissions: {string.Join(", ", missingPermissions)}", - "Please add these permissions as DELEGATED (not Application) in Azure Portal > App Registrations > API permissions\nSee: https://github.com/microsoft/Agent365-devTools/blob/main/docs/guides/custom-client-app-registration.md"); + $"Please add these permissions as DELEGATED (not Application) in Azure Portal > App Registrations > API permissions\nSee: {ConfigConstants.Agent365CliDocumentationUrl}"); } // Step 5: Verify admin consent @@ -245,17 +245,9 @@ private async Task> ValidatePermissionsConfiguredAsync( } // Find Microsoft Graph resource in required permissions - JsonObject? graphResource = null; - foreach (var resource in appInfo.RequiredResourceAccess) - { - var resourceObj = resource?.AsObject(); - var resourceAppId = resourceObj?["resourceAppId"]?.GetValue(); - if (resourceAppId == AuthenticationConstants.MicrosoftGraphResourceAppId) - { - graphResource = resourceObj; - break; - } - } + var graphResource = appInfo.RequiredResourceAccess + .Select(r => r?.AsObject()) + .FirstOrDefault(obj => obj?["resourceAppId"]?.GetValue() == AuthenticationConstants.MicrosoftGraphResourceAppId); if (graphResource == null) { @@ -269,18 +261,16 @@ private async Task> ValidatePermissionsConfiguredAsync( } // Build set of configured permission IDs - var configuredPermissionIds = new HashSet(); - foreach (var access in resourceAccess) - { - var accessObj = access?.AsObject(); - var permissionId = accessObj?["id"]?.GetValue(); - var permissionType = accessObj?["type"]?.GetValue(); - - if (permissionType == "Scope" && !string.IsNullOrWhiteSpace(permissionId)) + var configuredPermissionIds = resourceAccess + .Select(access => access?.AsObject()) + .Select(accessObj => new { - configuredPermissionIds.Add(permissionId); - } - } + PermissionId = accessObj?["id"]?.GetValue(), + PermissionType = accessObj?["type"]?.GetValue() + }) + .Where(x => x.PermissionType == "Scope" && !string.IsNullOrWhiteSpace(x.PermissionId)) + .Select(x => x.PermissionId!) + .ToHashSet(); // Resolve ALL permission IDs dynamically from Microsoft Graph // This ensures compatibility across different tenants and API versions @@ -348,17 +338,15 @@ private async Task> ResolvePermissionIdsAsync(string } // Build map of all available permissions (name -> GUID) - foreach (var scopeNode in oauth2PermissionScopes) - { - var scopeObj = scopeNode?.AsObject(); - var scopeValue = scopeObj?["value"]?.GetValue(); - var scopeId = scopeObj?["id"]?.GetValue(); - - if (!string.IsNullOrWhiteSpace(scopeValue) && !string.IsNullOrWhiteSpace(scopeId)) + permissionNameToIdMap = oauth2PermissionScopes + .Select(scopeNode => scopeNode?.AsObject()) + .Select(scopeObj => new { - permissionNameToIdMap[scopeValue] = scopeId; - } - } + Value = scopeObj?["value"]?.GetValue(), + Id = scopeObj?["id"]?.GetValue() + }) + .Where(x => !string.IsNullOrWhiteSpace(x.Value) && !string.IsNullOrWhiteSpace(x.Id)) + .ToDictionary(x => x.Value!, x => x.Id!); _logger.LogDebug("Retrieved {Count} permission definitions from Microsoft Graph", permissionNameToIdMap.Count); } @@ -425,27 +413,24 @@ private async Task ValidateAdminConsentAsync(string clientAppI } // Check if there's a grant for Microsoft Graph with required scopes - bool hasGraphGrant = false; - foreach (var grant in grants) - { - var grantObj = grant?.AsObject(); - var scope = grantObj?["scope"]?.GetValue(); - - if (!string.IsNullOrWhiteSpace(scope)) + var hasGraphGrant = grants + .Select(grant => grant?.AsObject()) + .Select(grantObj => grantObj?["scope"]?.GetValue()) + .Where(scope => !string.IsNullOrWhiteSpace(scope)) + .Any(scope => { - var grantedScopes = scope.Split(' ', StringSplitOptions.RemoveEmptyEntries); + var grantedScopes = scope!.Split(' ', StringSplitOptions.RemoveEmptyEntries); var foundPermissions = AuthenticationConstants.RequiredClientAppPermissions .Intersect(grantedScopes, StringComparer.OrdinalIgnoreCase) .ToList(); if (foundPermissions.Count > 0) { - hasGraphGrant = true; _logger.LogInformation("Admin consent verified for {Count} permissions", foundPermissions.Count); - break; + return true; } - } - } + return false; + }); if (!hasGraphGrant) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs index b0c510ec..f6ec7cef 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs @@ -733,7 +733,7 @@ private string GetUsageLocationFromAccount(AzureAccountInfo accountInfo) Console.WriteLine(" 3. Required API permissions not added"); Console.WriteLine(" 4. Admin consent not granted"); Console.WriteLine(); - Console.WriteLine("See: https://github.com/microsoft/Agent365-devTools/blob/main/docs/guides/custom-client-app-registration.md"); + Console.WriteLine($"See: {ConfigConstants.Agent365CliDocumentationUrl}"); return null; } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs index 575c581d..d92c665b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs @@ -46,7 +46,7 @@ public InteractiveGraphAuthService( { throw new ArgumentNullException( nameof(clientAppId), - "Client App ID is required. Configure clientAppId in a365.config.json. See docs/guides/custom-client-app-registration.md for setup instructions."); + $"Client App ID is required. Configure clientAppId in a365.config.json. See {ConfigConstants.Agent365CliDocumentationUrl} for setup instructions."); } if (!Guid.TryParse(clientAppId, out _)) @@ -75,7 +75,7 @@ public Task GetAuthenticatedGraphClientAsync( } _logger.LogInformation("Attempting to authenticate to Microsoft Graph interactively..."); - _logger.LogInformation("This requires Application.ReadWrite.All and AgentIdentityBlueprint.ReadWrite.All permissions for Agent Blueprint operations."); + _logger.LogInformation("This requires permissions defined in AuthenticationConstants.RequiredClientAppPermissions for Agent Blueprint operations."); _logger.LogInformation(""); _logger.LogInformation("IMPORTANT: A browser window will open for authentication."); _logger.LogInformation("Please sign in with an account that has Global Administrator or similar privileges."); @@ -100,9 +100,9 @@ public Task GetAuthenticatedGraphClientAsync( }); _logger.LogInformation("Opening browser for authentication..."); - _logger.LogInformation("IMPORTANT: You must grant consent for the following permissions:"); - _logger.LogInformation(" - Application.ReadWrite.All (for creating applications and blueprints)"); - _logger.LogInformation(" - AgentIdentityBlueprint.ReadWrite.All (for configuring inheritable permissions)"); + _logger.LogInformation("IMPORTANT: You must grant consent for all required permissions."); + _logger.LogInformation("Required permissions are defined in AuthenticationConstants.RequiredClientAppPermissions."); + _logger.LogInformation($"See {ConfigConstants.Agent365CliDocumentationUrl} for the complete list."); _logger.LogInformation(""); // Create GraphServiceClient with the credential @@ -220,7 +220,7 @@ private void ThrowInsufficientPermissionsException(Exception innerException) _logger.LogError("Authentication failed - insufficient permissions"); throw new GraphApiException( "Graph authentication", - "Insufficient permissions - you must be a Global Administrator or have Application.ReadWrite.All permission", + "Insufficient permissions - you must be a Global Administrator or have all required permissions defined in AuthenticationConstants.RequiredClientAppPermissions", isPermissionIssue: true); } } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs index 0664a546..951467d1 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs @@ -251,7 +251,8 @@ public async Task CreateBlueprintImplementation_WithMissingDisplayName_ShouldThr isSetupAll: false, _mockConfigService, _mockBotConfigurator, - _mockPlatformDetector); + _mockPlatformDetector, + _mockGraphApiService); // Assert - Should return false when consent service fails result.Should().BeFalse(); @@ -285,7 +286,8 @@ public async Task CreateBlueprintImplementation_WithAzureValidationFailure_Shoul isSetupAll: false, _mockConfigService, _mockBotConfigurator, - _mockPlatformDetector); + _mockPlatformDetector, + _mockGraphApiService); // Assert result.Should().BeFalse(); @@ -480,7 +482,8 @@ public async Task CreateBlueprintImplementation_ShouldLogProgressMessages() isSetupAll: false, _mockConfigService, _mockBotConfigurator, - _mockPlatformDetector); + _mockPlatformDetector, + _mockGraphApiService); // Assert result.Should().BeFalse(); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SubcommandValidationTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SubcommandValidationTests.cs index f213268c..1cb83061 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SubcommandValidationTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SubcommandValidationTests.cs @@ -170,7 +170,7 @@ public async Task BlueprintSubcommand_WithMissingClientAppId_FailsValidation() // Assert errors.Should().HaveCountGreaterThan(0); errors.Should().Contain(e => e.Contains("clientAppId")); - errors.Should().Contain(e => e.Contains("custom-client-app-registration.md")); + errors.Should().Contain(e => e.Contains("learn.microsoft.com")); } #endregion diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs index a828bb23..a201da8d 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Exceptions/ClientAppValidationExceptionTests.cs @@ -47,7 +47,7 @@ public void AppNotFound_IncludesDocumentationReference() // Assert exception.MitigationSteps.Should().Contain(s => - s.Contains("docs/guides/custom-client-app-registration.md")); + s.Contains(ConfigConstants.Agent365CliDocumentationUrl)); } #endregion @@ -112,7 +112,7 @@ public void MissingPermissions_IncludesDetailedSetupInstructions() exception.MitigationSteps.Should().Contain(s => s.Contains("API permissions")); exception.MitigationSteps.Should().Contain(s => s.Contains("admin consent")); exception.MitigationSteps.Should().Contain(s => - s.Contains("docs/guides/custom-client-app-registration.md")); + s.Contains(ConfigConstants.Agent365CliDocumentationUrl)); } #endregion @@ -147,7 +147,7 @@ public void MissingAdminConsent_IncludesConsentGrantInstructions() exception.MitigationSteps.Should().Contain(s => s.Contains("Grant admin consent")); exception.MitigationSteps.Should().Contain(s => s.Contains("Confirm the consent dialog")); exception.MitigationSteps.Should().Contain(s => - s.Contains("docs/guides/custom-client-app-registration.md#step-4-grant-admin-consent")); + s.Contains(ConfigConstants.Agent365CliDocumentationUrl)); } #endregion @@ -216,7 +216,7 @@ public void ValidationFailed_IncludesGenericMitigationSteps() exception.MitigationSteps.Should().Contain(s => s.Contains("az login")); exception.MitigationSteps.Should().Contain(s => s.Contains("Azure Portal")); exception.MitigationSteps.Should().Contain(s => - s.Contains("docs/guides/custom-client-app-registration.md")); + s.Contains(ConfigConstants.Agent365CliDocumentationUrl)); } #endregion diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Models/Agent365ConfigTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Models/Agent365ConfigTests.cs index 99d84aae..c0a38964 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Models/Agent365ConfigTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Models/Agent365ConfigTests.cs @@ -488,7 +488,7 @@ public void Validate_WithMissingClientAppId_ReturnsError() // Assert errors.Should().Contain(e => e.Contains("clientAppId is required")); - errors.Should().Contain(e => e.Contains("custom-client-app-registration.md")); + errors.Should().Contain(e => e.Contains("learn.microsoft.com")); } [Fact] From 87e78d73166ec427459498d77e3ad9706075efe5 Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Fri, 5 Dec 2025 16:07:07 -0800 Subject: [PATCH 4/7] Refactor Agent365 app registration guide Streamlined and clarified the custom client app registration guide: - Emphasized the use of Delegated permissions over Application. - Added a Quick Setup section with step-by-step instructions. - Updated Prerequisites to address common mistakes and clarify roles. - Restructured Configure API Permissions with detailed guidance. - Replaced Azure CLI instructions with Graph Explorer for beta APIs. - Added Troubleshooting for permission type errors and beta issues. - Highlighted security and compliance benefits of Delegated permissions. These changes improve clarity, reduce redundancy, and enhance usability. --- docs/guides/custom-client-app-registration.md | 222 ++++++++---------- 1 file changed, 95 insertions(+), 127 deletions(-) diff --git a/docs/guides/custom-client-app-registration.md b/docs/guides/custom-client-app-registration.md index c980b00e..7ad8f6f2 100644 --- a/docs/guides/custom-client-app-registration.md +++ b/docs/guides/custom-client-app-registration.md @@ -2,36 +2,27 @@ ## Overview -The Agent365 CLI requires a custom client app registration in your Entra ID tenant to authenticate and manage Agent Identity Blueprints. This guide covers the Agent365-specific requirements. +The Agent365 CLI requires a custom client app registration in your Entra ID tenant to authenticate and manage Agent Identity Blueprints. -**For general app registration steps**, see Microsoft's official documentation: -- [Quickstart: Register an application](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) -- [Grant tenant-wide admin consent](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/grant-admin-consent) - -### CRITICAL: Delegated vs Application Permissions - -**You MUST use Delegated permissions (NOT Application permissions)** for all five required permissions. - -| Permission Type | When to Use | How Agent365 Uses It | -|----------------|-------------|---------------------| -| **Delegated** ("Scope") | User signs in interactively | **Agent365 CLI uses this** - You sign in, CLI acts on your behalf | -| **Application** ("Role") | Service runs without user | **Don't use** - For background services/daemons only | +## Quick Setup -**Why Delegated?** -- You sign in interactively (`az login`, browser authentication) -- CLI performs actions **as you** (audit trails show your identity) -- More secure - limited by your actual permissions -- Ensures accountability and compliance +### Prerequisites -**Common mistake**: Adding `Directory.Read.All` as **Application** instead of **Delegated**. +**To register the app** (Steps 1-2): +- Any developer with basic Entra ID access can register an application -## Quick Setup +**To add permissions and grant consent** (Steps 3-4): +- **One of these admin roles** is required: + - **Application Administrator** (recommended - can manage app registrations and grant consent) + - **Cloud Application Administrator** (can manage app registrations and grant consent) + - **Global Administrator** (has all permissions, but not required) -### Prerequisites +> **Don't have admin access?** You can complete Steps 1-2 yourself, then ask your tenant administrator to complete Steps 3-4. Provide them: +> - Your **Application (client) ID** from Step 2 +> - A link to this guide: [Custom Client App Registration](#3-configure-api-permissions) -- **Azure role**: Global Administrator or Application Administrator -- **Azure CLI**: Installed and signed in (`az login`) -- **Tenant access**: Entra ID tenant where you'll deploy Agent365 +**Optional**: +- Azure CLI (only needed if you prefer command-line automation instead of Graph Explorer) ### 1. Register Application @@ -54,6 +45,8 @@ From the app's **Overview** page, copy the **Application (client) ID** (GUID for ### 3. Configure API Permissions +> **⚠️ Admin privileges required for this step and Step 4.** If you're a developer without admin access, send your **Application (client) ID** from Step 2 to your tenant administrator and have them complete Steps 3-4. + **Choose Your Method**: The two `AgentIdentityBlueprint.*` permissions are beta APIs and may not be visible in the Azure Portal UI. You can either: - **Option A**: Use Azure Portal for all permissions (if beta permissions are visible) - **Option B**: Use Microsoft Graph API to add all permissions (recommended if beta permissions not visible) @@ -66,6 +59,8 @@ From the app's **Overview** page, copy the **Application (client) ID** (GUID for 2. Click **Add a permission** → **Microsoft Graph** → **Delegated permissions** 3. Search for and add these 5 permissions: +> **Important**: You MUST use **Delegated permissions** (NOT Application permissions). The CLI authenticates interactively - you sign in, and it acts on your behalf. See [Troubleshooting](#wrong-permission-type-delegated-vs-application) if you accidentally add Application permissions. + | Permission | Purpose | |-----------|---------| | `Application.ReadWrite.All` | Create and manage applications and Agent Blueprints | @@ -74,117 +69,65 @@ From the app's **Overview** page, copy the **Application (client) ID** (GUID for | `DelegatedPermissionGrant.ReadWrite.All` | Grant permissions for agent blueprints | | `Directory.Read.All` | Read directory data for validation | -4. Click **Grant admin consent for [Your Tenant]** (requires Global Admin or Application Admin role) +4. Click **Grant admin consent for [Your Tenant]** + - **Why is this required?** Agent Identity Blueprints are tenant-wide resources that multiple users and applications can reference. Without tenant-wide consent, the CLI will fail during authentication. + - **What if it fails?** You need Application Administrator, Cloud Application Administrator, or Global Administrator role. Ask your tenant admin for help. 5. Verify all permissions show green checkmarks under "Status" -**Important**: Use **Delegated permissions** (NOT Application permissions). The CLI requires delegated permissions because you sign in interactively. - If the beta permissions (`AgentIdentityBlueprint.*`) are **not visible**, proceed to **Option B** below. #### Option B: Microsoft Graph API (For Beta Permissions) **Use this method if `AgentIdentityBlueprint.*` permissions are not visible in Azure Portal**. -##### Step 1: Add permissions to app manifest - -First, ensure you're signed in with admin privileges: - -```bash -az login -``` - -Update the app registration's `requiredResourceAccess` to include all 5 permissions: - -```bash -# Replace YOUR_CLIENT_APP_ID with your Application (client) ID from Step 2 -az ad app update --id YOUR_CLIENT_APP_ID --required-resource-accesses @- < **Permission ID mapping**: -> - `1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9` = `Application.ReadWrite.All` -> - `e1fe6dd8-ba31-4d61-89e7-88639da4683d` = `Directory.Read.All` -> - `06b708a9-e830-4db3-a914-8e69da51d44f` = `DelegatedPermissionGrant.ReadWrite.All` -> - `8f6a01e7-0391-4ee5-aa22-a3af122cef27` = `AgentIdentityBlueprint.ReadWrite.All` -> - `06da0dbc-49e2-44d2-8312-53f166ab848a` = `AgentIdentityBlueprint.UpdateAuthProperties.All` - -##### Step 2: Create service principal (if not exists) - -```bash -# This creates the enterprise app / service principal for your app registration -az ad sp create --id YOUR_CLIENT_APP_ID -``` - -If the service principal already exists, this command will return its details (safe to run). - -##### Step 3: Grant admin consent via API - -Get the service principal object ID: - -```bash -SP_OBJECT_ID=$(az ad sp list --filter "appId eq 'YOUR_CLIENT_APP_ID'" --query "[0].id" -o tsv) -echo "Service Principal Object ID: $SP_OBJECT_ID" -``` - -Get Microsoft Graph service principal ID: - -```bash -GRAPH_SP_ID=$(az ad sp list --filter "appId eq '00000003-0000-0000-c000-000000000000'" --query "[0].id" -o tsv) -echo "Microsoft Graph SP ID: $GRAPH_SP_ID" -``` - -Create the admin consent grant: - -```bash -az rest --method POST \ - --url "https://graph.microsoft.com/v1.0/oauth2PermissionGrants" \ - --body "{ - \"clientId\": \"$SP_OBJECT_ID\", - \"consentType\": \"AllPrincipals\", - \"principalId\": null, - \"resourceId\": \"$GRAPH_SP_ID\", - \"scope\": \"Application.ReadWrite.All Directory.Read.All DelegatedPermissionGrant.ReadWrite.All AgentIdentityBlueprint.ReadWrite.All AgentIdentityBlueprint.UpdateAuthProperties.All\" - }" -``` - -**Verification**: Run this command to verify the grant was created: - -```bash -az rest --method GET \ - --url "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?\$filter=clientId eq '$SP_OBJECT_ID'" \ - --query "value[0].scope" -o tsv -``` - -You should see all 5 permission names listed. - -**Critical**: **Do NOT click "Grant admin consent" in Azure Portal** after using the API method. This will remove the beta permissions in tenants where they're not visible in the UI. +1. **Open Graph Explorer**: Go to https://developer.microsoft.com/graph/graph-explorer +2. **Sign in** with your admin account (Application Administrator or Cloud Application Administrator) +3. **Grant admin consent using Graph API**: + + **Step 1**: Get your service principal ID and Graph resource ID: + + ``` + GET https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq 'YOUR_CLIENT_APP_ID'&$select=id + ``` + + Copy the `id` value (this is your `SP_OBJECT_ID`) + + ``` + GET https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '00000003-0000-0000-c000-000000000000'&$select=id + ``` + + Copy the `id` value (this is your `GRAPH_RESOURCE_ID`) + + **Step 2**: Grant admin consent with all 5 permissions: + + Change method to **POST** and use this URL: + ``` + POST https://graph.microsoft.com/v1.0/oauth2PermissionGrants + ``` + + **Request Body**: + ```json + { + "clientId": "SP_OBJECT_ID_FROM_STEP1", + "consentType": "AllPrincipals", + "principalId": null, + "resourceId": "GRAPH_RESOURCE_ID_FROM_STEP1", + "scope": "Application.ReadWrite.All Directory.Read.All DelegatedPermissionGrant.ReadWrite.All AgentIdentityBlueprint.ReadWrite.All AgentIdentityBlueprint.UpdateAuthProperties.All" + } + ``` + + Click **Run query** - you should get a `201 Created` response. + + **Verification**: Query the grant to confirm: + ``` + GET https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId eq 'SP_OBJECT_ID_FROM_STEP1' + ``` + + The `scope` field should contain all 5 permission names. + +**Important**: +- The `consentType: "AllPrincipals"` in the request body grants **tenant-wide admin consent**, which is required because Agent Identity Blueprints are tenant-wide resources. +- **Do NOT click "Grant admin consent" in Azure Portal** after using this method, as it will remove the beta permissions. ### 4. Use in Agent365 CLI @@ -201,6 +144,31 @@ The CLI automatically validates: ## Troubleshooting +### Wrong Permission Type (Delegated vs Application) + +**Symptom**: CLI fails with authentication errors or permission denied errors. + +**Root cause**: You added **Application permissions** instead of **Delegated permissions**. + +| Permission Type | When to Use | How Agent365 Uses It | +|----------------|-------------|---------------------| +| **Delegated** ("Scope") | User signs in interactively | **Agent365 CLI uses this** - You sign in, CLI acts on your behalf | +| **Application** ("Role") | Service runs without user | **Don't use** - For background services/daemons only | + +**Why Delegated?** +- You sign in interactively (browser authentication) +- CLI performs actions **as you** (audit trails show your identity) +- More secure - limited by your actual permissions +- Ensures accountability and compliance + +**Solution**: +1. Go to Azure Portal → App registrations → Your app → API permissions +2. **Remove** any Application permissions (these show as "Admin" in the Type column) +3. **Add** the same permissions as **Delegated** permissions +4. Grant admin consent again + +**Common mistake**: Adding `Directory.Read.All` as **Application** instead of **Delegated**. + ### Beta Permissions Disappear After Portal Admin Consent **Symptom**: You used the API method (Option B) to add beta permissions, but they disappeared after clicking "Grant admin consent" in Azure Portal. From 8ea0181bd62da0e73d86987fd6d73607e1b06c98 Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Fri, 5 Dec 2025 18:44:29 -0800 Subject: [PATCH 5/7] Add verbose logging, update permissions, and enhance UX Introduced a `--verbose` option to `CleanupCommand` for detailed logging, ensuring consistent behavior across all subcommands. Renamed `EnsureAgentApplicationCreateConsentAsync` to `EnsureBlueprintPermissionGrantAsync` and updated it to use the `AgentIdentityBlueprint.ReadWrite.All` permission, replacing the deprecated `AgentApplication.Create` permission. Enhanced `CleanConsoleFormatter` to support `Debug` and `Trace` log levels, aligning with Azure CLI patterns. Updated error handling and logging for better clarity and context. Refactored retry logic and test cases to use the new method. Updated documentation and examples to reflect these changes, including adding a `clientAppId` field to the example config file. These changes improve functionality, user experience, and maintainability. --- docs/guides/custom-client-app-registration.md | 12 +++--- .../Commands/CleanupCommand.cs | 36 ++++++++++++++---- .../SetupSubcommands/BlueprintSubcommand.cs | 2 +- .../Services/DelegatedConsentService.cs | 25 ++++++------- .../Services/Helpers/CleanConsoleFormatter.cs | 37 +++++++++++++++++-- .../Commands/BlueprintSubcommandTests.cs | 2 +- src/a365.config.example.json | 1 + 7 files changed, 84 insertions(+), 31 deletions(-) diff --git a/docs/guides/custom-client-app-registration.md b/docs/guides/custom-client-app-registration.md index 7ad8f6f2..2eff0006 100644 --- a/docs/guides/custom-client-app-registration.md +++ b/docs/guides/custom-client-app-registration.md @@ -63,9 +63,9 @@ From the app's **Overview** page, copy the **Application (client) ID** (GUID for | Permission | Purpose | |-----------|---------| -| `Application.ReadWrite.All` | Create and manage applications and Agent Blueprints | | `AgentIdentityBlueprint.ReadWrite.All` | Manage Agent Blueprint configurations (beta API) | | `AgentIdentityBlueprint.UpdateAuthProperties.All` | Update Agent Blueprint inheritable permissions (beta API) | +| `Application.ReadWrite.All` | Create and manage applications and Agent Blueprints | | `DelegatedPermissionGrant.ReadWrite.All` | Grant permissions for agent blueprints | | `Directory.Read.All` | Read directory data for validation | @@ -87,13 +87,13 @@ If the beta permissions (`AgentIdentityBlueprint.*`) are **not visible**, procee **Step 1**: Get your service principal ID and Graph resource ID: ``` - GET https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq 'YOUR_CLIENT_APP_ID'&$select=id + https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq 'YOUR_CLIENT_APP_ID'&$select=id ``` Copy the `id` value (this is your `SP_OBJECT_ID`) ``` - GET https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '00000003-0000-0000-c000-000000000000'&$select=id + https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '00000003-0000-0000-c000-000000000000'&$select=id ``` Copy the `id` value (this is your `GRAPH_RESOURCE_ID`) @@ -102,7 +102,7 @@ If the beta permissions (`AgentIdentityBlueprint.*`) are **not visible**, procee Change method to **POST** and use this URL: ``` - POST https://graph.microsoft.com/v1.0/oauth2PermissionGrants + https://graph.microsoft.com/v1.0/oauth2PermissionGrants ``` **Request Body**: @@ -119,8 +119,10 @@ If the beta permissions (`AgentIdentityBlueprint.*`) are **not visible**, procee Click **Run query** - you should get a `201 Created` response. **Verification**: Query the grant to confirm: + + Change method to **GET** and use this URL: ``` - GET https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId eq 'SP_OBJECT_ID_FROM_STEP1' + https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId eq 'SP_OBJECT_ID_FROM_STEP1' ``` The `scope` field should contain all 5 permission names. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs index 0fa2a593..1abb50ab 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs @@ -29,13 +29,18 @@ public static Command CreateCommand( ArgumentHelpName = "file" }; + var verboseOption = new Option( + new[] { "--verbose", "-v" }, + description: "Enable verbose logging"); + cleanupCommand.AddOption(configOption); + cleanupCommand.AddOption(verboseOption); // Set default handler for 'a365 cleanup' (without subcommand) - cleans up everything - cleanupCommand.SetHandler(async (configFile) => + cleanupCommand.SetHandler(async (configFile, verbose) => { await ExecuteAllCleanupAsync(logger, configService, botConfigurator, executor, graphApiService, configFile); - }, configOption); + }, configOption, verboseOption); // Add subcommands for granular control cleanupCommand.AddCommand(CreateBlueprintCleanupCommand(logger, configService, executor, graphApiService)); @@ -60,9 +65,14 @@ private static Command CreateBlueprintCleanupCommand( ArgumentHelpName = "file" }; + var verboseOption = new Option( + new[] { "--verbose", "-v" }, + description: "Enable verbose logging"); + command.AddOption(configOption); + command.AddOption(verboseOption); - command.SetHandler(async (configFile) => + command.SetHandler(async (configFile, verbose) => { try { @@ -127,7 +137,7 @@ private static Command CreateBlueprintCleanupCommand( { logger.LogError(ex, "Blueprint cleanup failed"); } - }, configOption); + }, configOption, verboseOption); return command; } @@ -147,9 +157,14 @@ private static Command CreateAzureCleanupCommand( ArgumentHelpName = "file" }; + var verboseOption = new Option( + new[] { "--verbose", "-v" }, + description: "Enable verbose logging"); + command.AddOption(configOption); + command.AddOption(verboseOption); - command.SetHandler(async (configFile) => + command.SetHandler(async (configFile, verbose) => { try { @@ -248,7 +263,7 @@ private static Command CreateAzureCleanupCommand( { logger.LogError(ex, "Azure cleanup failed with exception"); } - }, configOption); + }, configOption, verboseOption); return command; } @@ -267,9 +282,14 @@ private static Command CreateInstanceCleanupCommand( ArgumentHelpName = "file" }; + var verboseOption = new Option( + new[] { "--verbose", "-v" }, + description: "Enable verbose logging"); + command.AddOption(configOption); + command.AddOption(verboseOption); - command.SetHandler(async (configFile) => + command.SetHandler(async (configFile, verbose) => { try { @@ -361,7 +381,7 @@ private static Command CreateInstanceCleanupCommand( { logger.LogError(ex, "Instance cleanup failed: {Message}", ex.Message); } - }, configOption); + }, configOption, verboseOption); return command; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs index 7e762299..d114eda6 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs @@ -318,7 +318,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( var success = await retryHelper.ExecuteWithRetryAsync( async ct => { - return await delegatedConsentService.EnsureAgentApplicationCreateConsentAsync( + return await delegatedConsentService.EnsureBlueprintPermissionGrantAsync( clientAppId, tenantId, ct); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs index e6e50745..229d29b1 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs @@ -10,17 +10,16 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services; /// -/// C# implementation of DelegatedAgentApplicationCreateConsent.ps1 -/// Ensures oauth2PermissionGrant exists for Microsoft Graph Command Line Tools -/// to enable AgentApplication.Create permission +/// Ensures oauth2PermissionGrant exists for the custom client application. +/// Validates that AgentIdentityBlueprint.ReadWrite.All permission is granted, which is required for creating and managing Agent Blueprints. /// public sealed class DelegatedConsentService { private readonly ILogger _logger; private readonly GraphApiService _graphService; - // Constants from PowerShell script - private const string TargetScope = "AgentApplication.Create Application.ReadWrite.All"; + // Constants + private const string TargetScope = "AgentIdentityBlueprint.ReadWrite.All"; private const string AllPrincipalsConsentType = "AllPrincipals"; public DelegatedConsentService( @@ -32,22 +31,22 @@ public DelegatedConsentService( } /// - /// Ensures AgentApplication.Create permission is granted to the custom client application - /// This is required before creating Agent Blueprints + /// Ensures AgentIdentityBlueprint.ReadWrite.All permission is granted to the custom client application. + /// Required for creating and managing Agent Blueprints. /// /// Application ID of the custom client app from configuration /// Tenant ID where the permission grant will be created /// Cancellation token /// True if grant was created or updated successfully - public async Task EnsureAgentApplicationCreateConsentAsync( + public async Task EnsureBlueprintPermissionGrantAsync( string callingAppId, string tenantId, CancellationToken cancellationToken = default) { try { - _logger.LogInformation("==> Ensuring AgentApplication.Create permission for Microsoft Graph Command Line Tools"); - _logger.LogInformation(" Calling App ID: {AppId}", callingAppId); + _logger.LogInformation("==> Ensuring AgentIdentityBlueprint.ReadWrite.All permission for custom client app"); + _logger.LogInformation(" Client App ID: {AppId}", callingAppId); _logger.LogInformation(" Tenant ID: {TenantId}", tenantId); _logger.LogInformation(" Required Scope: {Scope}", TargetScope); @@ -76,8 +75,8 @@ public async Task EnsureAgentApplicationCreateConsentAsync( using var httpClient = new HttpClient(); httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", graphToken); - // Step 1: Get or create service principal for calling app (Microsoft Graph Command Line Tools) - _logger.LogInformation(" Looking up service principal for calling app"); + // Step 1: Get or create service principal for custom client app + _logger.LogInformation(" Looking up service principal for client app (ID: {AppId})", callingAppId); var clientSp = await GetOrCreateServicePrincipalAsync(httpClient, callingAppId, tenantId, cancellationToken); if (clientSp == null) { @@ -134,7 +133,7 @@ public async Task EnsureAgentApplicationCreateConsentAsync( } catch (Exception ex) { - _logger.LogError(ex, "Failed to ensure AgentApplication.Create consent: {Message}", ex.Message); + _logger.LogError(ex, "Failed to ensure AgentIdentityBlueprint.ReadWrite.All consent: {Message}", ex.Message); // Check if this looks like a CAE error if (ex.Message.Contains("TokenIssuedBeforeRevocationTimestamp", StringComparison.OrdinalIgnoreCase) || diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/CleanConsoleFormatter.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/CleanConsoleFormatter.cs index 00ebe060..dac31d7c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/CleanConsoleFormatter.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/CleanConsoleFormatter.cs @@ -11,7 +11,7 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; /// /// Custom console formatter that outputs clean messages without timestamps or category names. /// Follows Azure CLI output patterns for user-friendly CLI experience. -/// Errors are displayed in red, warnings in yellow, info is plain text. +/// Errors are displayed in red, warnings in yellow, info is plain text, debug/trace in dark gray. /// public sealed class CleanConsoleFormatter : ConsoleFormatter { @@ -41,7 +41,7 @@ public override void Write( // Check if we're writing to actual console (supports colors) bool isConsole = !Console.IsOutputRedirected; - // Azure CLI pattern: red for errors, yellow for warnings, no color for info + // Azure CLI pattern: red for errors, yellow for warnings, dark gray for debug/trace, no color for info switch (logEntry.LogLevel) { case LogLevel.Error: @@ -75,7 +75,37 @@ public override void Write( textWriter.WriteLine(message); } break; - default: + case LogLevel.Debug: + if (isConsole) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.Write("[DEBUG] "); + Console.Write(message); + Console.ResetColor(); + Console.WriteLine(); + } + else + { + textWriter.Write("[DEBUG] "); + textWriter.WriteLine(message); + } + break; + case LogLevel.Trace: + if (isConsole) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.Write("[TRACE] "); + Console.Write(message); + Console.ResetColor(); + Console.WriteLine(); + } + else + { + textWriter.Write("[TRACE] "); + textWriter.WriteLine(message); + } + break; + default: // Information textWriter.WriteLine(message); break; } @@ -89,6 +119,7 @@ public override void Write( { LogLevel.Error or LogLevel.Critical => ConsoleColor.Red, LogLevel.Warning => ConsoleColor.Yellow, + LogLevel.Debug or LogLevel.Trace => ConsoleColor.DarkGray, _ => Console.ForegroundColor }; Console.WriteLine(logEntry.Exception); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs index 951467d1..d530e081 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BlueprintSubcommandTests.cs @@ -1056,7 +1056,7 @@ public void DocumentParameterOrder_EnsureDelegatedConsentWithRetriesAsync() // logger); // // The method then calls: - // await delegatedConsentService.EnsureAgentApplicationCreateConsentAsync( + // await delegatedConsentService.EnsureBlueprintPermissionGrantAsync( // clientAppId, // <-- Receives setupConfig.ClientAppId // tenantId, // <-- Receives setupConfig.TenantId // ct); diff --git a/src/a365.config.example.json b/src/a365.config.example.json index b0335bab..5b5bd051 100644 --- a/src/a365.config.example.json +++ b/src/a365.config.example.json @@ -1,5 +1,6 @@ { "tenantId": "00000000-0000-0000-0000-000000000000", + "clientAppId": "your-client-app-id-from-entra-id", "subscriptionId": "00000000-0000-0000-0000-000000000000", "resourceGroup": "your-resource-group-name", "location": "westus", From d3f92820c635e32f441ea0e74fbba40b8f9d723b Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Mon, 8 Dec 2025 09:00:20 -0800 Subject: [PATCH 6/7] Clarify beta permissions setup with Graph API Improve documentation for granting admin consent for beta permissions (`AgentIdentityBlueprint.*`) using the Microsoft Graph API. Add warnings to avoid using Azure Portal's "Grant admin consent" button after API usage, as it can overwrite and delete beta permissions. Update Graph Explorer instructions to include steps for handling permissions errors and clarify the process for retrieving `SP_OBJECT_ID` and `GRAPH_RESOURCE_ID`. Provide guidance on verifying permissions via the `oauth2PermissionGrants` endpoint. Add a detailed explanation of the root cause and solution for disappearing beta permissions when using Azure Portal. Highlight that the API method (`consentType: "AllPrincipals"`) already grants tenant-wide admin consent. Include CLI validation details to ensure proper permissions setup. --- docs/guides/custom-client-app-registration.md | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/docs/guides/custom-client-app-registration.md b/docs/guides/custom-client-app-registration.md index 2eff0006..a219b8ff 100644 --- a/docs/guides/custom-client-app-registration.md +++ b/docs/guides/custom-client-app-registration.md @@ -80,27 +80,31 @@ If the beta permissions (`AgentIdentityBlueprint.*`) are **not visible**, procee **Use this method if `AgentIdentityBlueprint.*` permissions are not visible in Azure Portal**. +> **⚠️ WARNING**: If you use this API method, **do NOT use Azure Portal's "Grant admin consent" button** afterward. The API method grants admin consent automatically, and using the Portal button will **delete your beta permissions**. See [troubleshooting section](#beta-permissions-disappear-after-portal-admin-consent) for details. + 1. **Open Graph Explorer**: Go to https://developer.microsoft.com/graph/graph-explorer 2. **Sign in** with your admin account (Application Administrator or Cloud Application Administrator) 3. **Grant admin consent using Graph API**: **Step 1**: Get your service principal ID and Graph resource ID: + Set method to **GET** and use this URL: ``` https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq 'YOUR_CLIENT_APP_ID'&$select=id ``` - Copy the `id` value (this is your `SP_OBJECT_ID`) + Click **Run query**. If the query fails with a permissions error, click the **Modify permissions** tab, consent to the required permissions, then click **Run query** again. Copy the `id` value (this is your `SP_OBJECT_ID`) + Set method to **GET** and use this URL: ``` https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '00000003-0000-0000-c000-000000000000'&$select=id ``` - Copy the `id` value (this is your `GRAPH_RESOURCE_ID`) + Click **Run query**. If the query fails with a permissions error, click the **Modify permissions** tab, consent to the required permissions, then click **Run query** again. Copy the `id` value (this is your `GRAPH_RESOURCE_ID`) **Step 2**: Grant admin consent with all 5 permissions: - Change method to **POST** and use this URL: + Set method to **POST** and use this URL: ``` https://graph.microsoft.com/v1.0/oauth2PermissionGrants ``` @@ -116,20 +120,18 @@ If the beta permissions (`AgentIdentityBlueprint.*`) are **not visible**, procee } ``` - Click **Run query** - you should get a `201 Created` response. + Click **Run query**. If the query fails with a permissions error (likely DelegatedPermissionGrant.ReadWrite.All), click the **Modify permissions** tab, consent to DelegatedPermissionGrant.ReadWrite.All, then click **Run query** again. You should get a `201 Created` response. **Verification**: Query the grant to confirm: - Change method to **GET** and use this URL: + Set method to **GET** and use this URL: ``` https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId eq 'SP_OBJECT_ID_FROM_STEP1' ``` - The `scope` field should contain all 5 permission names. + Click **Run query**. If the query fails with a permissions error, click the **Modify permissions** tab, consent to the required permissions, then click **Run query** again. The `scope` field should contain all 5 permission names. -**Important**: -- The `consentType: "AllPrincipals"` in the request body grants **tenant-wide admin consent**, which is required because Agent Identity Blueprints are tenant-wide resources. -- **Do NOT click "Grant admin consent" in Azure Portal** after using this method, as it will remove the beta permissions. +> **⚠️ CRITICAL WARNING**: The `consentType: "AllPrincipals"` in the POST request above **already grants tenant-wide admin consent**. **DO NOT click "Grant admin consent" in Azure Portal** after using this API method - doing so will **delete your beta permissions** because the Portal UI cannot see beta permissions and will overwrite your API-granted consent with only the visible permissions. ### 4. Use in Agent365 CLI @@ -177,7 +179,18 @@ The CLI automatically validates: **Root cause**: Azure Portal doesn't show beta permissions in the UI, so when you click "Grant admin consent" in Portal, it only grants the *visible* permissions and overwrites the API-granted consent. -**Solution**: Never use Portal admin consent after API method. The API method already grants admin consent (consentType: "AllPrincipals"). +**Why this happens**: +1. You use the Graph API (Option B) to add all 5 permissions including beta permissions +2. The API call with `consentType: "AllPrincipals"` **already grants tenant-wide admin consent** +3. You go to Azure Portal and see only 3 permissions (the beta permissions are invisible) +4. You click "Grant admin consent" in Portal thinking you need to +5. Portal overwrites your API-granted consent with **only the 3 visible permissions** +6. Your 2 beta permissions are now deleted + +**Solution**: +- **Never use Portal admin consent after API method** - the API method already grants admin consent +- If you accidentally deleted beta permissions, re-run the Option B API steps to restore them +- You can verify admin consent was granted by checking the API verification step - if the query returns your permissions, consent is already granted ### Validation Errors From f76e08c2d12dfceb6a8ece77ee255eaca786f55a Mon Sep 17 00:00:00 2001 From: Sellakumaran Kanagarathnam Date: Mon, 8 Dec 2025 11:55:29 -0800 Subject: [PATCH 7/7] Improve Agent365 CLI setup and validation process Updated documentation to include detailed steps for granting admin consent via Microsoft Graph API, including handling beta permissions. Enhanced PowerShell script to clear NuGet cache and ensure a clean build. Improved `ClientAppValidator` with fallback logic for consented permissions via `oauth2PermissionGrants`, added a new method `GetConsentedPermissionsAsync`, and refined validation error handling. Added critical warnings about Azure Portal overwriting API-granted permissions and provided solutions for restoring beta permissions. Enhanced logging and troubleshooting guidance for better user experience and security. --- docs/guides/custom-client-app-registration.md | 72 +++++++++++--- scripts/cli/install-cli.ps1 | 9 ++ .../Services/ClientAppValidator.cs | 99 +++++++++++++++++++ 3 files changed, 166 insertions(+), 14 deletions(-) diff --git a/docs/guides/custom-client-app-registration.md b/docs/guides/custom-client-app-registration.md index a219b8ff..db1ece0f 100644 --- a/docs/guides/custom-client-app-registration.md +++ b/docs/guides/custom-client-app-registration.md @@ -21,9 +21,6 @@ The Agent365 CLI requires a custom client app registration in your Entra ID tena > - Your **Application (client) ID** from Step 2 > - A link to this guide: [Custom Client App Registration](#3-configure-api-permissions) -**Optional**: -- Azure CLI (only needed if you prefer command-line automation instead of Graph Explorer) - ### 1. Register Application Follow [Microsoft's quickstart guide](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app) to create an app registration: @@ -57,7 +54,7 @@ From the app's **Overview** page, copy the **Application (client) ID** (GUID for 1. In your app registration, go to **API permissions** 2. Click **Add a permission** → **Microsoft Graph** → **Delegated permissions** -3. Search for and add these 5 permissions: +3. Add these 5 permissions one by one: > **Important**: You MUST use **Delegated permissions** (NOT Application permissions). The CLI authenticates interactively - you sign in, and it acts on your behalf. See [Troubleshooting](#wrong-permission-type-delegated-vs-application) if you accidentally add Application permissions. @@ -69,6 +66,12 @@ From the app's **Overview** page, copy the **Application (client) ID** (GUID for | `DelegatedPermissionGrant.ReadWrite.All` | Grant permissions for agent blueprints | | `Directory.Read.All` | Read directory data for validation | + **For each permission above**: + - In the search box, type the permission name (e.g., `AgentIdentityBlueprint.ReadWrite.All`) + - Check the checkbox next to the permission + - Click **Add permissions** button + - Repeat for all 5 permissions + 4. Click **Grant admin consent for [Your Tenant]** - **Why is this required?** Agent Identity Blueprints are tenant-wide resources that multiple users and applications can reference. Without tenant-wide consent, the CLI will fail during authentication. - **What if it fails?** You need Application Administrator, Cloud Application Administrator, or Global Administrator role. Ask your tenant admin for help. @@ -88,12 +91,32 @@ If the beta permissions (`AgentIdentityBlueprint.*`) are **not visible**, procee **Step 1**: Get your service principal ID and Graph resource ID: - Set method to **GET** and use this URL: + > **What's a service principal?** It's your app's identity in your tenant, required before granting permissions via API. + + Set method to **GET** and use this URL (replace YOUR_CLIENT_APP_ID with your actual Application client ID from Step 2): ``` https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq 'YOUR_CLIENT_APP_ID'&$select=id ``` - Click **Run query**. If the query fails with a permissions error, click the **Modify permissions** tab, consent to the required permissions, then click **Run query** again. Copy the `id` value (this is your `SP_OBJECT_ID`) + Click **Run query**. If the query fails with a permissions error, click the **Modify permissions** tab, consent to the required permissions, then click **Run query** again. + + **If the query returns empty results** (`"value": []`), create the service principal: + + Set method to **POST** and use this URL: + ``` + https://graph.microsoft.com/v1.0/servicePrincipals + ``` + + **Request Body** (replace YOUR_CLIENT_APP_ID with your actual Application client ID): + ```json + { + "appId": "YOUR_CLIENT_APP_ID" + } + ``` + + Click **Run query**. You should get a `201 Created` response. + + **Copy the `id` value from whichever query succeeded** (GET or POST) - this is your `SP_OBJECT_ID`. Set method to **GET** and use this URL: ``` @@ -102,7 +125,9 @@ If the beta permissions (`AgentIdentityBlueprint.*`) are **not visible**, procee Click **Run query**. If the query fails with a permissions error, click the **Modify permissions** tab, consent to the required permissions, then click **Run query** again. Copy the `id` value (this is your `GRAPH_RESOURCE_ID`) - **Step 2**: Grant admin consent with all 5 permissions: + **Step 2**: Grant admin consent with all 5 permissions (including beta): + + This API call grants tenant-wide admin consent for all 5 permissions, including the 2 beta permissions that aren't visible in Portal. Set method to **POST** and use this URL: ``` @@ -120,16 +145,32 @@ If the beta permissions (`AgentIdentityBlueprint.*`) are **not visible**, procee } ``` - Click **Run query**. If the query fails with a permissions error (likely DelegatedPermissionGrant.ReadWrite.All), click the **Modify permissions** tab, consent to DelegatedPermissionGrant.ReadWrite.All, then click **Run query** again. You should get a `201 Created` response. - - **Verification**: Query the grant to confirm: + Click **Run query**. If the query fails with a permissions error (likely DelegatedPermissionGrant.ReadWrite.All), click the **Modify permissions** tab, consent to DelegatedPermissionGrant.ReadWrite.All, then click **Run query** again. + + **If you get `201 Created` response** - Success! The `scope` field in the response shows all 5 permission names. You're done. + + **If you get error `Request_MultipleObjectsWithSameKeyValue`** - A grant already exists (you may have added permissions in Portal earlier). Update it instead: Set method to **GET** and use this URL: ``` https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId eq 'SP_OBJECT_ID_FROM_STEP1' ``` - Click **Run query**. If the query fails with a permissions error, click the **Modify permissions** tab, consent to the required permissions, then click **Run query** again. The `scope` field should contain all 5 permission names. + Click **Run query**. Copy the `id` value from the response. + + Set method to **PATCH** and use this URL (replace YOUR_GRANT_ID with the ID you just copied): + ``` + https://graph.microsoft.com/v1.0/oauth2PermissionGrants/YOUR_GRANT_ID + ``` + + **Request Body**: + ```json + { + "scope": "Application.ReadWrite.All Directory.Read.All DelegatedPermissionGrant.ReadWrite.All AgentIdentityBlueprint.ReadWrite.All AgentIdentityBlueprint.UpdateAuthProperties.All" + } + ``` + + Click **Run query**. You should get a `200 OK` response with all 5 permissions in the `scope` field. > **⚠️ CRITICAL WARNING**: The `consentType: "AllPrincipals"` in the POST request above **already grants tenant-wide admin consent**. **DO NOT click "Grant admin consent" in Azure Portal** after using this API method - doing so will **delete your beta permissions** because the Portal UI cannot see beta permissions and will overwrite your API-granted consent with only the visible permissions. @@ -189,8 +230,9 @@ The CLI automatically validates: **Solution**: - **Never use Portal admin consent after API method** - the API method already grants admin consent -- If you accidentally deleted beta permissions, re-run the Option B API steps to restore them -- You can verify admin consent was granted by checking the API verification step - if the query returns your permissions, consent is already granted +- If you accidentally deleted beta permissions, re-run the Option B Step 2 to restore them + - You'll get a `Request_MultipleObjectsWithSameKeyValue` error - follow the PATCH instructions in Step 2 +- Check the `scope` field in the POST or PATCH response to verify all 5 permissions are listed ### Validation Errors @@ -199,7 +241,9 @@ The CLI automatically validates your client app when running `a365 setup all` or Common issues: - **App not found**: Verify you copied the **Application (client) ID** (not Object ID) - **Missing permissions**: Add all five required permissions -- **Admin consent not granted**: Click "Grant admin consent" in Azure Portal +- **Admin consent not granted**: + - If you used **Option A** (Portal only): Click "Grant admin consent" in Azure Portal + - If you used **Option B** (Graph API): Re-run the POST or PATCH request - do NOT use Portal's consent button - **Wrong permission type**: Use Delegated permissions, not Application permissions For detailed troubleshooting, see [Microsoft's app registration documentation](https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app). diff --git a/scripts/cli/install-cli.ps1 b/scripts/cli/install-cli.ps1 index 4cf21210..b9580907 100644 --- a/scripts/cli/install-cli.ps1 +++ b/scripts/cli/install-cli.ps1 @@ -21,6 +21,15 @@ if (-not (Test-Path $outputDir)) { Write-Host "Cleaning old packages from $outputDir..." Get-ChildItem -Path $outputDir -Filter '*.nupkg' | Remove-Item -Force +# Clear NuGet package cache to avoid version conflicts +Write-Host "Clearing NuGet package cache..." +Remove-Item ~/.nuget/packages/microsoft.agents.a365.devtools.cli -Recurse -Force -ErrorAction SilentlyContinue +Write-Host "Package cache cleared" + +# Clean the project to ensure fresh build +Write-Host "Cleaning project..." +dotnet clean $projectPath -c Release + # Build the project first to ensure NuGet restore and build outputs exist Write-Host "Building CLI tool (Release configuration)..." dotnet build $projectPath -c Release diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs index 6fdc3960..c249663f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs @@ -110,6 +110,20 @@ public async Task ValidateClientAppAsync( // Step 4: Validate permissions in manifest var missingPermissions = await ValidatePermissionsConfiguredAsync(appInfo, graphToken, ct); + + // Step 4.5: For any unresolvable permissions (beta APIs), check oauth2PermissionGrants as fallback + if (missingPermissions.Count > 0) + { + var consentedPermissions = await GetConsentedPermissionsAsync(clientAppId, graphToken, ct); + // Remove permissions that have been consented even if not in app registration + missingPermissions.RemoveAll(p => consentedPermissions.Contains(p, StringComparer.OrdinalIgnoreCase)); + + if (consentedPermissions.Count > 0) + { + _logger.LogInformation("Found {Count} consented permissions via oauth2PermissionGrants (including beta APIs)", consentedPermissions.Count); + } + } + if (missingPermissions.Count > 0) { return ValidationResult.Failure( @@ -358,6 +372,91 @@ private async Task> ResolvePermissionIdsAsync(string return permissionNameToIdMap; } + /// + /// Gets the list of permissions that have been consented for the app via oauth2PermissionGrants. + /// This is used as a fallback for beta permissions that may not be visible in the app registration's requiredResourceAccess. + /// + private async Task> GetConsentedPermissionsAsync(string clientAppId, string graphToken, CancellationToken ct) + { + var consentedPermissions = new HashSet(StringComparer.OrdinalIgnoreCase); + + try + { + // Get service principal for the app + var spCheckResult = await _executor.ExecuteAsync( + "az", + $"rest --method GET --url \"{GraphApiBaseUrl}/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id\" --headers \"Authorization=Bearer {graphToken}\"", + cancellationToken: ct); + + if (!spCheckResult.Success) + { + _logger.LogDebug("Could not query service principal for consent check"); + return consentedPermissions; + } + + var spResponse = JsonNode.Parse(spCheckResult.StandardOutput); + var servicePrincipals = spResponse?["value"]?.AsArray(); + + if (servicePrincipals == null || servicePrincipals.Count == 0) + { + _logger.LogDebug("Service principal not found for consent check"); + return consentedPermissions; + } + + var sp = servicePrincipals[0]!.AsObject(); + var spObjectId = sp["id"]?.GetValue(); + + if (string.IsNullOrWhiteSpace(spObjectId)) + { + return consentedPermissions; + } + + // Get oauth2PermissionGrants + var grantsResult = await _executor.ExecuteAsync( + "az", + $"rest --method GET --url \"{GraphApiBaseUrl}/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'\" --headers \"Authorization=Bearer {graphToken}\"", + cancellationToken: ct); + + if (!grantsResult.Success) + { + _logger.LogDebug("Could not query oauth2PermissionGrants"); + return consentedPermissions; + } + + var grantsResponse = JsonNode.Parse(grantsResult.StandardOutput); + var grants = grantsResponse?["value"]?.AsArray(); + + if (grants == null || grants.Count == 0) + { + return consentedPermissions; + } + + // Extract all scopes from grants + foreach (var grant in grants) + { + var grantObj = grant?.AsObject(); + var scope = grantObj?["scope"]?.GetValue(); + + if (!string.IsNullOrWhiteSpace(scope)) + { + var scopes = scope.Split(' ', StringSplitOptions.RemoveEmptyEntries); + foreach (var s in scopes) + { + consentedPermissions.Add(s); + } + } + } + + _logger.LogDebug("Found {Count} consented permissions from oauth2PermissionGrants", consentedPermissions.Count); + } + catch (Exception ex) + { + _logger.LogDebug("Error retrieving consented permissions: {Message}", ex.Message); + } + + return consentedPermissions; + } + private async Task ValidateAdminConsentAsync(string clientAppId, string graphToken, CancellationToken ct) { _logger.LogInformation("Checking admin consent status...");