diff --git a/.claude/agents/pr-code-reviewer.md b/.claude/agents/pr-code-reviewer.md index 6b77c6e0..0eda80e2 100644 --- a/.claude/agents/pr-code-reviewer.md +++ b/.claude/agents/pr-code-reviewer.md @@ -131,9 +131,9 @@ For each changed file, analyze: - Are error messages user-friendly? 4. **Resource Management** - - Are IDisposable objects disposed? - - Are connections/streams closed? - - Any potential memory leaks? + - Are IDisposable objects disposed? Are connections/streams closed? Any potential memory leaks? + - **IMPORTANT**: For every `var x = await SomeMethod(...)` in the diff, use `Read` to look up the method's return type in the source file. If the return type implements `IDisposable`, flag missing `using` as a `high` severity `resource_leak`. Do NOT rely on the diff alone — the return type is almost never in the diff. + - **IMPORTANT**: Also scan for `var x = await A(...); if (...) { ... } else { x = await B(...); }` — the first `IDisposable` value is silently leaked when the else-branch overwrites `x`. See Anti-Pattern #13. 5. **Null Safety** - Potential null reference exceptions? @@ -410,6 +410,195 @@ Differentiate between: - Runs on Linux runners (cross-platform not required) - Tests strongly recommended but not blocking +## C#-Specific Anti-Patterns (Check These in Every Review) + +These patterns have caused real bugs and Copilot review comments in this repo. Always scan new/changed code for them. + +### 1. Wrong Scope Constant for Operation +When a method acquires a token with a specific scope, verify the scope constant matches the operation. +- **Pattern to catch**: `DeleteXxx` method using `ReadWriteAllScope` instead of `DeleteRestoreAllScope` +- **Severity**: `high` — causes deterministic 403s for the operation +- **Check**: Read the constant used and compare to the method name + docs describing what permission is needed + +### 2. Null-Only Guard on Nullable String Variables +`== null` is insufficient for string values returned from JSON/APIs — empty string is also invalid. +- **Pattern to catch**: `if (existingId == null)` where `existingId` came from a JSON parse or API response +- **Severity**: `high` — empty string generates malformed URLs (e.g., `.../oauth2PermissionGrants/`) +- **Fix**: Always use `string.IsNullOrWhiteSpace(existingId)` for Guard checks on strings used in URLs + +### 3. Unused Tuple Return Elements +Multi-element tuples where one element is always `null` at all return sites. +- **Pattern to catch**: `Task<(bool x, string? y, string? z)>` where every `return` statement ends with `, null)` +- **Severity**: `medium` — API noise, confusing callers, harder to understand contract +- **Fix**: Remove the unused element from the return type and all callers + +### 4. Misleading Log Message Scope +Log messages that claim to cover "all configured resources" when only a subset is handled. +- **Pattern to catch**: `"covers all configured resources"` in a consent/grant flow that only builds URLs for one resource type (e.g., Microsoft Graph only) +- **Severity**: `medium` — misleads operators troubleshooting why non-Graph resources aren't consented +- **Fix**: Qualify the message: `"covers Microsoft Graph delegated scopes only"` + +### 5. CancellationToken.None in Long-Running Operations +Hardcoded `CancellationToken.None` in handler body for long-running async calls (infrastructure provisioning, permission grants, etc.). +- **Pattern to catch**: `SetHandler(async (opt1, opt2, ...) => { ... SomethingAsync(..., CancellationToken.None) ... }, opt1, opt2, ...)` +- **Severity**: `medium` — Ctrl+C cannot cancel long-running operations; partial state may be applied +- **Fix**: Use `InvocationContext`: + ```csharp + command.SetHandler(async (InvocationContext context) => + { + var opt1 = context.ParseResult.GetValueForOption(opt1Option); + var ct = context.GetCancellationToken(); + await SomethingAsync(..., ct); + }); + ``` + +### 6. Duplicate Logic Using Different Execution Mechanisms +Two separate implementations of the same operation using different execution paths (e.g., `ProcessStartInfo` vs. `CommandExecutor`). +- **Pattern to catch**: Static helper method running `az account show` via `Process.Start` when an instance method in a sibling service does the same via `CommandExecutor` +- **Severity**: `medium` — divergence risk; one gets fixes/improvements the other doesn't; different testability +- **Fix**: Extract to a shared static helper in `Services/Helpers/` and delegate from both callers + +### 7. `Task.Delay` Without CancellationToken +`Task.Delay` called without a CancellationToken inside a handler that receives one — makes the wait non-cancellable, blocking Ctrl+C and accumulating if the step is retried. +- **Pattern to catch**: `await Task.Delay(N)` inside a method/handler that has a `ct` or `cancellationToken` parameter in scope +- **Severity**: `medium` — Ctrl+C stalls during the delay; can compound if the delay is in a loop +- **Fix**: `await Task.Delay(N, ct);` + +### 8. `Process.WaitForExitAsync` With Unread Redirected Stderr +`RedirectStandardError = true` combined with reading only stdout — if the process writes enough to stderr the pipe buffer fills and it deadlocks waiting for the reader. +- **Pattern to catch**: `ProcessStartInfo` with `RedirectStandardError = true` where only `StandardOutput.ReadToEndAsync()` is awaited before `WaitForExitAsync()` +- **Severity**: `high` — deterministic deadlock when the subprocess writes >4 KB to stderr +- **Fix**: Read both streams concurrently before waiting: + ```csharp + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + await Task.WhenAll(outputTask, errorTask); + await process.WaitForExitAsync(); + var output = outputTask.Result; + ``` + +### 9. `Environment.Exit` Instead of `ExceptionHandler.ExitWithCleanup` +Direct `Environment.Exit(N)` calls skip the repo's output-flush / console-state-reset logic in `ExceptionHandler.ExitWithCleanup`. +- **Pattern to catch**: `Environment.Exit(1)` (or any exit code) in CLI command handlers or exception catch blocks +- **Severity**: `medium` — console may be left in a dirty state (partial progress output not flushed, ANSI reset not sent) +- **Fix**: Replace with `ExceptionHandler.ExitWithCleanup(1);` + +### 10. Bearer Token Embedded in Process Command-Line Arguments +Injecting a raw Bearer token as a CLI argument (e.g., `az rest --headers "Authorization=Bearer {token}"`). +- **Pattern to catch**: String interpolation of a token into `az rest --headers` argument passed to `ExecuteAsync` +- **Severity**: `high` (security) — process command-line arguments are visible to all local users via OS process listing, crash dumps, and audit logs +- **Fix**: Use in-process HTTP (`GraphApiService` / `HttpClient`) or pass token via stdin/temp file with restricted permissions + +### 11. Test Classes Creating Real `GraphApiService` Without Cache Warmup +Test classes that construct real (non-substitute) `GraphApiService` or `AgentBlueprintService` instances without pre-warming the `AzCliHelper` process-level token cache. `EnsureGraphHeadersAsync` calls `AzCliHelper.AcquireAzCliTokenAsync` as its FIRST step — if the cache is cold, it spawns a real `az account get-access-token` subprocess (~20s per test class instance). This makes the test suite take minutes instead of seconds. + +A related dead-code smell: mocking `CommandExecutor.ExecuteAsync` to return `"fake-token"` for `get-access-token` calls looks correct but is never reached — the subprocess fires before the executor fallback is attempted. + +- **Pattern to catch** (any of the following in test code): + 1. `new GraphApiService(logger, executor, handler)` or `new AgentBlueprintService(...)` without `AzCliHelper.WarmAzCliTokenCache(...)` in the test class constructor + 2. `executor.ExecuteAsync(...).Returns(...)` matching `"get-access-token"` in a class that also constructs real `GraphApiService` instances — confirms the executor mock is dead code + 3. Missing `loginHintResolver: () => Task.FromResult(null)` parameter when constructing `GraphApiService` in tests (bypasses the `az account show` subprocess) +- **Severity**: `high` — causes ~20s per test *instance* (xUnit creates one instance per test method); a 10-test class goes from <1s to 200s +- **Check**: For every `new GraphApiService(` or `new AgentBlueprintService(` in a test file, verify the test class constructor contains: + ```csharp + AzCliHelper.WarmAzCliTokenCache("https://graph.microsoft.com/", "", "fake-graph-token"); + ``` + where `` matches all tenant ID strings used in that class's test methods. +- **Fix**: + ```csharp + // In test class constructor — warm for every tenantId string used in this class: + public MyServiceTests() + { + AzCliHelper.WarmAzCliTokenCache("https://graph.microsoft.com/", "tenant-123", "fake-graph-token"); + // Also pass loginHintResolver to bypass az account show: + // new GraphApiService(logger, executor, handler, loginHintResolver: () => Task.FromResult(null)) + } + ``` +- **Note**: `GraphApiServiceTokenCacheTests` is the intentional exception — it owns the cache and manages `AzCliTokenAcquirerOverride` explicitly via setUp/tearDown. + +### 12. Retry Loop Catches `TaskCanceledException` Without Early Exit +A catch block that handles `TaskCanceledException` (or `OperationCanceledException`) alongside transient errors and retries all of them equally — so a user pressing Ctrl+C burns through all retry attempts before propagating. +- **Pattern to catch**: `catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)` (or `OperationCanceledException`) inside a retry loop, with no check of `cancellationToken.IsCancellationRequested` before the retry delay +- **Severity**: `high` — Ctrl+C appears to hang for the full retry window; partial state may continue to be applied +- **Fix**: + ```csharp + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + if (ex is TaskCanceledException && cancellationToken.IsCancellationRequested) + throw; // propagate immediately — do not retry + // ... retry logic ... + } + ``` + +### 13. `IDisposable` Variable Overwritten in Else-Branch Without Prior Disposal +A variable holding an `IDisposable` is overwritten in an else/fallback branch without first disposing the value assigned in the if-branch. +- **Pattern to catch**: `var doc = await Primary(...); if (doc != null && ...) { use doc } else { doc = await Fallback(...); }` where the first `doc` is not disposed before reassignment +- **Severity**: `high` — the primary result leaks on every code path that falls into the else-branch; in high-frequency callers this accumulates +- **Fix**: Dispose explicitly before overwriting, or restructure with separate `using` scopes: + ```csharp + var primaryDoc = await Primary(...); + JsonDocument? doc; + if (primaryDoc != null && ...) + { + doc = primaryDoc; + } + else + { + primaryDoc?.Dispose(); + doc = await Fallback(...); + } + ``` +- **Check**: In the diff, for every pattern `var x = ...; if (...) { ... } else { x = ...; }` where the type is `IDisposable`, verify the original value is disposed in the else-branch. + +### 14. CLI Option Value Read from `ParseResult` But Never Used in Handler +An option is wired up and parsed but the variable holding its value is never referenced in the handler body — the flag appears in `--help` output but silently has no effect. +- **Pattern to catch**: `var verbose = context.ParseResult.GetValueForOption(verboseOption);` (or any option) with no subsequent reference to `verbose` in the handler lambda +- **Severity**: `medium` — misleads users who pass `--verbose` expecting more output +- **Fix**: Either wire the variable into logging configuration (e.g., adjust log level) or remove the `GetValueForOption` call. Keeping the option declaration is acceptable so it appears in help — just don't claim to read a value you discard. + +### 15. Hardcoded OAuth2 `state` Parameter +A fixed string (e.g., `"xyz123"`, `"state"`, `"abc"`) used as the OAuth2 `state` parameter in a consent/authorization URL. +- **Pattern to catch**: `$"&state=xyz123"` or any literal string in an OAuth2 URL `state=` segment +- **Severity**: `medium` — the `state` parameter is designed to be a random nonce for CSRF protection; a hardcoded value eliminates that protection. Even when the URL is only displayed (not automatically followed), it sets a bad precedent and will fail audits. +- **Fix**: Generate a random nonce per URL construction: + ```csharp + $"&state={Guid.NewGuid():N}" + ``` + +### 16. Test Assertion Flipped Without `because:` Documenting the Requirement Change +An assertion is changed from one expected value to another (e.g., `BeFalse()` → `BeTrue()`, `Be("old")` → `Be("new")`) without a `because:` string explaining what requirement changed. +- **Pattern to catch**: `result.Should().BeTrue()` / `result.Should().BeFalse()` / `result.Should().Be(...)` in the diff (added lines) with no `because:` argument, especially when the surrounding context shows the original assertion had a different expected value +- **Severity**: `medium` — a flipped assertion with no `because:` is indistinguishable from an implementation-tracking change (test updated to match code, not to match the requirement); the next reader cannot know if the behavior change was intentional +- **Fix**: Add `because:` to document the invariant: + ```csharp + result.Should().BeTrue( + because: "McpServersMetadata.Read.All is always included even when the manifest is missing, so the method proceeds and returns true"); + ``` + +### 17. `Environment.Exit` Used Instead of `ExceptionHandler.ExitWithCleanup` +A command handler calls `Environment.Exit(n)` directly instead of the codebase's standardized `ExceptionHandler.ExitWithCleanup(n)`. +- **Pattern to catch**: `Environment.Exit(` in any file under `Commands/` or `Services/` +- **Severity**: `medium` — `Environment.Exit` bypasses the `ExceptionHandler` cleanup that flushes console colors, writes final log entries, and ensures a clean terminal state. The codebase has `ExceptionHandler.ExitWithCleanup` specifically for this purpose (see `DeployCommand.cs`, `AdminSubcommand.cs`). +- **Fix**: Replace `Environment.Exit(1)` with `ExceptionHandler.ExitWithCleanup(1)` + +### 18. ARM `bool?` Existence Methods Return `false` for Non-404 Errors +A method with return type `bool?` (where `null` signals "fall back to az CLI") returns `false` for non-404 HTTP responses such as 401/403/5xx. +- **Pattern to catch**: `return response.StatusCode == HttpStatusCode.OK;` inside a `bool?`-returning method, where no explicit handling exists for non-200/non-404 responses +- **Severity**: `high` — callers use `HasValue` to decide whether to skip the az CLI fallback. Returning `false` for a 401/403 causes the caller to treat an auth failure as "resource does not exist" and attempt to create a resource that may already exist. +- **Fix**: Distinguish 200/404/other explicitly: + ```csharp + if (response.StatusCode == HttpStatusCode.OK) return true; + if (response.StatusCode == HttpStatusCode.NotFound) return false; + return null; // 401/403/5xx — caller falls back to az CLI + ``` + +**MANDATORY REPORTING RULE**: Whenever the diff contains any test file (`.Tests.cs`), you MUST emit a named finding for this check — even if no violation is found. The finding must appear in the review output with one of three statuses: + - **`high` severity** if a violation is found (missing warmup, dead executor mock, etc.) + - **`info` — FIXED** if the PR is fixing a prior violation (warmup added to previously-cold classes) — list each class fixed and its measured or estimated speedup + - **`info` — PASS** if all test classes with real service instances already have warmup in their constructors + +Do NOT silently omit this check. The rule exists because silent omission is how the regression in `da6f750` went undetected. + ## Example Invocation When you receive a request like "Review PR #253", you should: diff --git a/.claude/skills/review-staged/SKILL.md b/.claude/skills/review-staged/SKILL.md index 9fa55ce3..9ebc9e99 100644 --- a/.claude/skills/review-staged/SKILL.md +++ b/.claude/skills/review-staged/SKILL.md @@ -1,7 +1,7 @@ --- name: review-staged description: Generate structured code review for staged files (git staged changes) using Claude Code agents. Provides feedback before committing to catch issues early. -allowed-tools: Bash(git:*), Read, Write +allowed-tools: Bash(git:*), Bash(dotnet:*), Bash(cd:*), Read, Write --- # Review Staged Files Skill @@ -27,8 +27,9 @@ Examples: 4. **Analyzes changes** for security, testing, design patterns, and code quality issues 5. **Differentiates contexts**: CLI code vs GitHub Actions code (different standards) 6. **Creates actionable feedback**: Specific refactoring suggestions based on file names and patterns -7. **Generates structured review document** saved to a markdown file -8. **Shows summary** of all issues found organized by severity +7. **Runs the test suite and measures per-test timing** — flags any test taking > 1 second as a performance regression +8. **Generates structured review document** saved to a markdown file +9. **Shows summary** of all issues found organized by severity ## Engineering Review Principles @@ -66,6 +67,15 @@ This skill enforces the same principles as the PR review skill: - **CLI reliability**: CLI code without tests is BLOCKING - **GitHub Actions tests**: Strongly recommended (HIGH severity) but not blocking - **Mock external dependencies**: Proper mocking patterns +- **Test performance — measured by running, not just static analysis**: The review ALWAYS runs the full test suite and reports per-test timing. Any test method taking **> 1 second** is flagged as a performance regression (HIGH severity). The finding must include: + - The slow test class and method name(s) with their measured time + - The root cause (cold `AzCliHelper` token cache, missing `WarmAzCliTokenCache` call, real subprocess not mocked, etc.) + - The fix (warmup call pattern, `loginHintResolver` injection, etc.) + - Expected time after fix + + If all tests complete in < 1 second each: emit an **INFO — PASS** finding with the total suite time. + + **Do not skip the test run.** Static code analysis alone missed the regression in `da6f750`; only measurement catches it reliably. ### Security - **No hardcoded secrets**: Use environment variables or Azure Key Vault @@ -101,7 +111,19 @@ The skill uses **Claude Code directly** for semantic code analysis (same as revi 4. Claude Code gets staged changes: `git diff --staged` 5. Claude Code performs semantic analysis using its own capabilities 6. Claude Code identifies specific issues with line numbers and code references -7. Claude Code writes markdown file to `.codereviews/claude-staged-.md` +7. **Claude Code runs the full test suite with per-test timing:** + ```bash + cd src && dotnet test tests.proj --configuration Release --logger "console;verbosity=normal" 2>&1 + ``` + Parse the output for lines matching `[X s]` or `[X,XXX ms]` patterns. Extract test class name, method name, and duration. Flag any test method taking **> 1 second**. Group findings by test class and include the measured times in the review. +8. Claude Code writes markdown file to `.codereviews/claude-staged-.md` + +**Test timing output format** (from `dotnet test --logger "console;verbosity=normal"`): +``` + Passed SomeTests.Method_Scenario_ExpectedResult [< 1 ms] + Passed OtherTests.Method_Slow [22 s] +``` +Any line showing `[X s]` where X ≥ 1 is a slow test. Report all such tests in a dedicated finding. **Key Advantages**: - ✅ No API key required - uses Claude Code's existing authentication diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index dbcd9144..26c9c321 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -36,6 +36,8 @@ - Focus on quality over quantity of tests - Add regression tests for bug fixes - Tests should verify CLI reliability +- **Tests must assert requirements, not implementation** — when a test is changed to match new code behavior (rather than to reflect a changed requirement), that is a red flag. A test that silently tracks whatever the code does provides no regression protection. If a test needs to be updated, explicitly document the requirement the new assertion encodes (use `because:` in FluentAssertions). If you cannot articulate a requirement reason, the test change should be questioned. +- **FluentAssertions `because:` is mandatory for non-obvious assertions** — any assertion on a URL structure, encoding format, security-sensitive behavior, or protocol requirement must include a `because:` clause explaining the invariant being enforced. - **Dispose IDisposable objects properly**: - `HttpResponseMessage` objects created in tests must be disposed - Even in mock/test handlers, follow proper disposal patterns @@ -94,6 +96,7 @@ ### Output and Logging - No emojis or special characters in logs, output, or comments +- The output should be plain text, and display properly in windows, macOS, and Linux terminals - Keep user-facing messages clear and professional - Follow client-facing help text conventions @@ -114,7 +117,18 @@ - Check if it's a legacy reference that needs to be updated - **Files to check**: All `.cs`, `.csx` files in the repository -### Rule 2: Verify Copyright Headers +### Rule 2: Flag Tests Changed to Match Implementation +- **Description**: When a PR or staged change modifies a test assertion to match new code behavior, treat it as a high-priority review flag — not a routine update. +- **The anti-pattern**: A test previously asserted `X`. Code changed, so the test was updated to assert `not X` (or a different value of `X`) without documenting *why the requirement changed*. +- **Why it matters**: Tests that chase implementation provide zero regression protection. They give false confidence — all tests green, but the regression was in the test suite, not just the code. This is how silent regressions reach production. +- **Action**: For every test assertion change in the diff: + 1. Ask: "Did the *requirement* change, or just the implementation?" + 2. If the requirement changed: the PR must include a comment or `because:` clause stating the new requirement. + 3. If only the implementation changed: the test assertion should not need to change. Flag as **HIGH** if a test is weakened (e.g., `Contain` → `NotContain`, `Equal("x")` → `NotBeNull()`). + 4. If the assertion is on a security-sensitive, protocol-level, or external-API contract (OAuth URLs, HTTP headers, encoding format): flag as **CRITICAL** — require explicit documented justification. +- **Example of the failure mode** (from project history): Consent URL tests asserted `redirect_uri=` was present. When URL encoding was changed, tests were updated to match. No one asked whether `redirect_uri` was still required by the AAD protocol. The regression (`AADSTS500113`) reached the user before any test caught it. + +### Rule 3: Verify Copyright Headers - **Description**: Ensure all C# files have proper Microsoft copyright headers - **Action**: If a `.cs` file is missing a copyright header: - Add the Microsoft copyright header at the top of the file diff --git a/.gitignore b/.gitignore index cf395a39..02d1b05a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Internal working documents +docs/plans/ + ## A streamlined .gitignore for modern .NET projects ## including temporary files, build results, and ## files generated by popular .NET tools. If you are diff --git a/CHANGELOG.md b/CHANGELOG.md index c671be19..68b9e93f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Server-driven notice system: security advisories and critical upgrade prompts are displayed at startup when a maintainer updates `notices.json`. Notices are suppressed once the user upgrades past the specified `minimumVersion`. Results are cached locally for 4 hours to avoid network calls on every invocation. - `a365 cleanup azure --dry-run` — preview resources that would be deleted without making any changes or requiring Azure authentication - `AppServiceAuthRequirementCheck` — validates App Service deployment token before `a365 deploy` begins, catching revoked grants (AADSTS50173) early +- `a365 setup admin` — new command for Global Administrators to complete tenant-wide AllPrincipals OAuth2 permission grants after `a365 setup all` has been run by an Agent ID Admin ### Changed - `a365 publish` updates manifest IDs, creates `manifest.zip`, and prints concise upload instructions for Microsoft 365 Admin Center (Agents > All agents > Upload custom agent). Interactive prompts only occur in interactive terminals; redirect stdin to suppress them in scripts. ### Fixed +- `a365 cleanup` blueprint deletion now succeeds for Global Administrators even when the blueprint was created by a different user +- `a365 setup all` no longer times out for non-admin users — the CLI immediately surfaces a consent URL to share with an administrator instead of waiting for a browser prompt +- `a365 setup all` requests admin consent once for all resources instead of prompting once per resource - macOS/Linux: device code fallback when browser authentication is unavailable (#309) - Linux: MSAL fallback when PowerShell `Connect-MgGraph` fails in non-TTY environments (#309) - Admin consent polling no longer times out after 180s — blueprint service principal now resolved with correct MSAL token (#309) diff --git a/scripts/cli/install-cli.sh b/scripts/cli/install-cli.sh index 8e2af4e3..12d84da7 100755 --- a/scripts/cli/install-cli.sh +++ b/scripts/cli/install-cli.sh @@ -84,13 +84,18 @@ if dotnet tool uninstall -g Microsoft.Agents.A365.DevTools.Cli 2>/dev/null; then sleep 1 else echo "Could not uninstall existing CLI (may not be installed or locked)." - # Try to clear the tool directory manually if locked + # Try to clear the tool directory and shim manually (handles ghost/orphaned installs) TOOL_PATH="$HOME/.dotnet/tools/.store/microsoft.agents.a365.devtools.cli" if [ -d "$TOOL_PATH" ]; then echo "Attempting to clear locked tool directory..." rm -rf "$TOOL_PATH" 2>/dev/null || true sleep 1 fi + # Remove orphaned shim that blocks reinstall even when tool is not registered + SHIM="$HOME/.dotnet/tools/a365" + for ext in "" ".exe"; do + [ -f "${SHIM}${ext}" ] && rm -f "${SHIM}${ext}" 2>/dev/null && echo "Removed orphaned shim: ${SHIM}${ext}" || true + done fi # Install with specific version from local source diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs index d255d659..a8c4f079 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/CleanupCommand.cs @@ -692,15 +692,7 @@ private static async Task ExecuteAllCleanupAsync( logger.LogInformation("Agent user deleted"); } - // 5. Delete bot messaging endpoint using shared helper - if (!string.IsNullOrWhiteSpace(config.BotName)) - { - var endpointDeleted = await DeleteMessagingEndpointAsync(logger, config, botConfigurator, correlationId: correlationId); - if (!endpointDeleted) - { - hasFailures = true; - } - } + // 5. Messaging endpoint deletion is temporarily disabled. // 6. Delete Azure resources (Web App and App Service Plan) if (!string.IsNullOrWhiteSpace(config.WebAppName) && !string.IsNullOrWhiteSpace(config.ResourceGroup)) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/ConfigCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/ConfigCommand.cs index 30850f10..0d77e5b3 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/ConfigCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/ConfigCommand.cs @@ -223,10 +223,15 @@ private static Command CreateDisplaySubcommand(ILogger logger, string configDir) new[] { "--all", "-a" }, description: "Display both static and generated configuration"); + var fieldOption = new Option( + new[] { "--field", "-f" }, + description: "Output the value of a single field (for example: --field messagingEndpoint)"); + cmd.AddOption(generatedOption); cmd.AddOption(allOption); + cmd.AddOption(fieldOption); - cmd.SetHandler(async (bool showGenerated, bool showAll) => + cmd.SetHandler(async (bool showGenerated, bool showAll, string? field) => { try { @@ -246,6 +251,22 @@ private static Command CreateDisplaySubcommand(ILogger logger, string configDir) bool displayStatic = !showGenerated || showAll; bool displayGenerated = showGenerated || showAll; + // --field: output a single value from the selected config and exit + if (!string.IsNullOrWhiteSpace(field)) + { + var value = TryGetConfigField(config, field, displayGenerated, displayStatic, logger, displayOptions); + if (value != null) + { + Console.WriteLine(value); + } + else + { + Console.Error.WriteLine($"Field '{field}' not found in configuration."); + ExceptionHandler.ExitWithCleanup(1); + } + return; + } + if (displayStatic) { if (showAll) @@ -323,8 +344,58 @@ private static Command CreateDisplaySubcommand(ILogger logger, string configDir) { logger.LogError(ex, "Failed to display configuration: {Message}", ex.Message); } - }, generatedOption, allOption); + }, generatedOption, allOption, fieldOption); return cmd; } + + /// + /// Looks up a single field by JSON key from config, searching generated config first + /// (when checkGenerated is true) then static config (when checkStatic is true). + /// Returns the string value, or raw JSON text for non-string values, or null if not found. + /// + internal static string? TryGetConfigField( + Models.Agent365Config config, + string field, + bool checkGenerated, + bool checkStatic, + Microsoft.Extensions.Logging.ILogger logger, + JsonSerializerOptions? serializerOptions = null) + { + var options = serializerOptions ?? new JsonSerializerOptions + { + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + if (checkGenerated) + { + var generatedConfig = config.GetGeneratedConfigForDisplay(logger); + var generatedJson = JsonSerializer.Serialize(generatedConfig, options); + using var generatedDoc = JsonDocument.Parse(generatedJson); + if (generatedDoc.RootElement.TryGetProperty(field, out var generatedProp) && + generatedProp.ValueKind != JsonValueKind.Null) + { + return generatedProp.ValueKind == JsonValueKind.String + ? generatedProp.GetString() + : generatedProp.GetRawText(); + } + } + + if (checkStatic) + { + var staticConfig = config.GetStaticConfig(); + var staticJson = JsonSerializer.Serialize(staticConfig, options); + using var staticDoc = JsonDocument.Parse(staticJson); + if (staticDoc.RootElement.TryGetProperty(field, out var staticProp) && + staticProp.ValueKind != JsonValueKind.Null) + { + return staticProp.ValueKind == JsonValueKind.String + ? staticProp.GetString() + : staticProp.GetRawText(); + } + } + + return null; + } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs index 244c535b..f814d775 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DeployCommand.cs @@ -520,7 +520,7 @@ private static void HandleDeploymentException(Exception ex, ILogger logger) logger.LogError("Configuration file not found: {Message}", fileNotFound.Message); logger.LogInformation(""); logger.LogInformation("To get started:"); - logger.LogInformation(" 1. Copy a365.config.example.json to a365.config.json"); + logger.LogInformation(" 1. Copy a365.config.example.jsonc to a365.config.json"); logger.LogInformation(" 2. Edit a365.config.json with your Azure tenant and subscription details"); logger.LogInformation(" 3. Run 'a365 deploy' to perform a deployment"); logger.LogInformation(""); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopCommand.cs index d08afb17..7b62f889 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopCommand.cs @@ -145,7 +145,10 @@ private static async Task CallDiscoverToolServersAsync(IConfigService conf logger.LogInformation("Environment: {Environment}, Audience: {Audience}", config.Environment, audience); - authToken = await authService.GetAccessTokenAsync(audience); + // Resolve az CLI login hint so WAM targets the correct account instead of + // defaulting to the first cached MSAL account (which may be stale). + var loginHint = await Services.Helpers.AzCliHelper.ResolveLoginHintAsync(); + authToken = await authService.GetAccessTokenAsync(audience, userId: loginHint); if (string.IsNullOrWhiteSpace(authToken)) { @@ -165,7 +168,7 @@ private static async Task CallDiscoverToolServersAsync(IConfigService conf // Call the endpoint directly (no environment ID needed in URL or query) logger.LogInformation("Making GET request to: {RequestUrl}", discoverEndpointUrl); - var response = await httpClient.GetAsync(discoverEndpointUrl); + using var response = await httpClient.GetAsync(discoverEndpointUrl); if (!response.IsSuccessStatusCode) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs index 3c3ae52b..0ca0b735 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopSubcommands/GetTokenSubcommand.cs @@ -311,13 +311,15 @@ private static async Task AcquireAndDisplayTokenAsync( logger.LogInformation(""); // Use GetAccessTokenWithScopesAsync for explicit scope control + var loginHint = await AzCliHelper.ResolveLoginHintAsync(); var token = await authService.GetAccessTokenWithScopesAsync( resourceAppId, requestedScopes, tenantId, forceRefresh, clientAppId, - useInteractiveBrowser: true); + useInteractiveBrowser: true, + userId: loginHint); if (string.IsNullOrWhiteSpace(token)) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs index fe83268d..f88b9e12 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs @@ -39,7 +39,9 @@ public static Command CreateCommand( AgentBlueprintService blueprintService, BlueprintLookupService blueprintLookupService, FederatedCredentialService federatedCredentialService, - IClientAppValidator clientAppValidator) + IClientAppValidator clientAppValidator, + IConfirmationProvider confirmationProvider, + ArmApiService? armApiService = null) { var command = new Command("setup", "Set up your Agent 365 environment with granular control over each step\n\n" + @@ -51,7 +53,9 @@ public static Command CreateCommand( " 4. a365 setup permissions bot\n" + "Or run all steps at once:\n" + " a365 setup all # Full setup (includes infrastructure)\n" + - " a365 setup all --skip-infrastructure # Skip infrastructure if it already exists"); + " a365 setup all --skip-infrastructure # Skip infrastructure if it already exists\n\n" + + "For non-admin users — complete GA-only grants after setup all:\n" + + " a365 setup admin --config-dir \"\" # Run as Global Administrator"); // Add subcommands command.AddCommand(RequirementsSubcommand.CreateCommand( @@ -67,7 +71,10 @@ public static Command CreateCommand( logger, authValidator, configService, executor, graphApiService, blueprintService)); command.AddCommand(AllSubcommand.CreateCommand( - logger, configService, executor, botConfigurator, authValidator, platformDetector, graphApiService, blueprintService, clientAppValidator, blueprintLookupService, federatedCredentialService)); + logger, configService, executor, botConfigurator, authValidator, platformDetector, graphApiService, blueprintService, clientAppValidator, blueprintLookupService, federatedCredentialService, armApiService)); + + command.AddCommand(AdminSubcommand.CreateCommand( + logger, configService, authValidator, graphApiService, confirmationProvider)); return command; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AdminSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AdminSubcommand.cs new file mode 100644 index 00000000..1380e0ba --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AdminSubcommand.cs @@ -0,0 +1,289 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Commands; +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.Agents.A365.DevTools.Cli.Services.Internal; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; +using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementChecks; +using Microsoft.Extensions.Logging; +using System.CommandLine; + +namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; + +/// +/// Admin subcommand - Completes OAuth2 permission grants that require Global Administrator. +/// +/// Background: 'a365 setup all' run by an Agent ID Admin or Developer configures inheritable +/// permissions (which do not require GA) but cannot create AllPrincipals oauth2PermissionGrants +/// (which do). This command completes that remaining step. +/// +/// Technical limitation: oauth2PermissionGrant creation via the Graph API always requires +/// DelegatedPermissionGrant.ReadWrite.All, an admin-only scope. Additionally, GA bypasses +/// entitlement validation and can grant any scope; non-admin users receive HTTP 403 or 400 +/// for all resource SPs. There is no self-service path for non-admin users via the API. +/// +/// Required permissions: Global Administrator +/// +internal static class AdminSubcommand +{ + public static List GetChecks(AzureAuthValidator auth) + => SetupCommand.GetBaseChecks(auth); + + public static Command CreateCommand( + ILogger logger, + IConfigService configService, + AzureAuthValidator authValidator, + GraphApiService graphApiService, + IConfirmationProvider confirmationProvider) + { + var command = new Command( + "admin", + "Complete OAuth2 permission grants that require Global Administrator.\n\n" + + "Run this after 'a365 setup all' has been executed by an Agent ID Admin or Developer.\n" + + "Point --config-dir at the folder containing the agent's a365.config.json and\n" + + "a365.generated.config.json files.\n\n" + + "Required permissions:\n" + + " - Global Administrator\n\n" + + "Typical handoff workflow:\n" + + " 1. Agent ID Admin runs: a365 setup all\n" + + " 2. Agent ID Admin shares the config folder with a Global Administrator\n" + + " 3. Global Admin runs: a365 setup admin --config-dir \"\""); + + var configDirOption = new Option( + ["--config-dir", "-d"], + getDefaultValue: () => new DirectoryInfo(Environment.CurrentDirectory), + description: "Directory containing a365.config.json and a365.generated.config.json"); + + var verboseOption = new Option( + ["--verbose", "-v"], + description: "Show detailed output"); + + var dryRunOption = new Option( + "--dry-run", + description: "Show what would be done without executing"); + + var skipRequirementsOption = new Option( + "--skip-requirements", + description: "Skip requirements validation check\n" + + "Use with caution: setup may fail if prerequisites are not met"); + + var yesOption = new Option( + ["--yes", "-y"], + description: "Skip confirmation prompt and proceed automatically"); + + command.AddOption(configDirOption); + command.AddOption(verboseOption); + command.AddOption(dryRunOption); + command.AddOption(skipRequirementsOption); + command.AddOption(yesOption); + + command.SetHandler(async (System.CommandLine.Invocation.InvocationContext ctx) => + { + var configDir = ctx.ParseResult.GetValueForOption(configDirOption)!; + var dryRun = ctx.ParseResult.GetValueForOption(dryRunOption); + var skipRequirements = ctx.ParseResult.GetValueForOption(skipRequirementsOption); + var yes = ctx.ParseResult.GetValueForOption(yesOption); + var ct = ctx.GetCancellationToken(); + + var correlationId = HttpClientFactory.GenerateCorrelationId(); + logger.LogDebug("Starting setup admin (CorrelationId: {CorrelationId})", correlationId); + + if (dryRun) + { + logger.LogInformation("DRY RUN: Admin Permission Grants"); + logger.LogInformation("This would execute the following operations:"); + logger.LogInformation(""); + if (!skipRequirements) + logger.LogInformation(" 0. Validate prerequisites"); + else + logger.LogInformation(" 0. [SKIPPED] Requirements validation (--skip-requirements flag used)"); + logger.LogInformation(" 1. Load configuration from: {ConfigDir}", configDir.FullName); + logger.LogInformation(" 2. Resolve blueprint and resource service principals"); + logger.LogInformation(" 3. Create AllPrincipals OAuth2 grants for all configured resources"); + logger.LogInformation("No actual changes will be made."); + return; + } + + var setupResults = new SetupResults(); + + try + { + var configPath = Path.Combine(configDir.FullName, "a365.config.json"); + if (!File.Exists(configPath)) + { + logger.LogError( + "Configuration file not found: {ConfigPath}", + configPath); + logger.LogError( + "Ensure the Agent ID Admin has run 'a365 setup all' and shared the config folder."); + ExceptionHandler.ExitWithCleanup(1); + return; + } + + var setupConfig = await configService.LoadAsync(configPath); + + if (!string.IsNullOrWhiteSpace(setupConfig.ClientAppId)) + graphApiService.CustomClientAppId = setupConfig.ClientAppId; + + if (!skipRequirements) + { + var checks = GetChecks(authValidator); + try + { + await RequirementsSubcommand.RunChecksOrExitAsync( + checks, setupConfig, logger, ct); + } + catch (Exception reqEx) when (reqEx is not OperationCanceledException) + { + logger.LogError(reqEx, "Requirements check failed: {Message}", reqEx.Message); + logger.LogError("Rerun with --skip-requirements to bypass."); + ExceptionHandler.ExitWithCleanup(1); + } + } + + if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId)) + { + logger.LogError( + "AgentBlueprintId is missing from the generated config. " + + "Ensure 'a365 setup all' completed blueprint creation before running this command."); + ExceptionHandler.ExitWithCleanup(1); + return; + } + + // Build the same spec list as 'setup all' so all resources get grants. + var mcpManifestPath = Path.Combine( + setupConfig.DeploymentProjectPath ?? string.Empty, + McpConstants.ToolingManifestFileName); + var mcpScopes = await PermissionsSubcommand.ReadMcpScopesAsync(mcpManifestPath, logger); + var mcpResourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(setupConfig.Environment); + + var specs = new List + { + new ResourcePermissionSpec( + AuthenticationConstants.MicrosoftGraphResourceAppId, + "Microsoft Graph", + setupConfig.AgentApplicationScopes.ToArray(), + SetInheritable: false), + new ResourcePermissionSpec( + mcpResourceAppId, + "Agent 365 Tools", + mcpScopes, + SetInheritable: false), + new ResourcePermissionSpec( + ConfigConstants.MessagingBotApiAppId, + "Messaging Bot API", + new[] { "Authorization.ReadWrite", "user_impersonation" }, + SetInheritable: false), + new ResourcePermissionSpec( + ConfigConstants.ObservabilityApiAppId, + "Observability API", + new[] { "user_impersonation" }, + SetInheritable: false), + new ResourcePermissionSpec( + PowerPlatformConstants.PowerPlatformApiResourceAppId, + "Power Platform API", + new[] { "Connectivity.Connections.Read" }, + SetInheritable: false), + }; + + foreach (var customPerm in setupConfig.CustomBlueprintPermissions ?? new List()) + { + var (isValid, _) = customPerm.Validate(); + if (isValid && !string.IsNullOrWhiteSpace(customPerm.ResourceAppId)) + { + var resourceName = string.IsNullOrWhiteSpace(customPerm.ResourceName) + ? customPerm.ResourceAppId + : customPerm.ResourceName; + specs.Add(new ResourcePermissionSpec( + customPerm.ResourceAppId, + resourceName, + customPerm.Scopes.ToArray(), + SetInheritable: false)); + } + } + + // Display what will be done and ask for confirmation (unless --yes is set). + DisplayAdminConsentPreview(setupConfig, specs, logger); + + if (!yes) + { + var confirmed = await confirmationProvider.ConfirmAsync("Do you want to perform this operation? (y/N): "); + if (!confirmed) + { + logger.LogInformation("Operation cancelled."); + return; + } + } + + logger.LogInformation(""); + logger.LogInformation("Running admin permission grants... (TraceId: {TraceId})", correlationId); + if (skipRequirements) + logger.LogInformation("NOTE: Requirements validation skipped (--skip-requirements flag used)"); + + (bool grantsConfigured, string? blueprintSpObjectId) = + await BatchPermissionsOrchestrator.GrantAdminPermissionsAsync( + graphApiService, setupConfig, + setupConfig.AgentBlueprintId!, setupConfig.TenantId, + specs, logger, setupResults, ct, + knownBlueprintSpObjectId: setupConfig.AgentBlueprintServicePrincipalObjectId); + + setupResults.AdminConsentGranted = grantsConfigured; + + SetupHelpers.DisplayAdminSetupSummary(setupResults, blueprintSpObjectId, logger); + } + catch (Agent365Exception ex) + { + var logFilePath = ConfigService.GetCommandLogPath(CommandNames.Setup); + ExceptionHandler.HandleAgent365Exception(ex, logFilePath: logFilePath); + ExceptionHandler.ExitWithCleanup(1); + } + catch (FileNotFoundException fnfEx) + { + logger.LogError("Admin setup failed: {Message}", fnfEx.Message); + ExceptionHandler.ExitWithCleanup(1); + } + catch (Exception ex) + { + logger.LogError(ex, "Admin setup failed: {Message}", ex.Message); + throw; + } + }); + + return command; + } + + /// + /// Prints a preview of the OAuth2 grants that will be created, so the administrator + /// can review before approving. + /// + private static void DisplayAdminConsentPreview( + Agent365Config config, + IReadOnlyList specs, + ILogger logger) + { + var displayName = !string.IsNullOrWhiteSpace(config.AgentBlueprintDisplayName) + ? config.AgentBlueprintDisplayName + : config.AgentBlueprintId; + + logger.LogWarning("WARNING: The following OAuth2 grants will be created tenant-wide (consentType=AllPrincipals):"); + logger.LogInformation(""); + logger.LogInformation(" Blueprint : {DisplayName} ({BlueprintId})", displayName, config.AgentBlueprintId); + logger.LogInformation(" Tenant : {TenantId}", config.TenantId); + logger.LogInformation(""); + + foreach (var spec in specs) + { + if (spec.Scopes.Length == 0) continue; + logger.LogInformation(" - {ResourceName,-20}: {Scopes}", + spec.ResourceName, + string.Join(", ", spec.Scopes)); + } + + logger.LogInformation(""); + logger.LogWarning("WARNING: This gives the agent delegated consent for ALL users in the tenant."); + } +} 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 152b2de1..cc194edd 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs @@ -58,7 +58,8 @@ public static Command CreateCommand( AgentBlueprintService blueprintService, IClientAppValidator clientAppValidator, BlueprintLookupService blueprintLookupService, - FederatedCredentialService federatedCredentialService) + FederatedCredentialService federatedCredentialService, + ArmApiService? armApiService = null) { var command = new Command("all", "Run complete Agent 365 setup (all steps in sequence)\n" + @@ -97,8 +98,14 @@ public static Command CreateCommand( command.AddOption(skipInfrastructureOption); command.AddOption(skipRequirementsOption); - command.SetHandler(async (config, verbose, dryRun, skipInfrastructure, skipRequirements) => + command.SetHandler(async (System.CommandLine.Invocation.InvocationContext context) => { + var config = context.ParseResult.GetValueForOption(configOption)!; + var dryRun = context.ParseResult.GetValueForOption(dryRunOption); + var skipInfrastructure = context.ParseResult.GetValueForOption(skipInfrastructureOption); + var skipRequirements = context.ParseResult.GetValueForOption(skipRequirementsOption); + var ct = context.GetCancellationToken(); + // Generate correlation ID at workflow entry point var correlationId = HttpClientFactory.GenerateCorrelationId(); logger.LogDebug("Starting setup all (CorrelationId: {CorrelationId})", correlationId); @@ -108,7 +115,7 @@ public static Command CreateCommand( logger.LogInformation("DRY RUN: Complete Agent 365 Setup"); logger.LogInformation("This would execute the following operations:"); logger.LogInformation(""); - + if (!skipRequirements) { logger.LogInformation(" 0. Validate prerequisites (PowerShell modules, etc.)"); @@ -159,7 +166,7 @@ public static Command CreateCommand( try { await RequirementsSubcommand.RunChecksOrExitAsync( - checks, setupConfig, logger, CancellationToken.None); + checks, setupConfig, logger, ct); } catch (Exception reqEx) when (reqEx is not OperationCanceledException) { @@ -194,7 +201,9 @@ await RequirementsSubcommand.RunChecksOrExitAsync( platformDetector, setupConfig.NeedDeployment, skipInfrastructure, - CancellationToken.None); + ct, + armApiService, + graphApiService); setupResults.InfrastructureCreated = skipInfrastructure ? false : setupInfra; setupResults.InfrastructureAlreadyExisted = infraAlreadyExisted; @@ -231,32 +240,19 @@ await RequirementsSubcommand.RunChecksOrExitAsync( blueprintService, blueprintLookupService, federatedCredentialService, - skipEndpointRegistration: false, - correlationId: correlationId - ); + skipEndpointRegistration: true, + correlationId: correlationId, + options: new BlueprintCreationOptions(DeferConsent: true)); setupResults.BlueprintCreated = result.BlueprintCreated; setupResults.BlueprintAlreadyExisted = result.BlueprintAlreadyExisted; - setupResults.MessagingEndpointRegistered = result.EndpointRegistered; - setupResults.EndpointAlreadyExisted = result.EndpointAlreadyExisted; - - if (result.EndpointAlreadyExisted) - { - setupResults.Warnings.Add("Messaging endpoint already exists (not newly created)"); - } - - // If endpoint registration was attempted but failed, add to errors - // Do NOT add error if registration was skipped (--no-endpoint or missing config) - if (result.EndpointRegistrationAttempted && !result.EndpointRegistered) - { - setupResults.Errors.Add("Messaging endpoint registration failed"); - } + setupResults.ClientSecretManualActionRequired = result.ClientSecretManualActionRequired; - // Track Graph permissions status - critical for agent token exchange - setupResults.GraphPermissionsConfigured = result.GraphPermissionsConfigured; + // Graph permissions and admin consent are deferred to the batch orchestrator + // (DeferConsent: true above). Flags are updated in Step 4 after the orchestrator runs. if (result.GraphInheritablePermissionsFailed) { - setupResults.GraphInheritablePermissionsError = result.GraphInheritablePermissionsError + setupResults.GraphInheritablePermissionsError = result.GraphInheritablePermissionsError ?? "Microsoft Graph inheritable permissions failed to configure"; setupResults.Warnings.Add($"Microsoft Graph inheritable permissions: {setupResults.GraphInheritablePermissionsError}"); } @@ -265,6 +261,14 @@ await RequirementsSubcommand.RunChecksOrExitAsync( setupResults.GraphInheritablePermissionsConfigured = true; } + // Track Federated Identity Credential status + setupResults.FederatedCredentialConfigured = result.FederatedCredentialConfigured; + if (!result.FederatedCredentialConfigured && !string.IsNullOrWhiteSpace(result.FederatedCredentialError)) + { + setupResults.FederatedCredentialError = result.FederatedCredentialError; + setupResults.Warnings.Add($"Federated Identity Credential: {result.FederatedCredentialError}"); + } + if (!result.BlueprintCreated) { throw new GraphApiException( @@ -275,8 +279,8 @@ await RequirementsSubcommand.RunChecksOrExitAsync( // 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 + logger.LogDebug("Waiting for config file write to complete..."); + await Task.Delay(2000, ct); // Reload config to get blueprint ID // Use full path to ensure we're reading from the correct location @@ -291,6 +295,18 @@ await RequirementsSubcommand.RunChecksOrExitAsync( "Blueprint creation completed but AgentBlueprintId was not saved to configuration. " + "This is required for the next steps (MCP permissions and Bot permissions)."); } + + // Warn when service principal creation failed (SP object ID missing after blueprint creation). + // Setup continues because inheritable permissions use the blueprint objectId, not the SP. + // However, agent token exchange will not work until the SP exists. + if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintServicePrincipalObjectId)) + { + var spWarning = "Agent blueprint service principal was not created. " + + "Inheritable permissions and FIC may not function correctly. " + + "Run 'a365 setup blueprint' to retry SP creation."; + setupResults.Warnings.Add(spWarning); + logger.LogWarning(spWarning); + } } catch (Agent365Exception blueprintEx) { @@ -308,85 +324,115 @@ await RequirementsSubcommand.RunChecksOrExitAsync( throw; } - // Step 3: MCP Permissions + // Step 3: Configure all permissions (Graph + MCP + Bot x3 + Custom) in a single batch. + // Phase 1 — update blueprint requiredResourceAccess + resolve SPs once (non-admin). + // Phase 2 — create OAuth2 grants and inheritable permissions (non-admin). + // Phase 3 — single admin consent browser or one consolidated URL for non-admins. try { - bool mcpPermissionSetup = await PermissionsSubcommand.ConfigureMcpPermissionsAsync( - config.FullName, - logger, - configService, - executor, - graphApiService, - blueprintService, - setupConfig, - true, - setupResults); - - setupResults.McpPermissionsConfigured = mcpPermissionSetup; - if (mcpPermissionSetup) + // Pre-step: remove stale custom permissions before building the spec list. + var desiredCustomIds = new HashSet( + (setupConfig.CustomBlueprintPermissions ?? new List()) + .Select(p => p.ResourceAppId), + StringComparer.OrdinalIgnoreCase); + await PermissionsSubcommand.RemoveStaleCustomPermissionsAsync( + logger, graphApiService, blueprintService, setupConfig, desiredCustomIds, ct); + + // Build combined spec list. + var mcpManifestPath = Path.Combine( + setupConfig.DeploymentProjectPath ?? string.Empty, + McpConstants.ToolingManifestFileName); + var mcpScopes = await PermissionsSubcommand.ReadMcpScopesAsync(mcpManifestPath, logger); + var mcpResourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(setupConfig.Environment); + + var specs = new List + { + new ResourcePermissionSpec( + AuthenticationConstants.MicrosoftGraphResourceAppId, + "Microsoft Graph", + setupConfig.AgentApplicationScopes.ToArray(), + SetInheritable: true), + new ResourcePermissionSpec( + mcpResourceAppId, + "Agent 365 Tools", + mcpScopes, + SetInheritable: true), + new ResourcePermissionSpec( + ConfigConstants.MessagingBotApiAppId, + "Messaging Bot API", + new[] { "Authorization.ReadWrite", "user_impersonation" }, + SetInheritable: true), + new ResourcePermissionSpec( + ConfigConstants.ObservabilityApiAppId, + "Observability API", + new[] { "user_impersonation" }, + SetInheritable: true), + new ResourcePermissionSpec( + PowerPlatformConstants.PowerPlatformApiResourceAppId, + "Power Platform API", + new[] { "Connectivity.Connections.Read" }, + SetInheritable: true), + }; + + foreach (var customPerm in setupConfig.CustomBlueprintPermissions ?? new List()) { - setupResults.InheritablePermissionsConfigured = setupConfig.IsInheritanceConfigured(); + var (isValid, _) = customPerm.Validate(); + if (isValid && !string.IsNullOrWhiteSpace(customPerm.ResourceAppId)) + { + var resourceName = string.IsNullOrWhiteSpace(customPerm.ResourceName) + ? customPerm.ResourceAppId + : customPerm.ResourceName; + specs.Add(new ResourcePermissionSpec( + customPerm.ResourceAppId, + resourceName, + customPerm.Scopes.ToArray(), + SetInheritable: true)); + } } - } - catch (Exception mcpPermEx) - { - setupResults.McpPermissionsConfigured = false; - setupResults.Errors.Add($"MCP Permissions: {mcpPermEx.Message}"); - logger.LogWarning("MCP permissions failed: {Message}. Setup will continue, but MCP server permissions must be configured manually", mcpPermEx.Message); - } - // Step 4: Bot API Permissions - try - { - bool botPermissionSetup = await PermissionsSubcommand.ConfigureBotPermissionsAsync( - config.FullName, - logger, - configService, - executor, - setupConfig, - graphApiService, - blueprintService, - true, - setupResults); + var (blueprintPermissionsUpdated, inheritedPermissionsConfigured, consentGranted, adminConsentUrl) = + await BatchPermissionsOrchestrator.ConfigureAllPermissionsAsync( + graphApiService, blueprintService, setupConfig, + setupConfig.AgentBlueprintId!, setupConfig.TenantId, + specs, logger, setupResults, ct, + knownBlueprintSpObjectId: setupConfig.AgentBlueprintServicePrincipalObjectId); - setupResults.BotApiPermissionsConfigured = botPermissionSetup; - if (botPermissionSetup) + setupResults.BatchPermissionsPhase1Completed = blueprintPermissionsUpdated; + setupResults.BatchPermissionsPhase2Completed = inheritedPermissionsConfigured; + setupResults.AdminConsentGranted = consentGranted; + setupResults.AdminConsentUrl = adminConsentUrl; + + List? consentResourceNames = null; + if (!consentGranted && !string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId)) { - setupResults.BotInheritablePermissionsConfigured = setupConfig.IsBotInheritanceConfigured(); + consentResourceNames = SetupHelpers.PopulateAdminConsentUrls(setupConfig, mcpResourceAppId, mcpScopes); } - } - catch (Exception botPermEx) - { - setupResults.BotApiPermissionsConfigured = false; - setupResults.Errors.Add($"Bot API Permissions: {botPermEx.Message}"); - logger.LogWarning("Bot permissions failed: {Message}. Setup will continue, but Bot API permissions must be configured manually", botPermEx.Message); - } - // Step 5: Reconcile custom blueprint permissions — apply desired and remove stale entries. - // Always run (even when config is empty) to clean up any permissions no longer in config. - try - { - bool customPermissionsSetup = await PermissionsSubcommand.ConfigureCustomPermissionsAsync( - config.FullName, - logger, - configService, - executor, - graphApiService, - blueprintService, - setupConfig, - true, - setupResults); + await configService.SaveStateAsync(setupConfig); - setupResults.CustomPermissionsConfigured = customPermissionsSetup; + // Only advertise the path after the save has succeeded — the file must exist + // before we tell the caller where to find the consent URLs. + if (consentResourceNames is not null) + { + setupResults.ConsentUrlsSavedToPath = generatedConfigPath; + setupResults.ConsentResourceNames.AddRange(consentResourceNames); + setupResults.CombinedConsentUrl = SetupHelpers.BuildCombinedConsentUrl( + setupConfig.TenantId!, setupConfig.AgentBlueprintId!, + setupConfig.AgentApplicationScopes, mcpScopes); + } } - catch (Exception customPermEx) + catch (Exception permEx) { - setupResults.CustomPermissionsConfigured = false; - setupResults.Errors.Add($"Custom Blueprint Permissions: {customPermEx.Message}"); - logger.LogWarning("Custom permissions failed: {Message}. Setup will continue, but custom permissions must be configured manually", customPermEx.Message); + setupResults.BatchPermissionsPhase2Completed = false; + setupResults.AdminConsentGranted = false; + setupResults.Errors.Add($"Permissions: {permEx.Message}"); + logger.LogWarning("Permissions configuration failed: {Message}. Setup will continue, but permissions must be configured manually.", permEx.Message); } - // Display setup summary + // Step 4: Messaging endpoint registration is temporarily disabled. + + // Display verification URLs and setup summary + await SetupHelpers.DisplayVerificationInfoAsync(config, logger); logger.LogInformation(""); SetupHelpers.DisplaySetupSummary(setupResults, logger); } @@ -394,19 +440,23 @@ await RequirementsSubcommand.RunChecksOrExitAsync( { var logFilePath = ConfigService.GetCommandLogPath(CommandNames.Setup); ExceptionHandler.HandleAgent365Exception(ex, logFilePath: logFilePath); - Environment.Exit(1); + ExceptionHandler.ExitWithCleanup(1); } catch (FileNotFoundException fnfEx) { logger.LogError("Setup failed: {Message}", fnfEx.Message); ExceptionHandler.ExitWithCleanup(1); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { logger.LogError(ex, "Setup failed: {Message}", ex.Message); throw; } - }, configOption, verboseOption, dryRunOption, skipInfrastructureOption, skipRequirementsOption); + }); return command; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs new file mode 100644 index 00000000..c200713b --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs @@ -0,0 +1,763 @@ +// 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.Agents.A365.DevTools.Cli.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; + +/// +/// Orchestrates the three-phase batch permissions flow for agent blueprint setup. +/// +/// Phase 1 — Resolve service principals: +/// Pre-warms the delegated token and resolves all service principal IDs once +/// (blueprint + resources). Non-fatal: partial progress is preserved. +/// Note: requiredResourceAccess is NOT updated here — it is not supported for Agent Blueprints. +/// +/// Phase 2 — Configure permissions: +/// a) Inheritable permissions (Agent ID Administrator or Global Administrator): +/// Sets inheritable permission scopes on the blueprint via the Blueprint API, +/// then reads them back to verify they are present. Agent ID Admin can do this. +/// b) OAuth2 permission grants (Global Administrator only): +/// Creates AllPrincipals (tenant-wide) oauth2PermissionGrants via Graph API. +/// Requires Global Administrator — skipped for non-admin users. +/// Technical limitation: oauth2PermissionGrant creation via the API always requires +/// DelegatedPermissionGrant.ReadWrite.All which is an admin-only scope. Additionally, +/// GA bypasses entitlement validation and can grant any scope; non-admin users get +/// HTTP 403 (insufficient privileges) or HTTP 400 (entitlement not found) for all +/// five resource SPs. There is no self-service path for non-admin users via the API. +/// +/// Phase 3 — Admin consent (Global Administrator only): +/// For GA: skipped entirely — Phase 2b grants satisfy consent. +/// For non-admin: shows the 'a365 setup admin' command to hand off to a GA. +/// The consent URL is still generated for Graph scopes as a fallback reference. +/// +/// This class is a parallel implementation alongside SetupHelpers.EnsureResourcePermissionsAsync, +/// which remains unchanged for standalone callers and CopilotStudioSubcommand. +/// +internal static class BatchPermissionsOrchestrator +{ + /// + /// Configures permissions for all supplied resource specs in three sequential phases. + /// Each phase is non-fatal: a failure logs a warning and continues to the next phase, + /// so partial progress is preserved and the caller can report what succeeded. + /// + /// Graph API service (used for SP lookups, OAuth2 grants, admin check). + /// Blueprint service (used for requiredResourceAccess and inheritable permissions). + /// Agent365 configuration — ResourceConsents is updated in-memory on success. + /// Application (client) ID of the agent blueprint. + /// Tenant ID. + /// Ordered list of resource permission specs to configure. + /// Logger instance. + /// Optional setup results for tracking warnings (may be null for standalone commands). + /// Cancellation token. + /// + /// Tuple of (blueprintPermissionsUpdated, inheritedPermissionsConfigured, adminConsentGranted, adminConsentUrl). + /// adminConsentUrl is non-null only when the current user is not an admin and consent was not already present. + /// + public static async Task<(bool blueprintPermissionsUpdated, bool inheritedPermissionsConfigured, bool adminConsentGranted, string? adminConsentUrl)> + ConfigureAllPermissionsAsync( + GraphApiService graph, + AgentBlueprintService blueprintService, + Agent365Config config, + string blueprintAppId, + string tenantId, + IReadOnlyList specs, + ILogger logger, + SetupResults? setupResults, + CancellationToken ct, + string? knownBlueprintSpObjectId = null) + { + if (specs.Count == 0) + { + logger.LogInformation("No permission specs provided — skipping batch permissions configuration."); + return (true, true, true, null); + } + + // Filter out specs with no scopes — they would produce empty OAuth2 grants (HTTP 400). + // This can happen when the MCP manifest is missing or contains no required scopes. + var effectiveSpecs = specs.Where(s => s.Scopes.Length > 0).ToList(); + if (effectiveSpecs.Count < specs.Count) + { + var skipped = specs.Count - effectiveSpecs.Count; + logger.LogDebug("Skipping {Count} resource spec(s) with no scopes (manifest missing or empty).", skipped); + } + + if (effectiveSpecs.Count == 0) + { + logger.LogInformation("All permission specs have empty scope lists — skipping batch permissions configuration."); + return (true, true, true, null); + } + + // Use filtered list for all downstream phases + specs = effectiveSpecs; + + var permScopes = AuthenticationConstants.RequiredPermissionGrantScopes; + + // --- Resolve service principals --- + logger.LogInformation(""); + logger.LogInformation("Resolving service principals..."); + + BlueprintPermissionsResult? phase1Result = null; + var blueprintPermissionsUpdated = false; + try + { + phase1Result = await UpdateBlueprintPermissionsAsync( + graph, blueprintAppId, tenantId, specs, permScopes, logger, ct, + knownBlueprintSpObjectId); + blueprintPermissionsUpdated = true; + } + catch (Exception ex) + { + logger.LogWarning("Failed to resolve service principals: {Message}. Continuing.", ex.Message); + } + + // Check admin role once — reused by both Phase 2b (grants) and Phase 3 (consent check). + // Avoids a duplicate Graph call later. + // If Phase 1 failed (phase1Result == null), default to DoesNotHaveRole: we cannot + // authenticate, so interactive consent is impossible — return the URL instead of + // opening a browser. + var adminCheck = phase1Result != null + ? await graph.IsCurrentUserAdminAsync(tenantId, ct) + : Models.RoleCheckResult.DoesNotHaveRole; + var isGlobalAdmin = adminCheck == Models.RoleCheckResult.HasRole; + + // --- Phase 2a: Inheritable permissions (Agent ID Admin or GA) --- + // --- Phase 2b: OAuth2 grants (Global Administrator only) --- + logger.LogInformation(""); + logger.LogInformation("Configuring inheritable permissions and OAuth2 grants..."); + + var inheritedPermissionsConfigured = false; + Dictionary inheritedResults = + new(StringComparer.OrdinalIgnoreCase); + + if (phase1Result == null) + { + logger.LogWarning("Skipping permissions configuration: authentication to Microsoft Graph failed."); + } + else + { + // Phase 2a: Inheritable permissions — Agent ID Admin and GA can both set these. + // If the user lacks the required role, SetInheritablePermissionsAsync returns 403 + // which is caught via IsInsufficientPrivilegesError — one consolidated warning is + // emitted and remaining specs are skipped. + try + { + inheritedResults = await ConfigureInheritedPermissionsAsync( + graph, blueprintService, blueprintAppId, tenantId, specs, + phase1Result, permScopes, logger, setupResults, ct); + + var inheritableSpecs = specs.Where(s => s.SetInheritable).ToList(); + inheritedPermissionsConfigured = inheritableSpecs.Count == 0 || + inheritableSpecs.All(s => + inheritedResults.TryGetValue(s.ResourceAppId, out var r) && r.configured); + } + catch (Exception ex) + { + logger.LogWarning("Failed to configure inheritable permissions: {Message}. Continuing.", ex.Message); + } + + // Phase 2b: OAuth2 grants — Global Administrator only. + // Technical limitation: oauth2PermissionGrant creation via the Graph API requires + // DelegatedPermissionGrant.ReadWrite.All (admin-only scope). GA also bypasses + // entitlement validation. Non-admin users always get 403 or 400 for all resources. + if (isGlobalAdmin) + { + var grantsOk = await ConfigureOauth2GrantsAsync( + graph, blueprintAppId, tenantId, specs, phase1Result, permScopes, logger, ct); + + logger.LogInformation(""); + if (grantsOk) + { + logger.LogInformation("Admin consent granted (tenant-wide grants configured in Phase 2)."); + UpdateResourceConsents(config, specs, inheritedResults); + return (blueprintPermissionsUpdated, inheritedPermissionsConfigured, true, null); + } + + // Grants failed (e.g. SP propagation lag). Return false so the summary shows + // the failure and next steps (re-run 'a365 setup admin'). + logger.LogWarning("OAuth2 grants failed — the service principal may still be propagating."); + logger.LogWarning("Re-run 'a365 setup admin' to retry once propagation is complete."); + var graphScopes = specs + .Where(s => s.ResourceAppId == AuthenticationConstants.MicrosoftGraphResourceAppId) + .SelectMany(s => s.Scopes.Select(scope => $"{graph.GraphBaseUrl}/{scope}")) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + var retryConsentUrl = graphScopes.Count > 0 + ? SetupHelpers.BuildAdminConsentUrl(tenantId, blueprintAppId, graphScopes) + : null; + return (blueprintPermissionsUpdated, inheritedPermissionsConfigured, false, retryConsentUrl); + } + } + + // --- Admin consent --- + var (consentGranted, consentUrl) = await GrantAdminConsentAsync( + graph, config, blueprintAppId, tenantId, specs, phase1Result, permScopes, logger, setupResults, ct, adminCheck); + + // Update in-memory ResourceConsents so subsequent runs detect existing state. + // The caller is responsible for persisting changes via configService.SaveStateAsync. + if (consentGranted && phase1Result != null) + { + UpdateResourceConsents(config, specs, inheritedResults); + } + + string? adminConsentUrl = consentGranted ? null : consentUrl; + return (blueprintPermissionsUpdated, inheritedPermissionsConfigured, consentGranted, adminConsentUrl); + } + + /// + /// Phase 1: Pre-warms the delegated token, resolves the blueprint service principal once + /// (with retry for propagation), then resolves each resource service principal. + /// Note: requiredResourceAccess is not updated here — it is not supported for Agent Blueprints. + /// + private static async Task UpdateBlueprintPermissionsAsync( + GraphApiService graph, + string blueprintAppId, + string tenantId, + IReadOnlyList specs, + string[] permScopes, + ILogger logger, + CancellationToken ct, + string? knownBlueprintSpObjectId = null) + { + // 0. Pre-warm delegated token once — prevents bouncing between auth providers + // for subsequent Graph calls in this phase. + var prewarmScopes = permScopes.ToArray(); + using var user = await graph.GraphGetAsync(tenantId, "/v1.0/me?$select=id", ct, scopes: prewarmScopes); + if (user == null) + { + throw new SetupValidationException( + "Failed to authenticate to Microsoft Graph with delegated permissions. " + + "Check the errors above for the specific cause."); + } + + // 1. Attempt to resolve blueprint SP once (no retry). + // Agent Blueprint SPs are not queryable via the standard /v1.0/servicePrincipals endpoint — + // the lookup is expected to return null. Logged at debug level only to avoid console noise. + // Non-fatal: OAuth2 grants are skipped when unresolvable; inheritable permissions use app ID directly. + string? blueprintSpObjectId = !string.IsNullOrWhiteSpace(knownBlueprintSpObjectId) + ? knownBlueprintSpObjectId + : await graph.LookupServicePrincipalByAppIdAsync(tenantId, blueprintAppId, ct, permScopes); + + logger.LogDebug( + blueprintSpObjectId != null + ? "Blueprint service principal resolved: {SpObjectId}" + : "Blueprint service principal not found for {AppId} — OAuth2 grants will be skipped.", + blueprintSpObjectId ?? blueprintAppId); + + // 2. Per spec: ensure resource service principal exists (creates it if absent). + var resourceSpObjectIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var spec in specs) + { + try + { + var resourceSpId = await graph.EnsureServicePrincipalForAppIdAsync( + tenantId, spec.ResourceAppId, ct, permScopes); + + if (!string.IsNullOrWhiteSpace(resourceSpId)) + { + resourceSpObjectIds[spec.ResourceAppId] = resourceSpId; + logger.LogDebug(" - Resolved {ResourceName} SP: {SpId}", spec.ResourceName, resourceSpId); + } + else + { + logger.LogWarning( + " - Service principal not found for {ResourceName} ({ResourceAppId}). " + + "Phase 2 grants will be skipped for this resource.", + spec.ResourceName, spec.ResourceAppId); + } + } + catch (Exception ex) + { + logger.LogWarning( + " - Failed to resolve service principal for {ResourceName}: {Message}. " + + "Phase 2 grants will be skipped for this resource.", + spec.ResourceName, ex.Message); + } + } + + return new BlueprintPermissionsResult(blueprintSpObjectId ?? string.Empty, resourceSpObjectIds); + } + + /// + /// Phase 2a: Sets inheritable permissions on the blueprint for each spec, then reads them + /// back to verify they are present. Uses the blueprint app ID directly (not SP object ID). + /// Agent ID Administrator and Global Administrator can both perform this operation. + /// Returns per-spec results indicating whether each resource's permissions are confirmed present. + /// + private static async Task> + ConfigureInheritedPermissionsAsync( + GraphApiService graph, + AgentBlueprintService blueprintService, + string blueprintAppId, + string tenantId, + IReadOnlyList specs, + BlueprintPermissionsResult phase1Result, + string[] permScopes, + ILogger logger, + SetupResults? setupResults, + CancellationToken ct) + { + var inheritedResults = new Dictionary( + StringComparer.OrdinalIgnoreCase); + + // Track whether we have detected a systemic "Insufficient privileges" failure. + // On the first such failure we skip all remaining inheritable specs and emit one + // consolidated warning instead of one warning per resource. + var insufficientPrivilegesDetected = false; + + foreach (var spec in specs) + { + if (!spec.SetInheritable) + { + inheritedResults[spec.ResourceAppId] = (configured: false, alreadyExisted: false); + continue; + } + + if (insufficientPrivilegesDetected) + { + inheritedResults[spec.ResourceAppId] = (configured: false, alreadyExisted: false); + continue; + } + + logger.LogDebug( + " - Configuring inheritable permissions: {ResourceName} [{Scopes}]", + spec.ResourceName, string.Join(' ', spec.Scopes)); + + var (ok, alreadyExists, err) = await blueprintService.SetInheritablePermissionsAsync( + tenantId, blueprintAppId, spec.ResourceAppId, spec.Scopes, + requiredScopes: permScopes, ct); + + if (alreadyExists || ok) + { + // Read back to confirm the scopes are present — trust the API response only + // after verification so that transient write failures do not silently pass. + var (verified, verifiedScopes, verifyErr) = await blueprintService.VerifyInheritablePermissionsAsync( + tenantId, blueprintAppId, spec.ResourceAppId, ct, permScopes); + + if (verified) + { + inheritedResults[spec.ResourceAppId] = (configured: true, alreadyExisted: alreadyExists); + var verb = alreadyExists ? "already configured" : "configured"; + logger.LogInformation(" - {ResourceName}: inheritable permissions {Verb}", spec.ResourceName, verb); + } + else + { + inheritedResults[spec.ResourceAppId] = (configured: false, alreadyExisted: false); + logger.LogWarning( + " - Inheritable permissions set for {ResourceName} but verification read-back failed: {Error}", + spec.ResourceName, verifyErr ?? "not found in read-back"); + setupResults?.Warnings.Add( + $"Inheritable permissions for {spec.ResourceName} could not be verified after setting."); + } + } + else + { + inheritedResults[spec.ResourceAppId] = (configured: false, alreadyExisted: false); + var friendlyErr = TryExtractGraphErrorMessage(err) ?? err; + + if (IsInsufficientPrivilegesError(err)) + { + insufficientPrivilegesDetected = true; + logger.LogWarning( + "Inheritable permissions require the Agent ID Administrator or Global Administrator role. " + + "Remaining inheritable permission specs will be skipped."); + setupResults?.Warnings.Add( + "Inheritable permissions require the Agent ID Administrator or Global Administrator role."); + } + else + { + logger.LogWarning( + " - Failed to configure inheritable permissions for {ResourceName}: {Error}", + spec.ResourceName, friendlyErr); + setupResults?.Warnings.Add( + $"Failed to configure inheritable permissions for {spec.ResourceName}: {friendlyErr}"); + } + } + } + + return inheritedResults; + } + + /// + /// Phase 2b: Creates AllPrincipals (tenant-wide) OAuth2 permission grants for all specs. + /// Requires Global Administrator. Only called when the current user is confirmed GA. + /// Returns true if all grants succeeded, false if any grant failed. + /// + private static async Task ConfigureOauth2GrantsAsync( + GraphApiService graph, + string blueprintAppId, + string tenantId, + IReadOnlyList specs, + BlueprintPermissionsResult phase1Result, + string[] permScopes, + ILogger logger, + CancellationToken ct) + { + var hasBlueprintSp = !string.IsNullOrWhiteSpace(phase1Result.BlueprintSpObjectId); + if (!hasBlueprintSp) + { + logger.LogDebug("Skipping OAuth2 grants: blueprint SP was not resolved."); + return false; + } + + var allGrantsOk = true; + foreach (var spec in specs) + { + if (!phase1Result.ResourceSpObjectIds.TryGetValue(spec.ResourceAppId, out var resourceSpId)) + { + logger.LogDebug( + " - Skipping OAuth2 grant for {ResourceName}: resource SP not resolved.", + spec.ResourceName); + allGrantsOk = false; + continue; + } + + logger.LogDebug( + " - OAuth2 grant (AllPrincipals): blueprint -> {ResourceName} [{Scopes}]", + spec.ResourceName, string.Join(' ', spec.Scopes)); + + var grantResult = await graph.CreateOrUpdateOauth2PermissionGrantAsync( + tenantId, + phase1Result.BlueprintSpObjectId, + resourceSpId, + spec.Scopes, + ct, + permScopes); + + if (!grantResult) + { + logger.LogWarning(" - Failed to create OAuth2 permission grant for {ResourceName}.", spec.ResourceName); + allGrantsOk = false; + } + else + logger.LogInformation(" - OAuth2 grant configured for {ResourceName}", spec.ResourceName); + } + + return allGrantsOk; + } + + /// + /// Phase 3: Checks for existing consent (skips browser if found), then either opens the + /// browser for admins or returns a consolidated consent URL for non-admins. + /// Updates config.ResourceConsents indirectly via the caller after this method returns. + /// + private static async Task<(bool granted, string? consentUrl)> + GrantAdminConsentAsync( + GraphApiService graph, + Agent365Config config, + string blueprintAppId, + string tenantId, + IReadOnlyList specs, + BlueprintPermissionsResult? phase1Result, + string[] permScopes, + ILogger logger, + SetupResults? setupResults, + CancellationToken ct, + Models.RoleCheckResult adminCheck = Models.RoleCheckResult.Unknown) + { + // Build a consent URL covering Microsoft Graph delegated scopes only. + // The /v2.0/adminconsent scope= parameter accepts only standard OAuth2 delegated scopes. + // Non-Graph scopes (Bot API Authorization.ReadWrite, Agent Blueprint inheritable permissions, + // MCP server scopes) are blueprint-specific and cannot be consented via this URL — they are + // configured via the Agent Blueprint API (inheritable permissions) or are not OAuth2 scopes + // at all. Including them causes AADSTS650053 (unknown scope on Graph) or AADSTS500011 + // (resource SP not found via api:// identifier URI). + var graphScopes = specs + .Where(s => s.ResourceAppId == AuthenticationConstants.MicrosoftGraphResourceAppId) + .SelectMany(s => s.Scopes.Select(scope => $"https://graph.microsoft.com/{scope}")) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + // If there are no Graph scopes to consent to (e.g. agent config has no agentApplicationScopes), + // skip Phase 3 entirely — there is nothing to grant via the admin consent URL. + if (graphScopes.Count == 0) + { + logger.LogInformation("No Microsoft Graph scopes require admin consent — skipping consent URL."); + return (true, null); + } + + var consentUrl = SetupHelpers.BuildAdminConsentUrl(tenantId, blueprintAppId, graphScopes); + + // Check if consent already exists for ALL resolved resources (Phase 2 programmatic grants satisfy this check). + // Only skip browser consent if every resource has its consent in place. + if (phase1Result != null && !string.IsNullOrWhiteSpace(phase1Result.BlueprintSpObjectId)) + { + var specsWithResolvedSp = specs + .Where(s => phase1Result.ResourceSpObjectIds.ContainsKey(s.ResourceAppId)) + .ToList(); + + if (specsWithResolvedSp.Count > 0) + { + bool allConsented = true; + foreach (var spec in specsWithResolvedSp) + { + if (!phase1Result.ResourceSpObjectIds.TryGetValue(spec.ResourceAppId, out var resourceSpId)) + { + allConsented = false; + break; + } + + var consentExists = await AdminConsentHelper.CheckConsentExistsAsync( + graph, + tenantId, + phase1Result.BlueprintSpObjectId, + resourceSpId, + spec.Scopes, + logger, + ct, + scopes: permScopes); + + if (!consentExists) + { + allConsented = false; + break; + } + } + + if (allConsented) + { + logger.LogInformation("Admin consent already granted — skipping browser consent."); + return (true, consentUrl); + } + } + } + + // Consent not yet detected — check whether the current user can grant it interactively. + // adminCheck was resolved before Phase 2 and passed in to avoid a duplicate Graph call. + // When phase1Result is null, auth failed entirely — the message must reflect that, not imply + // we performed a role check and found the user lacks the GA role. + if (adminCheck == Models.RoleCheckResult.DoesNotHaveRole) + { + return (false, consentUrl); + } + + if (adminCheck == Models.RoleCheckResult.Unknown) + { + logger.LogDebug("Admin role check inconclusive — attempting consent anyway; API will surface any permission error."); + } + + // Admin path: open browser and poll for the grant. + // Note: this URL covers Microsoft Graph delegated scopes only (non-Graph resources use inheritable permissions). + logger.LogInformation("Opening browser for Microsoft Graph admin consent..."); + logger.LogInformation( + "If the browser does not open automatically, navigate to this URL: {ConsentUrl}", consentUrl); + BrowserHelper.TryOpenUrl(consentUrl, logger); + + bool consentGranted; + if (phase1Result != null && !string.IsNullOrWhiteSpace(phase1Result.BlueprintSpObjectId)) + { + consentGranted = await AdminConsentHelper.PollAdminConsentAsync( + graph, logger, tenantId, phase1Result.BlueprintSpObjectId, + "All permissions", timeoutSeconds: 180, intervalSeconds: 5, ct); + } + else + { + // Phase 1 did not resolve blueprint SP — cannot poll. Surface URL for manual completion. + logger.LogWarning( + "Cannot poll for consent: blueprint service principal was not resolved. " + + "Please verify consent was granted at: {ConsentUrl}", consentUrl); + consentGranted = false; + } + + if (consentGranted) + { + logger.LogInformation("Admin consent granted successfully."); + } + else + { + logger.LogWarning( + "Admin consent was not detected within the timeout. " + + "You can re-run this command after granting consent at: {ConsentUrl}", consentUrl); + setupResults?.Warnings.Add($"Admin consent not detected within timeout. Grant at: {consentUrl}"); + } + + return (consentGranted, consentGranted ? null : consentUrl); + } + + /// + /// Updates config.ResourceConsents in-memory for each spec based on phase results. + /// The caller is responsible for persisting the config via configService.SaveStateAsync. + /// + private static void UpdateResourceConsents( + Agent365Config config, + IReadOnlyList specs, + Dictionary inheritedResults) + { + foreach (var spec in specs) + { + inheritedResults.TryGetValue(spec.ResourceAppId, out var inherited); + + var existing = config.ResourceConsents.FirstOrDefault(rc => + rc.ResourceAppId.Equals(spec.ResourceAppId, StringComparison.OrdinalIgnoreCase)); + + if (existing != null) + { + existing.ConsentGranted = true; + existing.ConsentTimestamp = DateTime.UtcNow; + existing.Scopes = spec.Scopes.ToList(); + existing.InheritablePermissionsConfigured = inherited.configured; + existing.InheritablePermissionsAlreadyExist = inherited.alreadyExisted; + existing.InheritablePermissionsError = null; + } + else + { + config.ResourceConsents.Add(new ResourceConsent + { + ResourceName = spec.ResourceName, + ResourceAppId = spec.ResourceAppId, + ConsentGranted = true, + ConsentTimestamp = DateTime.UtcNow, + Scopes = spec.Scopes.ToList(), + InheritablePermissionsConfigured = inherited.configured, + InheritablePermissionsAlreadyExist = inherited.alreadyExisted, + InheritablePermissionsError = null + }); + } + } + } + + /// + /// Returns true when the Graph error response indicates a role-based access failure + /// (HTTP 403 "Insufficient privileges"). Used to distinguish systemic role failures + /// from per-resource configuration errors in Phase 2. + /// + private static bool IsInsufficientPrivilegesError(string? err) + { + if (string.IsNullOrWhiteSpace(err)) return false; + return err.Contains("Insufficient privileges", StringComparison.OrdinalIgnoreCase) + || err.Contains("Authorization_RequestDenied", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Extracts the human-readable message from a Graph API JSON error response. + /// Returns null if the input is not a parseable Graph error body. + /// + private static string? TryExtractGraphErrorMessage(string? err) + { + if (string.IsNullOrWhiteSpace(err)) return null; + try + { + using var doc = System.Text.Json.JsonDocument.Parse(err); + if (doc.RootElement.TryGetProperty("error", out var errorEl) && + errorEl.TryGetProperty("message", out var msgEl)) + return msgEl.GetString(); + } + catch { /* not JSON — return null so caller uses raw value */ } + return null; + } + + /// + /// Carries resolved service principal IDs from Phase 1 to Phases 2 and 3, + /// eliminating the need for per-phase SP lookups. + /// + private record BlueprintPermissionsResult( + string BlueprintSpObjectId, + IReadOnlyDictionary ResourceSpObjectIds); + + /// + /// Entry point for 'a365 setup admin'. Performs only Phase 1 (SP resolution) and + /// Phase 2b (AllPrincipals OAuth2 grants). Inheritable permissions are assumed to + /// have been set already by 'a365 setup all' run by an Agent ID Admin. + /// Returns the blueprint SP object ID for the verification query, and a boolean + /// indicating whether all grants were configured successfully. + /// + public static async Task<(bool grantsConfigured, string? blueprintSpObjectId)> + GrantAdminPermissionsAsync( + GraphApiService graph, + Agent365Config config, + string blueprintAppId, + string tenantId, + IReadOnlyList specs, + ILogger logger, + SetupResults setupResults, + CancellationToken ct, + string? knownBlueprintSpObjectId = null) + { + if (specs.Count == 0) + { + logger.LogInformation("No permission specs provided — nothing to grant."); + return (true, null); + } + + var effectiveSpecs = specs.Where(s => s.Scopes.Length > 0).ToList(); + if (effectiveSpecs.Count == 0) + { + logger.LogInformation("All permission specs have empty scope lists — nothing to grant."); + return (true, null); + } + + var permScopes = AuthenticationConstants.RequiredPermissionGrantScopes; + + // Phase 1: resolve SPs + logger.LogInformation(""); + logger.LogInformation("Resolving service principals..."); + + BlueprintPermissionsResult? phase1Result = null; + try + { + phase1Result = await UpdateBlueprintPermissionsAsync( + graph, blueprintAppId, tenantId, effectiveSpecs, permScopes, logger, ct, + knownBlueprintSpObjectId); + } + catch (Exception ex) + { + logger.LogWarning("Failed to resolve service principals: {Message}. Cannot continue.", ex.Message); + setupResults.Errors.Add($"Service principal resolution failed: {ex.Message}"); + return (false, null); + } + + // Phase 2b: AllPrincipals grants (GA only — this command is only for GA) + logger.LogInformation(""); + logger.LogInformation("Configuring OAuth2 permission grants (tenant-wide)..."); + + var allGrantsOk = true; + foreach (var spec in effectiveSpecs) + { + if (!phase1Result.ResourceSpObjectIds.TryGetValue(spec.ResourceAppId, out var resourceSpId)) + { + logger.LogWarning(" - Skipping OAuth2 grant for {ResourceName}: resource SP not resolved.", spec.ResourceName); + allGrantsOk = false; + continue; + } + + if (string.IsNullOrWhiteSpace(phase1Result.BlueprintSpObjectId)) + { + logger.LogWarning(" - Skipping OAuth2 grant for {ResourceName}: blueprint SP not resolved.", spec.ResourceName); + allGrantsOk = false; + continue; + } + + logger.LogDebug( + " - OAuth2 grant (AllPrincipals): blueprint -> {ResourceName} [{Scopes}]", + spec.ResourceName, string.Join(' ', spec.Scopes)); + + var grantResult = await graph.CreateOrUpdateOauth2PermissionGrantAsync( + tenantId, + phase1Result.BlueprintSpObjectId, + resourceSpId, + spec.Scopes, + ct, + permScopes); + + if (!grantResult) + { + logger.LogWarning(" - Failed to create OAuth2 permission grant for {ResourceName}.", spec.ResourceName); + setupResults.Warnings.Add($"OAuth2 grant failed for {spec.ResourceName}. Check GA permissions."); + allGrantsOk = false; + } + else + { + logger.LogInformation(" - OAuth2 grant configured for {ResourceName}", spec.ResourceName); + } + } + + return (allGrantsOk, phase1Result.BlueprintSpObjectId); + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintCreationOptions.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintCreationOptions.cs new file mode 100644 index 00000000..8d240abe --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintCreationOptions.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; + +/// +/// Options that control blueprint creation behavior in the setup orchestration. +/// +/// +/// When true, the blueprint step skips admin consent and the Graph inheritable permissions +/// call that follows it. The caller (e.g. AllSubcommand) is responsible for running consent +/// as a separate phase via BatchPermissionsOrchestrator. +/// This is an orchestration flag — it is NOT tied to whether the current user is an admin. +/// Standalone 'setup blueprint' uses the default value of false so consent runs normally. +/// +internal record BlueprintCreationOptions(bool DeferConsent = false); 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 6c047629..b7ce3663 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs @@ -37,6 +37,12 @@ internal class BlueprintCreationResult /// public bool EndpointRegistrationAttempted { get; set; } + /// + /// The reason endpoint registration failed, when EndpointRegistered is false and EndpointRegistrationAttempted is true. + /// Null if registration succeeded or was not attempted. + /// + public string? EndpointRegistrationFailureReason { get; set; } + /// /// Indicates whether Graph admin consent (OAuth2 permissions) was granted. /// @@ -50,6 +56,30 @@ internal class BlueprintCreationResult /// Error message when Graph inheritable permissions fail. /// public string? GraphInheritablePermissionsError { get; set; } + + /// + /// True when the client secret could not be created automatically (e.g. Forbidden) and + /// the user must create it manually and re-run setup. The summary should surface this as + /// an Action Required item. + /// + public bool ClientSecretManualActionRequired { get; set; } + + /// + /// Indicates whether the Federated Identity Credential was successfully configured. + /// When false and MSI was expected, agent token exchange will not work at runtime. + /// + public bool FederatedCredentialConfigured { get; set; } + + /// + /// Error message when Federated Identity Credential configuration fails. + /// + public string? FederatedCredentialError { get; set; } + + /// + /// The admin consent URL when consent was not granted because the current user lacks an admin role. + /// Non-null indicates a tenant administrator must complete consent at this URL. + /// + public string? AdminConsentUrl { get; set; } } /// @@ -72,7 +102,6 @@ internal static class BlueprintSubcommand { var checks = new List(SetupCommand.GetBaseChecks(auth)) { - new LocationRequirementCheck(), new ClientAppRequirementCheck(clientAppValidator), }; @@ -137,8 +166,17 @@ public static Command CreateCommand( command.AddOption(updateEndpointOption); command.AddOption(skipRequirementsOption); - command.SetHandler(async (config, verbose, dryRun, skipEndpointRegistration, endpointOnly, updateEndpoint, skipRequirements) => + command.SetHandler(async (System.CommandLine.Invocation.InvocationContext context) => { + var config = context.ParseResult.GetValueForOption(configOption)!; + var verbose = context.ParseResult.GetValueForOption(verboseOption); + var dryRun = context.ParseResult.GetValueForOption(dryRunOption); + var skipEndpointRegistration = context.ParseResult.GetValueForOption(skipEndpointRegistrationOption); + var endpointOnly = context.ParseResult.GetValueForOption(endpointOnlyOption); + var updateEndpoint = context.ParseResult.GetValueForOption(updateEndpointOption); + var skipRequirements = context.ParseResult.GetValueForOption(skipRequirementsOption); + var ct = context.GetCancellationToken(); + // Generate correlation ID at workflow entry point var correlationId = HttpClientFactory.GenerateCorrelationId(); logger.LogDebug("Starting blueprint setup (CorrelationId: {CorrelationId})", correlationId); @@ -162,32 +200,16 @@ public static Command CreateCommand( graphApiService.CustomClientAppId = setupConfig.ClientAppId; } + // Wire the sovereign/government cloud base URL from config so all Graph calls + // target the correct national cloud endpoint (commercial by default). + graphApiService.GraphBaseUrl = setupConfig.GraphBaseUrl; + // Handle --update-endpoint flag if (!string.IsNullOrWhiteSpace(updateEndpoint)) { - try - { - await UpdateEndpointAsync( - configPath: config.FullName, - newEndpointUrl: updateEndpoint, - logger: logger, - configService: configService, - botConfigurator: botConfigurator, - platformDetector: platformDetector, - correlationId: correlationId); - } - catch (Agent365Exception ex) - { - var logFilePath = ConfigService.GetCommandLogPath(CommandNames.Setup); - ExceptionHandler.HandleAgent365Exception(ex, logger: logger, logFilePath: logFilePath); - ExceptionHandler.ExitWithCleanup(ex.ExitCode); - } - catch (Exception ex) - { - logger.LogError("Endpoint update failed: {Message}", ex.Message); - logger.LogDebug(ex, "Endpoint update failed - stack trace"); - ExceptionHandler.ExitWithCleanup(1); - } + logger.LogInformation("Endpoint registration via the CLI is not supported for blueprint-based agents."); + logger.LogInformation("Configure the messaging endpoint directly in the Teams Developer Portal:"); + logger.LogInformation(" https://learn.microsoft.com/microsoft-agent-365/developer/create-instance#1-configure-agent-in-teams-developer-portal"); return; } @@ -202,7 +224,7 @@ await UpdateEndpointAsync( { var checks = BlueprintSubcommand.GetChecks(authValidator, clientAppValidator); await RequirementsSubcommand.RunChecksOrExitAsync( - checks, setupConfig, logger, CancellationToken.None); + checks, setupConfig, logger, ct); } catch (Exception reqEx) when (reqEx is not OperationCanceledException) { @@ -231,32 +253,9 @@ await RequirementsSubcommand.RunChecksOrExitAsync( // Handle --endpoint-only flag if (endpointOnly) { - try - { - logger.LogInformation("Registering blueprint messaging endpoint..."); - logger.LogInformation(""); - - await RegisterEndpointAndSyncAsync( - configPath: config.FullName, - logger: logger, - configService: configService, - botConfigurator: botConfigurator, - platformDetector: platformDetector, - correlationId: correlationId); - - logger.LogInformation(""); - logger.LogInformation("Endpoint registration completed successfully!"); - } - catch (Exception ex) - { - logger.LogError(ex, "Endpoint registration failed: {Message}", ex.Message); - logger.LogError(""); - logger.LogError("To resolve this issue:"); - logger.LogError(" 1. If endpoint already exists, delete it: a365 cleanup blueprint --endpoint-only"); - logger.LogError(" 2. Verify your messaging endpoint configuration in a365.config.json"); - logger.LogError(" 3. Try registration again: a365 setup blueprint --endpoint-only"); - Environment.Exit(1); - } + logger.LogInformation("Endpoint registration via the CLI is not supported for blueprint-based agents."); + logger.LogInformation("Configure the messaging endpoint directly in the Teams Developer Portal:"); + logger.LogInformation(" https://learn.microsoft.com/microsoft-agent-365/developer/create-instance#1-configure-agent-in-teams-developer-portal"); return; } @@ -280,7 +279,7 @@ await CreateBlueprintImplementationAsync( correlationId: correlationId ); - }, configOption, verboseOption, dryRunOption, skipEndpointRegistrationOption, endpointOnlyOption, updateEndpointOption, skipRequirementsOption); + }); return command; } @@ -342,25 +341,13 @@ public static async Task CreateBlueprintImplementationA FederatedCredentialService federatedCredentialService, bool skipEndpointRegistration = false, string? correlationId = null, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + BlueprintCreationOptions? options = null, + Func>? loginHintResolver = null) { - // Validate location before logging the header — prevents confusing output where the heading - // appears but setup immediately fails due to a missing config value. - if (!skipEndpointRegistration && string.IsNullOrWhiteSpace(setupConfig.Location)) - { - logger.LogError(ErrorMessages.EndpointLocationRequiredForCreate); - logger.LogInformation(ErrorMessages.EndpointLocationAddToConfig); - logger.LogInformation(ErrorMessages.EndpointLocationExample); - return new BlueprintCreationResult - { - BlueprintCreated = false, - EndpointRegistered = false, - EndpointRegistrationAttempted = false - }; - } - logger.LogInformation(""); logger.LogInformation("==> Creating Agent Blueprint"); + logger.LogInformation(""); var generatedConfigPath = Path.Combine( config.DirectoryName ?? Environment.CurrentDirectory, @@ -398,7 +385,8 @@ public static async Task CreateBlueprintImplementationA cleanLoggerFactory.CreateLogger(), new GraphApiService( cleanLoggerFactory.CreateLogger(), - executor)); + executor, + graphBaseUrl: setupConfig.GraphBaseUrl)); // Use DI-provided GraphApiService which already has MicrosoftGraphTokenProvider configured var graphService = graphApiService; @@ -457,7 +445,9 @@ public static async Task CreateBlueprintImplementationA setupConfig, configService, config, - cancellationToken); + cancellationToken, + options, + loginHintResolver: loginHintResolver); if (!blueprintResult.success) { @@ -486,6 +476,18 @@ public static async Task CreateBlueprintImplementationA generatedConfig["resourceConsents"] = new JsonArray(); } + // Always write messagingEndpoint to the generated config so it's available + // for Developer Portal configuration regardless of whether endpoint registration ran. + // NeedDeployment=true: derive from WebAppName; NeedDeployment=false: copy from static config. + var derivedMessagingEndpoint = setupConfig.NeedDeployment && !string.IsNullOrWhiteSpace(setupConfig.WebAppName) + ? $"https://{setupConfig.WebAppName}.azurewebsites.net/api/messages" + : setupConfig.MessagingEndpoint; + if (!string.IsNullOrWhiteSpace(derivedMessagingEndpoint)) + { + generatedConfig["messagingEndpoint"] = derivedMessagingEndpoint; + setupConfig.BotMessagingEndpoint = derivedMessagingEndpoint; + } + await File.WriteAllTextAsync(generatedConfigPath, generatedConfig.ToJsonString(new JsonSerializerOptions { WriteIndented = true }), cancellationToken); // ======================================================================== @@ -493,6 +495,7 @@ public static async Task CreateBlueprintImplementationA // ======================================================================== // Skip secret creation if blueprint already existed and secret is already configured + bool clientSecretManualActionRequired; if (blueprintAlreadyExisted && !string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintClientSecret)) { logger.LogInformation("Validating existing client secret..."); @@ -507,28 +510,33 @@ public static async Task CreateBlueprintImplementationA if (isValid) { logger.LogInformation("Client secret is valid, skipping creation"); + clientSecretManualActionRequired = false; } else { logger.LogInformation("Client secret is invalid or expired, creating new secret..."); - await CreateBlueprintClientSecretAsync( + var secretCreated = await CreateBlueprintClientSecretAsync( blueprintObjectId!, blueprintAppId!, graphService, setupConfig, configService, - logger); + logger, + loginHintResolver: loginHintResolver); + clientSecretManualActionRequired = !secretCreated; } } else { - await CreateBlueprintClientSecretAsync( + var secretCreated = await CreateBlueprintClientSecretAsync( blueprintObjectId!, blueprintAppId!, graphService, setupConfig, configService, - logger); + logger, + loginHintResolver: loginHintResolver); + clientSecretManualActionRequired = !secretCreated; } logger.LogInformation(""); @@ -543,56 +551,16 @@ await CreateBlueprintClientSecretAsync( logger.LogInformation("Generated config saved: {Path}", generatedConfigPath); logger.LogInformation(""); - // Register messaging endpoint unless --no-endpoint flag is used + // Endpoint registration is temporarily disabled pending a backend fix. + // Re-enable by restoring the registration block here and in the --endpoint-only / --update-endpoint + // paths in CreateCommand. Documentation will be updated when the backend issue is resolved. bool endpointRegistered = false; bool endpointAlreadyExisted = false; - if (!skipEndpointRegistration) - { - // Exception Handling Strategy: - // - During 'setup all': Endpoint failures are NON-BLOCKING. This allows subsequent steps - // (Bot API permissions) to still execute, enabling partial setup progress. - // - Standalone 'setup blueprint': Endpoint failures are BLOCKING (exception propagates). - // User explicitly requested endpoint registration, so failures should halt execution. - // - With '--no-endpoint': This block is skipped entirely (no registration attempted). - try - { - var (registered, alreadyExisted) = await RegisterEndpointAndSyncAsync( - configPath: config.FullName, - logger: logger, - configService: configService, - botConfigurator: botConfigurator, - platformDetector: platformDetector, - correlationId: correlationId); - endpointRegistered = registered; - endpointAlreadyExisted = alreadyExisted; - } - catch (Exception endpointEx) when (isSetupAll) - { - // ONLY during 'setup all': Treat endpoint registration failure as non-blocking - // This allows Bot API permissions (Step 4) to still be configured - endpointRegistered = false; - endpointAlreadyExisted = false; - logger.LogWarning(""); - logger.LogWarning("Endpoint registration failed: {Message}", endpointEx.Message); - logger.LogWarning("Setup will continue to configure Bot API permissions"); - logger.LogWarning(""); - logger.LogWarning("To resolve endpoint registration issues:"); - logger.LogWarning(" 1. Delete existing endpoint: a365 cleanup blueprint --endpoint-only"); - logger.LogWarning(" 2. Register endpoint again: a365 setup blueprint --endpoint-only"); - logger.LogWarning(" Or rerun full setup: a365 setup blueprint"); - logger.LogWarning(""); - } - // NOTE: If NOT isSetupAll, exception propagates to caller (blocking behavior) - // This is intentional: standalone 'a365 setup blueprint' should fail fast on endpoint errors - } - else - { - logger.LogInformation("Skipping endpoint registration (--no-endpoint flag)"); - logger.LogInformation("Register endpoint later with: a365 setup blueprint --endpoint-only"); - } + string? endpointFailureReason = null; - // Display verification info and summary - await SetupHelpers.DisplayVerificationInfoAsync(config, logger); + // Display verification info — skipped when called from 'setup all' (AllSubcommand shows it at the end) + if (!isSetupAll) + await SetupHelpers.DisplayVerificationInfoAsync(config, logger); // Reconcile custom blueprint permissions — apply desired and remove stale entries. // Always run (even when config is empty) so that permissions removed from config are @@ -632,12 +600,17 @@ await PermissionsSubcommand.ConfigureCustomPermissionsAsync( { BlueprintCreated = true, BlueprintAlreadyExisted = blueprintAlreadyExisted, + ClientSecretManualActionRequired = clientSecretManualActionRequired, EndpointRegistered = endpointRegistered, EndpointAlreadyExisted = endpointAlreadyExisted, EndpointRegistrationAttempted = !skipEndpointRegistration, + EndpointRegistrationFailureReason = endpointFailureReason, GraphPermissionsConfigured = blueprintResult.graphPermissionsConfigured, GraphInheritablePermissionsFailed = blueprintResult.graphInheritablePermissionsFailed, - GraphInheritablePermissionsError = blueprintResult.graphInheritablePermissionsError + GraphInheritablePermissionsError = blueprintResult.graphInheritablePermissionsError, + FederatedCredentialConfigured = blueprintResult.ficConfigured, + FederatedCredentialError = blueprintResult.ficError, + AdminConsentUrl = blueprintResult.adminConsentUrl }; } @@ -653,6 +626,18 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( CancellationToken cancellationToken = default, string? correlationId = null) { + // Fast fail on invalid config — avoids multiple retry attempts with exponential backoff + if (!Guid.TryParse(clientAppId, out _)) + { + logger.LogError("Invalid Client App ID format: {AppId} — skipping consent", clientAppId ?? "(null)"); + return false; + } + if (!Guid.TryParse(tenantId, out _)) + { + logger.LogError("Invalid Tenant ID format: {TenantId} — skipping consent", tenantId ?? "(null)"); + return false; + } + var retryHelper = new RetryHelper(logger); try @@ -697,9 +682,9 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( /// Implements displayName-first discovery for idempotency: always searches by displayName from a365.config.json (the source of truth). /// Cached objectIds are only used for dependent resources (FIC, etc.) after blueprint existence is confirmed. /// Used by: BlueprintSubcommand and A365SetupRunner Phase 2.2 - /// Returns: (success, appId, objectId, servicePrincipalId, alreadyExisted, graphPermissionsConfigured, graphInheritablePermissionsFailed, graphInheritablePermissionsError) + /// Returns: (success, appId, objectId, servicePrincipalId, alreadyExisted, graphPermissionsConfigured, graphInheritablePermissionsFailed, graphInheritablePermissionsError, ficConfigured, ficError, adminConsentUrl) /// - public static async Task<(bool success, string? appId, string? objectId, string? servicePrincipalId, bool alreadyExisted, bool graphPermissionsConfigured, bool graphInheritablePermissionsFailed, string? graphInheritablePermissionsError)> CreateAgentBlueprintAsync( + public static async Task<(bool success, string? appId, string? objectId, string? servicePrincipalId, bool alreadyExisted, bool graphPermissionsConfigured, bool graphInheritablePermissionsFailed, string? graphInheritablePermissionsError, bool ficConfigured, string? ficError, string? adminConsentUrl)> CreateAgentBlueprintAsync( ILogger logger, CommandExecutor executor, GraphApiService graphApiService, @@ -715,7 +700,9 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( Models.Agent365Config setupConfig, IConfigService configService, FileInfo configFile, - CancellationToken ct) + CancellationToken ct, + BlueprintCreationOptions? options = null, + Func>? loginHintResolver = null) { // ======================================================================== // Idempotency Check: DisplayName-First Discovery @@ -740,8 +727,8 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( if (lookupResult.Found) { logger.LogInformation("Found existing blueprint by display name"); - logger.LogInformation(" - Object ID: {ObjectId}", lookupResult.ObjectId); - logger.LogInformation(" - App ID: {AppId}", lookupResult.AppId); + logger.LogInformation(" Blueprint ID: {AppId}", lookupResult.AppId); + logger.LogDebug(" Object ID: {ObjectId}", lookupResult.ObjectId); existingObjectId = lookupResult.ObjectId; existingAppId = lookupResult.AppId; @@ -750,22 +737,68 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( } } - // If blueprint exists, get service principal if we don't have it + // If blueprint exists, verify service principal still exists (cached ID may be stale if SP was deleted externally) if (blueprintAlreadyExists && !string.IsNullOrWhiteSpace(existingAppId)) { - if (string.IsNullOrWhiteSpace(existingServicePrincipalId)) + logger.LogDebug("Looking up service principal for blueprint..."); + var spLookup = await blueprintLookupService.GetServicePrincipalByAppIdAsync( + tenantId, existingAppId, ct, + scopes: AuthenticationConstants.RequiredPermissionGrantScopes); + + if (spLookup.Found) { - logger.LogDebug("Looking up service principal for blueprint..."); - var spLookup = await blueprintLookupService.GetServicePrincipalByAppIdAsync( - tenantId, existingAppId, ct, - scopes: AuthenticationConstants.RequiredPermissionGrantScopes); - - if (spLookup.Found) + if (spLookup.ObjectId != existingServicePrincipalId) { - logger.LogDebug("Service principal found: {ObjectId}", spLookup.ObjectId); - existingServicePrincipalId = spLookup.ObjectId; + logger.LogDebug("Service principal ID updated (was: {OldId}, now: {NewId})", existingServicePrincipalId ?? "(none)", spLookup.ObjectId); requiresPersistence = true; } + existingServicePrincipalId = spLookup.ObjectId; + } + else + { + if (!string.IsNullOrWhiteSpace(existingServicePrincipalId)) + logger.LogDebug("Cached service principal {CachedId} no longer exists — will recreate.", existingServicePrincipalId); + existingServicePrincipalId = null; + // SP missing for an existing app — attempt creation so downstream steps have a valid SP. + logger.LogInformation("Service principal not found for existing blueprint — attempting to create it..."); + var spToken = await graphApiService.GetGraphAccessTokenAsync(tenantId, ct); + if (!string.IsNullOrWhiteSpace(spToken)) + { + using var spHttpClient = Services.Internal.HttpClientFactory.CreateAuthenticatedClient(spToken); + var spRetryHelper = new Services.Helpers.RetryHelper(logger); + existingServicePrincipalId = await CreateServicePrincipalAsync(existingAppId, spHttpClient, spRetryHelper, logger, ct); + if (!string.IsNullOrWhiteSpace(existingServicePrincipalId)) + { + requiresPersistence = true; + // Wait for SP to replicate before OAuth2 grants are attempted. + // Directory_ObjectNotFound on oauth2PermissionGrants POST means the SP's + // clientId is not yet visible to the grants API replica. Polling GET /servicePrincipals + // is insufficient — the object is readable almost immediately, but oauth2PermissionGrants + // requires the SP to appear in a different replication index. + // Probe oauth2PermissionGrants directly: a 200 (even empty list) means the grants + // API can now see the SP's clientId and creation will succeed. + logger.LogInformation("Waiting for service principal to propagate in directory..."); + var spPropagated = await spRetryHelper.ExecuteWithRetryAsync( + async token => + { + using var checkResp = await spHttpClient.GetAsync( + $"{Constants.GraphApiConstants.BaseUrl}/v1.0/oauth2PermissionGrants?$filter=clientId eq '{existingServicePrincipalId}'", token); + return checkResp.IsSuccessStatusCode; + }, + result => !result, + maxRetries: 12, + baseDelaySeconds: 5, + ct); + if (spPropagated) + logger.LogDebug("Service principal propagated and verified"); + else + logger.LogWarning("Service principal propagation check timed out — grants may fail"); + } + } + else + { + logger.LogWarning("Could not acquire Graph token to create missing service principal"); + } } // Persist objectIds if needed (migration scenario or new discovery) @@ -786,7 +819,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( { logger.LogError("Existing blueprint found but required identifiers are missing (AppId: {AppId}, ObjectId: {ObjectId})", existingAppId, existingObjectId); - return (false, null, null, null, alreadyExisted: false, graphPermissionsConfigured: false, graphInheritablePermissionsFailed: false, graphInheritablePermissionsError: null); + return (false, null, null, null, alreadyExisted: false, graphPermissionsConfigured: false, graphInheritablePermissionsFailed: false, graphInheritablePermissionsError: null, ficConfigured: false, ficError: null, adminConsentUrl: null); } return await CompleteBlueprintConfigurationAsync( @@ -806,7 +839,8 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( existingObjectId, existingServicePrincipalId, alreadyExisted: true, - ct); + ct, + options); } // ======================================================================== @@ -827,7 +861,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( { sponsorUserId = me.Id; logger.LogInformation("Current user: {DisplayName} <{UPN}>", me.DisplayName, me.UserPrincipalName); - logger.LogInformation("Sponsor: https://graph.microsoft.com/v1.0/users/{UserId}", sponsorUserId); + logger.LogDebug("Sponsor: {BaseUrl}/v1.0/users/{UserId}", Constants.GraphApiConstants.BaseUrl, sponsorUserId); } } catch (Exception ex) @@ -850,19 +884,27 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( { appManifest["sponsors@odata.bind"] = new JsonArray { - $"https://graph.microsoft.com/v1.0/users/{sponsorUserId}" + $"{Constants.GraphApiConstants.BaseUrl}/v1.0/users/{sponsorUserId}" }; appManifest["owners@odata.bind"] = new JsonArray { - $"https://graph.microsoft.com/v1.0/users/{sponsorUserId}" + $"{Constants.GraphApiConstants.BaseUrl}/v1.0/users/{sponsorUserId}" }; } - var graphToken = await AcquireMsalGraphTokenAsync(tenantId, setupConfig.ClientAppId, logger, ct); + var blueprintLoginHint = loginHintResolver != null + ? await loginHintResolver() + : await InteractiveGraphAuthService.ResolveAzLoginHintAsync(); + // Use Application.ReadWrite.All explicitly — NOT .default. Using .default bundles all + // consented scopes including AgentIdentityBlueprint.*, which Entra rejects for + // POST /v1.0/servicePrincipals ("backing application must be in the local tenant"). + logger.LogDebug("Acquiring blueprint httpClient token — scope: Application.ReadWrite.All, loginHint: {LoginHint}", blueprintLoginHint ?? "(none)"); + var graphToken = await AcquireMsalGraphTokenAsync(tenantId, setupConfig.ClientAppId, logger, ct, + scope: AuthenticationConstants.ApplicationReadWriteAllScope, loginHint: blueprintLoginHint); if (string.IsNullOrEmpty(graphToken)) { logger.LogError("Failed to extract access token from Graph client"); - return (false, null, null, null, alreadyExisted: false, graphPermissionsConfigured: false, graphInheritablePermissionsFailed: false, graphInheritablePermissionsError: null); + return (false, null, null, null, alreadyExisted: false, graphPermissionsConfigured: false, graphInheritablePermissionsFailed: false, graphInheritablePermissionsError: null, ficConfigured: false, ficError: null, adminConsentUrl: null); } // Create the application using Microsoft Graph SDK @@ -870,7 +912,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( httpClient.DefaultRequestHeaders.Add("ConsistencyLevel", "eventual"); httpClient.DefaultRequestHeaders.Add("OData-Version", "4.0"); // Required for @odata.type - var createAppUrl = "https://graph.microsoft.com/beta/applications"; + var createAppUrl = $"{Constants.GraphApiConstants.BaseUrl}/beta/applications"; logger.LogInformation("Creating Agent Blueprint application..."); logger.LogInformation(" - Display Name: {DisplayName}", displayName); @@ -923,7 +965,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( errorContent = await appResponse.Content.ReadAsStringAsync(ct); logger.LogError("Failed to create application (all fallbacks exhausted): {Status} - {Error}", appResponse.StatusCode, errorContent); appResponse.Dispose(); - return (false, null, null, null, alreadyExisted: false, graphPermissionsConfigured: false, graphInheritablePermissionsFailed: false, graphInheritablePermissionsError: null); + return (false, null, null, null, alreadyExisted: false, graphPermissionsConfigured: false, graphInheritablePermissionsFailed: false, graphInheritablePermissionsError: null, ficConfigured: false, ficError: null, adminConsentUrl: null); } logger.LogWarning("Agent Blueprint created without owner assignment. Client secret creation will fail unless the custom client app has Application.ReadWrite.All permission or you have Application Administrator role in your Entra tenant."); @@ -932,7 +974,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( { logger.LogError("Failed to create application (fallback): {Status} - {Error}", appResponse.StatusCode, errorContent); appResponse.Dispose(); - return (false, null, null, null, alreadyExisted: false, graphPermissionsConfigured: false, graphInheritablePermissionsFailed: false, graphInheritablePermissionsError: null); + return (false, null, null, null, alreadyExisted: false, graphPermissionsConfigured: false, graphInheritablePermissionsFailed: false, graphInheritablePermissionsError: null, ficConfigured: false, ficError: null, adminConsentUrl: null); } } } @@ -940,7 +982,7 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( { logger.LogError("Failed to create application: {Status} - {Error}", appResponse.StatusCode, errorContent); appResponse.Dispose(); - return (false, null, null, null, alreadyExisted: false, graphPermissionsConfigured: false, graphInheritablePermissionsFailed: false, graphInheritablePermissionsError: null); + return (false, null, null, null, alreadyExisted: false, graphPermissionsConfigured: false, graphInheritablePermissionsFailed: false, graphInheritablePermissionsError: null, ficConfigured: false, ficError: null, adminConsentUrl: null); } } @@ -951,16 +993,16 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( var objectId = app["id"]!.GetValue(); logger.LogInformation("Application created successfully"); - logger.LogInformation(" - App ID: {AppId}", appId); - logger.LogInformation(" - Object ID: {ObjectId}", objectId); + logger.LogInformation(" Blueprint ID: {AppId}", appId); + logger.LogDebug(" Object ID: {ObjectId}", objectId); // Wait for application propagation using RetryHelper var retryHelper = new RetryHelper(logger); - logger.LogInformation("Waiting for application object to propagate in directory..."); + logger.LogInformation("Waiting for application to propagate in directory..."); var appAvailable = await retryHelper.ExecuteWithRetryAsync( async ct => { - var checkResp = await httpClient.GetAsync($"https://graph.microsoft.com/v1.0/applications/{objectId}", ct); + var checkResp = await httpClient.GetAsync($"{Constants.GraphApiConstants.BaseUrl}/v1.0/applications/{objectId}", ct); return checkResp.IsSuccessStatusCode; }, result => !result, @@ -971,14 +1013,14 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( if (!appAvailable) { logger.LogError("Application object not available after creation and retries. Aborting setup."); - return (false, null, null, null, alreadyExisted: false, graphPermissionsConfigured: false, graphInheritablePermissionsFailed: false, graphInheritablePermissionsError: null); + return (false, null, null, null, alreadyExisted: false, graphPermissionsConfigured: false, graphInheritablePermissionsFailed: false, graphInheritablePermissionsError: null, ficConfigured: false, ficError: null, adminConsentUrl: null); } - logger.LogInformation("Application object verified in directory"); + logger.LogDebug("Application object verified in directory"); // Update application with identifier URI var identifierUri = $"api://{appId}"; - var patchAppUrl = $"https://graph.microsoft.com/v1.0/applications/{objectId}"; + var patchAppUrl = $"{Constants.GraphApiConstants.BaseUrl}/v1.0/applications/{objectId}"; var patchBody = new JsonObject { ["identifierUris"] = new JsonArray { identifierUri } @@ -992,41 +1034,23 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( if (!patchResponse.IsSuccessStatusCode) { var patchError = await patchResponse.Content.ReadAsStringAsync(ct); - logger.LogInformation("Waiting for application propagation before setting identifier URI..."); + logger.LogDebug("Waiting for application propagation before setting identifier URI..."); logger.LogDebug("Identifier URI update deferred (propagation delay): {Error}", patchError); } else { - logger.LogInformation("Identifier URI set to: {Uri}", identifierUri); + logger.LogDebug("Identifier URI set to: {Uri}", identifierUri); } // Create service principal + // Retry on 400 NoBackingApplicationObject: Agent Blueprint apps may not yet be indexed + // by appId in all Graph API replicas even after the application object is visible by + // objectId. Retry with backoff until the appId index is replicated. logger.LogInformation("Creating service principal..."); - - var spManifest = new JsonObject - { - ["appId"] = appId - }; - - var createSpUrl = "https://graph.microsoft.com/v1.0/servicePrincipals"; - var spResponse = await httpClient.PostAsync( - createSpUrl, - new StringContent(spManifest.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), - ct); - - string? servicePrincipalId = null; - if (spResponse.IsSuccessStatusCode) - { - var spJson = await spResponse.Content.ReadAsStringAsync(ct); - var sp = JsonNode.Parse(spJson)!.AsObject(); - servicePrincipalId = sp["id"]!.GetValue(); - logger.LogInformation("Service principal created: {SpId}", servicePrincipalId); - } - else + string? servicePrincipalId = await CreateServicePrincipalAsync(appId, httpClient, retryHelper, logger, ct); + if (string.IsNullOrWhiteSpace(servicePrincipalId)) { - var spError = await spResponse.Content.ReadAsStringAsync(ct); - logger.LogInformation("Waiting for application propagation before creating service principal..."); - logger.LogDebug("Service principal creation deferred (propagation delay): {Error}", spError); + logger.LogError("Service principal creation failed after retries"); } // Wait for service principal propagation using RetryHelper @@ -1036,23 +1060,21 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( var spPropagated = await retryHelper.ExecuteWithRetryAsync( async ct => { - var checkSp = await httpClient.GetAsync($"https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '{appId}'", ct); - if (checkSp.IsSuccessStatusCode) - { - var content = await checkSp.Content.ReadAsStringAsync(ct); - var spList = JsonDocument.Parse(content); - return spList.RootElement.GetProperty("value").GetArrayLength() > 0; - } - return false; + // Probe oauth2PermissionGrants directly — a 200 (even empty list) confirms + // the SP's clientId is visible to the grants API replication layer. + // GET /servicePrincipals resolves too fast and gives false confidence. + using var checkResp = await httpClient.GetAsync( + $"{Constants.GraphApiConstants.BaseUrl}/v1.0/oauth2PermissionGrants?$filter=clientId eq '{servicePrincipalId}'", ct); + return checkResp.IsSuccessStatusCode; }, result => !result, - maxRetries: 10, + maxRetries: 12, baseDelaySeconds: 5, ct); if (spPropagated) { - logger.LogInformation("Service principal verified in directory"); + logger.LogDebug("Service principal verified in directory"); } else { @@ -1086,20 +1108,84 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( objectId, servicePrincipalId, alreadyExisted: false, - ct); + ct, + options, + ownerSetAtCreation: !string.IsNullOrEmpty(sponsorUserId)); } catch (Exception ex) { logger.LogError(ex, "Failed to create agent blueprint: {Message}", ex.Message); - return (false, null, null, null, alreadyExisted: false, graphPermissionsConfigured: false, graphInheritablePermissionsFailed: false, graphInheritablePermissionsError: null); + return (false, null, null, null, alreadyExisted: false, graphPermissionsConfigured: false, graphInheritablePermissionsFailed: false, graphInheritablePermissionsError: null, ficConfigured: false, ficError: null, adminConsentUrl: null); + } + } + + /// + /// Creates a service principal for the given appId, retrying on replication lag (400/403). + /// Returns the SP object ID on success, or null on failure. + /// + private static async Task CreateServicePrincipalAsync( + string appId, + HttpClient httpClient, + Services.Helpers.RetryHelper retryHelper, + ILogger logger, + CancellationToken ct) + { + var createSpUrl = $"{Constants.GraphApiConstants.BaseUrl}/v1.0/servicePrincipals"; + var spManifestJson = new JsonObject { ["appId"] = appId }.ToJsonString(); + int forbiddenRetries = 0; + const int maxForbiddenRetries = 3; + + using var spResponse = await retryHelper.ExecuteWithRetryAsync( + async token => await httpClient.PostAsync( + createSpUrl, + new StringContent(spManifestJson, System.Text.Encoding.UTF8, "application/json"), + token), + async (response, token) => + { + if (response.IsSuccessStatusCode) return false; + if (response.StatusCode == System.Net.HttpStatusCode.BadRequest) + { + logger.LogDebug("SP creation returned 400 BadRequest — Entra appId index not yet replicated, retrying..."); + return true; + } + if (response.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + await response.Content.LoadIntoBufferAsync(); + var body = await response.Content.ReadAsStringAsync(token); + if (body.Contains("Authorization_RequestDenied", StringComparison.OrdinalIgnoreCase) + && body.Contains("backing application", StringComparison.OrdinalIgnoreCase) + && forbiddenRetries < maxForbiddenRetries) + { + forbiddenRetries++; + logger.LogDebug("SP creation returned 403 Forbidden (replication lag, attempt {Attempt}/{Max}) — retrying...", forbiddenRetries, maxForbiddenRetries); + return true; + } + } + return false; + }, + maxRetries: 10, + baseDelaySeconds: 8, + cancellationToken: ct); + + if (spResponse.IsSuccessStatusCode) + { + var spJson = await spResponse.Content.ReadAsStringAsync(ct); + var sp = JsonNode.Parse(spJson)!.AsObject(); + var spId = sp["id"]!.GetValue(); + logger.LogDebug("Service principal created: {SpId}", spId); + return spId; } + + var spError = await spResponse.Content.ReadAsStringAsync(ct); + logger.LogError("Service principal creation failed after retries: {StatusCode} — {Error}", (int)spResponse.StatusCode, spError); + return null; } /// /// Completes blueprint configuration by validating/creating federated credentials and requesting admin consent. /// Called by both existing blueprint and new blueprint paths to ensure consistent configuration. /// - private static async Task<(bool success, string? appId, string? objectId, string? servicePrincipalId, bool alreadyExisted, bool graphPermissionsConfigured, bool graphInheritablePermissionsFailed, string? graphInheritablePermissionsError)> CompleteBlueprintConfigurationAsync( + private static async Task<(bool success, string? appId, string? objectId, string? servicePrincipalId, bool alreadyExisted, bool graphPermissionsConfigured, bool graphInheritablePermissionsFailed, string? graphInheritablePermissionsError, bool ficConfigured, string? ficError, string? adminConsentUrl)> CompleteBlueprintConfigurationAsync( ILogger logger, CommandExecutor executor, GraphApiService graphApiService, @@ -1116,7 +1202,9 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( string objectId, string? servicePrincipalId, bool alreadyExisted, - CancellationToken ct) + CancellationToken ct, + BlueprintCreationOptions? options = null, + bool ownerSetAtCreation = false) { // ======================================================================== // Application Owner Validation @@ -1129,8 +1217,20 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( if (!alreadyExisted) { - // For new blueprints, verify that the owner was set during creation - logger.LogInformation("Validating blueprint owner assignment..."); + if (ownerSetAtCreation) + { + // owners@odata.bind was included in the creation payload and creation returned 201. + // Trust the response — skip post-creation verification. + // Agent Blueprint owner endpoints reject tokens that include Directory.AccessAsUser.All + // (bundled with Application.ReadWrite.All delegated), making any GET/POST to owners/$ref + // unreliable. The 201 from creation is authoritative. + logger.LogDebug("Owner set at creation via owners@odata.bind — skipping post-creation verification"); + } + else + { + // owners@odata.bind was not set at creation (current user could not be resolved). + // Attempt owner assignment as a fallback. + logger.LogDebug("Validating blueprint owner assignment..."); var isOwner = await graphApiService.IsApplicationOwnerAsync( tenantId, objectId, @@ -1140,22 +1240,57 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( if (isOwner) { - logger.LogInformation("Current user is confirmed as blueprint owner"); + logger.LogDebug("Current user is confirmed as blueprint owner"); } else { - logger.LogWarning("WARNING: Current user is NOT set as blueprint owner"); - logger.LogWarning("This may have occurred if the owners@odata.bind field was rejected during creation"); - logger.LogWarning("You may need to manually add yourself as owner via Azure Portal:"); - logger.LogWarning(" 1. Go to Azure Portal -> Entra ID -> App registrations"); - logger.LogWarning(" 2. Find application: {DisplayName}", displayName); - logger.LogWarning(" 3. Navigate to Owners blade and add yourself"); - logger.LogWarning("Without owner permissions, you cannot configure callback URLs or bot IDs in Developer Portal"); + logger.LogWarning("Current user is NOT set as blueprint owner — this may have occurred if the owners@odata.bind field was rejected during creation"); + logger.LogInformation("Attempting to assign current user as blueprint owner..."); + + // Retrieve the current user's object ID, then POST to owners/$ref + var meDoc = await graphApiService.GraphGetAsync(tenantId, "/v1.0/me?$select=id", ct); + var currentUserObjectId = meDoc?.RootElement.TryGetProperty("id", out var idEl) == true + ? idEl.GetString() + : null; + + if (string.IsNullOrWhiteSpace(currentUserObjectId)) + { + logger.LogError("Could not retrieve current user ID — cannot assign blueprint owner"); + } + else + { + var ownerPayload = new Dictionary + { + ["@odata.id"] = $"{Constants.GraphApiConstants.BaseUrl}/v1.0/users/{currentUserObjectId}" + }; + + var ownerResponse = await graphApiService.GraphPostWithResponseAsync( + tenantId, + $"/v1.0/applications/{objectId}/owners/$ref", + ownerPayload, + ct); + + if (ownerResponse.IsSuccess) + { + logger.LogInformation("Owner assignment succeeded — current user is now a blueprint owner"); + } + else + { + logger.LogError("Failed to assign current user as blueprint owner: {Status} {Reason}", ownerResponse.StatusCode, ownerResponse.ReasonPhrase); + logger.LogError("Owner assignment error detail: {Body}", ownerResponse.Body); + logger.LogWarning("Without owner permissions, federated credential creation will fail for this blueprint"); + logger.LogWarning("You may need to manually add yourself as owner via Azure Portal:"); + logger.LogWarning(" 1. Go to Azure Portal -> Entra ID -> App registrations"); + logger.LogWarning(" 2. Find application: {DisplayName}", displayName); + logger.LogWarning(" 3. Navigate to Owners blade and add yourself"); + } + } } + } // end else (sponsorUserId was null at creation) } else { - logger.LogInformation("Skipping owner validation for existing blueprint (owners@odata.bind not applied to existing blueprints)"); + logger.LogDebug("Skipping owner validation for existing blueprint (owners@odata.bind not applied to existing blueprints)"); } // ======================================================================== @@ -1163,6 +1298,9 @@ public static async Task EnsureDelegatedConsentWithRetriesAsync( // ======================================================================== // Create Federated Identity Credential ONLY when MSI is relevant (if managed identity provided) + bool ficConfigured = false; + string? ficError = null; + if (useManagedIdentity && !string.IsNullOrWhiteSpace(managedIdentityPrincipalId)) { logger.LogInformation("Configuring Federated Identity Credential for Managed Identity..."); @@ -1188,15 +1326,15 @@ await retryHelper.ExecuteWithRetryAsync( ct); // Return true if successful or already exists - // Return false if should retry (HTTP 404) + // Return false with ShouldRetry=true only for transient errors (e.g. HTTP 404 propagation delay) return ficCreateResult.Success || ficCreateResult.AlreadyExisted; }, - result => !result, // Retry while result is false + result => !result && (ficCreateResult?.ShouldRetry ?? false), // Only retry on transient failures maxRetries: 10, baseDelaySeconds: 3, ct); - bool ficSuccess = (ficCreateResult?.Success ?? false) || (ficCreateResult?.AlreadyExisted ?? false); + ficConfigured = (ficCreateResult?.Success ?? false) || (ficCreateResult?.AlreadyExisted ?? false); if (ficCreateResult?.AlreadyExisted ?? false) { @@ -1208,16 +1346,19 @@ await retryHelper.ExecuteWithRetryAsync( } else { + ficError = ficCreateResult?.ErrorMessage + ?? "Federated Identity Credential creation failed"; logger.LogWarning("[WARN] Federated Identity Credential creation failed - you may need to create it manually in Entra ID"); + logger.LogWarning(" Ensure the client app has 'AgentIdentityBlueprint.UpdateAuthProperties.All' permission consented."); } } else if (!useManagedIdentity) { - logger.LogInformation("Skipping Federated Identity Credential creation (external hosting / no MSI configured)"); + logger.LogDebug("Skipping Federated Identity Credential creation (external hosting / no MSI configured)"); } else { - logger.LogInformation("Skipping Federated Identity Credential creation (no MSI Principal ID provided)"); + logger.LogDebug("Skipping Federated Identity Credential creation (no MSI Principal ID provided)"); } // ======================================================================== @@ -1236,7 +1377,8 @@ await retryHelper.ExecuteWithRetryAsync( servicePrincipalId, setupConfig, alreadyExisted, - ct); + ct, + deferConsent: options?.DeferConsent ?? false); // Add Graph API consent to the resource consents collection var applicationScopes = GetApplicationScopes(setupConfig, logger); @@ -1253,7 +1395,7 @@ await retryHelper.ExecuteWithRetryAsync( generatedConfig["resourceConsents"] = resourceConsents; - if (!consentSuccess) + if (!consentSuccess && !string.IsNullOrEmpty(consentUrlGraph)) { logger.LogWarning(""); logger.LogWarning("Admin consent may not have been detected"); @@ -1263,7 +1405,8 @@ await retryHelper.ExecuteWithRetryAsync( // Track Graph permissions status - this is critical for agent token exchange bool graphPermissionsFailed = !graphInheritablePermissionsConfigured; - return (true, appId, objectId, servicePrincipalId, alreadyExisted, consentSuccess, graphPermissionsFailed, graphInheritablePermissionsError); + string? adminConsentUrl = !consentSuccess ? consentUrlGraph : null; + return (true, appId, objectId, servicePrincipalId, alreadyExisted, consentSuccess, graphPermissionsFailed, graphInheritablePermissionsError, ficConfigured, ficError, adminConsentUrl); } /// @@ -1313,8 +1456,21 @@ private static List GetApplicationScopes(Models.Agent365Config setupConf string? servicePrincipalId, Models.Agent365Config setupConfig, bool alreadyExisted, - CancellationToken ct) + CancellationToken ct, + bool deferConsent = false) { + // When called from AllSubcommand via DeferConsent: true, skip consent and Graph + // inheritable permissions entirely. The batch orchestrator handles both as Phase 3 + // (and Phase 2 via the Graph spec). Return a neutral result: consent not done yet + // (false), no URL from this step (empty string), inheritable permissions not failed + // (true so AllSubcommand does not add a spurious warning in Step 2). + if (deferConsent) + { + logger.LogDebug("Admin consent deferred to batch orchestrator — skipping in blueprint step."); + return (consentSuccess: false, consentUrl: string.Empty, + graphInheritablePermissionsConfigured: true, graphInheritablePermissionsError: null); + } + var applicationScopes = GetApplicationScopes(setupConfig, logger); bool consentAlreadyExists = false; @@ -1370,8 +1526,9 @@ private static List GetApplicationScopes(Models.Agent365Config setupConf } } - var applicationScopesJoined = string.Join(' ', applicationScopes); - var consentUrlGraph = $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={appId}&scope={Uri.EscapeDataString(applicationScopesJoined)}&redirect_uri=https://entra.microsoft.com/TokenAuthorize&state=xyz123"; + var consentUrlGraph = SetupHelpers.BuildAdminConsentUrl( + tenantId, appId, + applicationScopes.Select(s => $"{AuthenticationConstants.MicrosoftGraphResourceUri}/{s}")); if (consentAlreadyExists) { @@ -1410,9 +1567,27 @@ await SetupHelpers.EnsureResourcePermissionsAsync( return (true, consentUrlGraph, graphInheritableConfigured, graphInheritableError); } + // Check if the current user has an admin role that can grant tenant-wide consent + var adminCheck = await graphApiService.IsCurrentUserAdminAsync(tenantId, ct); + if (adminCheck == Models.RoleCheckResult.DoesNotHaveRole) + { + logger.LogWarning("Admin consent is required but the current user does not have the Global Administrator role."); + logger.LogWarning("Ask a tenant administrator to complete the following:"); + logger.LogWarning(""); + logger.LogWarning(" 1. Grant admin consent for the agent blueprint:"); + logger.LogWarning(" {ConsentUrl}", consentUrlGraph); + + return (false, consentUrlGraph, false, null); + } + + if (adminCheck == Models.RoleCheckResult.Unknown) + { + logger.LogDebug("Admin role check inconclusive — attempting consent anyway; API will surface any permission error."); + } + // Request consent via browser logger.LogInformation("Requesting admin consent for application"); - logger.LogInformation(" - Application scopes: {Scopes}", string.Join(", ", applicationScopes)); + logger.LogDebug(" - Application scopes: {Scopes}", string.Join(", ", applicationScopes)); logger.LogInformation("Opening browser for Graph API admin consent..."); logger.LogInformation("If the browser does not open automatically, navigate to this URL to grant consent: {ConsentUrl}", consentUrlGraph); BrowserHelper.TryOpenUrl(consentUrlGraph, logger); @@ -1430,46 +1605,54 @@ await SetupHelpers.EnsureResourcePermissionsAsync( consentSuccess = await AdminConsentHelper.PollAdminConsentAsync(executor, logger, appId, "Graph API Scopes", 180, 5, ct); } - bool graphInheritablePermissionsConfigured = false; - string? graphInheritablePermissionsError = null; - if (consentSuccess) { logger.LogInformation("Graph API admin consent granted successfully!"); + } + else + { + logger.LogWarning("Graph API admin consent may not have completed"); + } - // Set inheritable permissions for Microsoft Graph - logger.LogInformation("Configuring inheritable permissions for Microsoft Graph..."); - try - { - setupConfig.AgentBlueprintId = appId; + // Configure Graph inheritable permissions regardless of admin consent outcome. + // Inheritable permissions define what scopes agent instances *can* inherit from the blueprint + // and require AgentIdentityBlueprint.ReadWrite.All (already consented on the client app). + // Admin consent is a separate gate that controls whether those inherited scopes are usable + // at runtime — it does not block configuring the permission manifest here. + bool graphInheritablePermissionsConfigured = false; + string? graphInheritablePermissionsError = null; - await SetupHelpers.EnsureResourcePermissionsAsync( - graph: graphApiService, - blueprintService: blueprintService, - config: setupConfig, - resourceAppId: AuthenticationConstants.MicrosoftGraphResourceAppId, - resourceName: "Microsoft Graph", - scopes: applicationScopes.ToArray(), - logger: logger, - addToRequiredResourceAccess: false, - setInheritablePermissions: true, - setupResults: null, - ct: ct); + logger.LogInformation("Configuring inheritable permissions for Microsoft Graph..."); + try + { + setupConfig.AgentBlueprintId = appId; - logger.LogInformation("Microsoft Graph inheritable permissions configured successfully"); - graphInheritablePermissionsConfigured = true; - } - catch (Exception ex) - { - graphInheritablePermissionsError = ex.Message; - 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 blueprint"); + await SetupHelpers.EnsureResourcePermissionsAsync( + graph: graphApiService, + blueprintService: blueprintService, + 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"); + if (!consentSuccess) + { + logger.LogWarning("Note: Admin consent has not been granted — Graph permissions will not be usable at runtime until an admin grants consent via: {Url}", consentUrlGraph); } + graphInheritablePermissionsConfigured = true; } - else + catch (Exception ex) { - logger.LogWarning("Graph API admin consent may not have completed"); + graphInheritablePermissionsError = ex.Message; + 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 blueprint"); } return (consentSuccess, consentUrlGraph, graphInheritablePermissionsConfigured, graphInheritablePermissionsError); @@ -1478,25 +1661,38 @@ await SetupHelpers.EnsureResourcePermissionsAsync( /// /// Acquires a Microsoft Graph access token using MSAL interactive authentication /// (WAM on Windows, browser-based flow on other platforms). - /// The token carries the delegated permissions of the custom client app, including - /// Application.ReadWrite.All, which is required for operations such as addPassword. + /// Pass a specific scope (e.g. AgentIdentityBlueprint.ReadWrite.All) to avoid bundling + /// Application.ReadWrite.All and the Directory.AccessAsUser.All scope it carries, which is + /// rejected by the Agent Blueprint API. Defaults to .default (all consented permissions). + /// Pass loginHint so WAM targets the az-logged-in user rather than the OS default account. /// - private static async Task AcquireMsalGraphTokenAsync(string tenantId, string clientAppId, ILogger logger, CancellationToken ct = default) + private static async Task AcquireMsalGraphTokenAsync(string tenantId, string clientAppId, ILogger logger, CancellationToken ct = default, string? scope = null, string? loginHint = null) { + // Guard: MSAL will fail (and block for ~30s on WAM) with empty credentials. + if (string.IsNullOrWhiteSpace(clientAppId) || string.IsNullOrWhiteSpace(tenantId)) + { + logger.LogDebug("Skipping MSAL token acquisition: clientAppId or tenantId is empty"); + return null; + } + try { var credential = new MsalBrowserCredential( clientAppId, tenantId, redirectUri: null, // Let MsalBrowserCredential use WAM on Windows - logger); + logger, + loginHint: loginHint); - var tokenRequestContext = new TokenRequestContext(new[] { "https://graph.microsoft.com/.default" }); + var resolvedScope = string.IsNullOrWhiteSpace(scope) + ? $"{Constants.GraphApiConstants.BaseUrl}/.default" + : $"{Constants.GraphApiConstants.BaseUrl}/{scope}"; + var tokenRequestContext = new TokenRequestContext(new[] { resolvedScope }); var token = await credential.GetTokenAsync(tokenRequestContext, ct); return token.Token; } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { logger.LogError(ex, "Failed to acquire MSAL Graph access token"); return null; @@ -1512,7 +1708,7 @@ private async static Task GetAuthenticatedGraphClientAsync(I logger.LogInformation("Authenticating to Microsoft Graph using interactive browser authentication..."); logger.LogInformation("IMPORTANT: Agent Blueprint operations require Application.ReadWrite.All permission."); logger.LogInformation("This will open a browser window for interactive authentication."); - logger.LogInformation("Please sign in with a Global Administrator account."); + logger.LogInformation("Please sign in with your Microsoft account."); logger.LogInformation(""); // Use InteractiveGraphAuthService to get proper authentication @@ -1544,26 +1740,37 @@ private async static Task GetAuthenticatedGraphClientAsync(I /// Creates client secret for Agent Blueprint (Phase 2.5) /// Used by: BlueprintSubcommand and A365SetupRunner /// - public static async Task CreateBlueprintClientSecretAsync( + /// True if the secret was created successfully; false if it failed and manual action is required. + public static async Task CreateBlueprintClientSecretAsync( string blueprintObjectId, string blueprintAppId, GraphApiService graphService, Models.Agent365Config setupConfig, IConfigService configService, ILogger logger, - CancellationToken ct = default) + CancellationToken ct = default, + Func>? loginHintResolver = null) { try { logger.LogInformation("Creating client secret for Agent Blueprint using Graph API..."); - // Use the MSAL token (carries Application.ReadWrite.All from the custom client app). - // This works for any user with a properly configured custom client app, regardless of - // whether they are an owner of the blueprint app registration. + // Resolve login hint so WAM targets the az-logged-in user, not the OS default account. + // Without this, WAM may return a cached token for a different user who is not the owner. + var loginHint = loginHintResolver != null + ? await loginHintResolver() + : await InteractiveGraphAuthService.ResolveAzLoginHintAsync(); + + // Use a token scoped to AgentIdentityBlueprint.ReadWrite.All (already consented on the + // client app). Using .default bundles Application.ReadWrite.All → Directory.AccessAsUser.All, + // which the Agent Blueprint API explicitly rejects for addPassword. ReadWrite.All includes + // all granular update permissions including AddRemoveCreds (passwordCredentials). var graphToken = await AcquireMsalGraphTokenAsync( setupConfig.TenantId ?? string.Empty, setupConfig.ClientAppId ?? string.Empty, - logger, ct); + logger, ct, + scope: AuthenticationConstants.AgentIdentityBlueprintReadWriteAllScope, + loginHint: loginHint); if (string.IsNullOrWhiteSpace(graphToken)) { @@ -1582,11 +1789,20 @@ public static async Task CreateBlueprintClientSecretAsync( } }; - var addPasswordUrl = $"https://graph.microsoft.com/v1.0/applications/{blueprintObjectId}/addPassword"; - var passwordResponse = await httpClient.PostAsync( - addPasswordUrl, - new StringContent(secretBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"), - ct); + var addPasswordUrl = $"{Constants.GraphApiConstants.BaseUrl}/v1.0/applications/{blueprintObjectId}/addPassword"; + var secretBodyJson = secretBody.ToJsonString(); + // Retry on 404: newly created Agent Blueprints may not yet be visible to all Graph + // API replicas due to Entra eventual consistency. Retry with backoff until propagated. + var retryHelper = new RetryHelper(logger); + var passwordResponse = await retryHelper.ExecuteWithRetryAsync( + async token => await httpClient.PostAsync( + addPasswordUrl, + new StringContent(secretBodyJson, System.Text.Encoding.UTF8, "application/json"), + token), + response => response.StatusCode == System.Net.HttpStatusCode.NotFound, + maxRetries: 5, + baseDelaySeconds: 5, + cancellationToken: ct); if (!passwordResponse.IsSuccessStatusCode) { @@ -1625,24 +1841,15 @@ public static async Task CreateBlueprintClientSecretAsync( logger.LogWarning("WARNING: Secret encryption is only available on Windows. The secret is stored in plaintext."); logger.LogWarning("Consider using environment variables or Azure Key Vault for production deployments."); } + + return true; } catch (Exception ex) { logger.LogWarning(ex, "Failed to create client secret automatically: {Message}", ex.Message); - logger.LogWarning("To create the secret manually you need one of the following on the blueprint app registration:"); - logger.LogWarning(" - Owner of the app registration"); - logger.LogWarning(" - Application Administrator, Cloud Application Administrator, or Global Administrator role in your Entra tenant"); - logger.LogWarning("See: https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/permissions-reference#application-administrator"); - logger.LogInformation("Manual steps to create and add the secret:"); - logger.LogInformation(" 1. Go to Microsoft Entra admin center (https://entra.microsoft.com)"); - logger.LogInformation(" 2. Navigate to App registrations > All applications"); - logger.LogInformation(" 3. Find your blueprint app by ID: {AppId}", blueprintAppId); - logger.LogInformation(" 4. Open Certificates & secrets > Client secrets > New client secret"); - logger.LogInformation(" 5. Copy the Value (not the Secret ID) - it is only shown once"); - logger.LogInformation(" 6. Add both fields to a365.generated.config.json:"); - logger.LogInformation(" \"agentBlueprintClientSecret\": \"\""); - logger.LogInformation(" \"agentBlueprintClientSecretProtected\": false"); - logger.LogInformation(" 7. Re-run: a365 setup all"); + logger.LogWarning("Create the client secret manually for blueprint app {AppId} and add it to a365.generated.config.json, then re-run: a365 setup all", blueprintAppId); + logger.LogWarning("See: https://learn.microsoft.com/en-us/entra/identity-platform/how-to-add-credentials"); + return false; } } @@ -1679,7 +1886,7 @@ private static async Task ValidateClientSecretAsync( { ["client_id"] = clientId, ["client_secret"] = plaintextSecret, - ["scope"] = "https://graph.microsoft.com/.default", + ["scope"] = $"{Constants.GraphApiConstants.BaseUrl}/.default", ["grant_type"] = "client_credentials" }); @@ -2016,8 +2223,8 @@ private static async Task CreateFederatedIdentityCredentialAsync( var urls = new [] { - $"https://graph.microsoft.com/beta/applications/{blueprintObjectId}/federatedIdentityCredentials", - $"https://graph.microsoft.com/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintObjectId}/federatedIdentityCredentials" + $"{Constants.GraphApiConstants.BaseUrl}/beta/applications/{blueprintObjectId}/federatedIdentityCredentials", + $"{Constants.GraphApiConstants.BaseUrl}/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintObjectId}/federatedIdentityCredentials" }; // Use RetryHelper for federated credential creation with exponential backoff diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs index 894b8bf1..eb4af7c2 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/CopilotStudioSubcommand.cs @@ -67,8 +67,13 @@ public static Command CreateCommand( command.AddOption(verboseOption); command.AddOption(dryRunOption); - command.SetHandler(async (config, verbose, dryRun) => + command.SetHandler(async (System.CommandLine.Invocation.InvocationContext context) => { + var config = context.ParseResult.GetValueForOption(configOption)!; + var verbose = context.ParseResult.GetValueForOption(verboseOption); + var dryRun = context.ParseResult.GetValueForOption(dryRunOption); + var ct = context.GetCancellationToken(); + var setupConfig = await configService.LoadAsync(config.FullName); if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId)) @@ -89,7 +94,7 @@ public static Command CreateCommand( if (!dryRun) { var copilotChecks = CopilotStudioSubcommand.GetChecks(authValidator); - await RequirementsSubcommand.RunChecksOrExitAsync(copilotChecks, setupConfig, logger, CancellationToken.None); + await RequirementsSubcommand.RunChecksOrExitAsync(copilotChecks, setupConfig, logger, ct); } if (dryRun) @@ -111,7 +116,7 @@ await ConfigureAsync( graphApiService, blueprintService); - }, configOption, verboseOption, dryRunOption); + }); return command; } @@ -164,7 +169,8 @@ await SetupHelpers.EnsureResourcePermissionsAsync( } catch (Exception ex) { - logger.LogError(ex, "Failed to configure CopilotStudio permissions: {Message}", ex.Message); + logger.LogError("Failed to configure CopilotStudio permissions: {Message}", ex.Message); + logger.LogDebug(ex, "Failed to configure CopilotStudio permissions exception details"); return false; } } 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 cbb2f753..73f18fbc 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs @@ -65,8 +65,12 @@ public static Command CreateCommand( command.AddOption(verboseOption); command.AddOption(dryRunOption); - command.SetHandler(async (config, verbose, dryRun) => + command.SetHandler(async (System.CommandLine.Invocation.InvocationContext context) => { + var config = context.ParseResult.GetValueForOption(configOption)!; + var dryRun = context.ParseResult.GetValueForOption(dryRunOption); + var ct = context.GetCancellationToken(); + if (dryRun) { var dryRunConfig = await configService.LoadAsync(config.FullName); @@ -97,11 +101,11 @@ public static Command CreateCommand( if (setupConfig.NeedDeployment) { await RequirementsSubcommand.RunChecksOrExitAsync( - GetChecks(authValidator), setupConfig, logger, CancellationToken.None); + GetChecks(authValidator), setupConfig, logger, ct); } else { - logger.LogInformation("NeedDeployment=false - skipping Azure subscription validation."); + logger.LogDebug("NeedDeployment=false - skipping Azure subscription validation."); } var generatedConfigPath = Path.Combine( @@ -116,12 +120,12 @@ await CreateInfrastructureImplementationAsync( platformDetector, setupConfig.NeedDeployment, false, - CancellationToken.None); + ct); logger.LogInformation(""); logger.LogInformation("Next steps: Run 'a365 setup blueprint' to create the agent blueprint"); - }, configOption, verboseOption, dryRunOption); + }); return command; } @@ -136,7 +140,9 @@ await CreateInfrastructureImplementationAsync( PlatformDetector platformDetector, bool needDeployment, bool skipInfrastructure, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + ArmApiService? armApiService = null, + GraphApiService? graphApiService = null) { if (!File.Exists(configPath)) { @@ -151,7 +157,8 @@ await CreateInfrastructureImplementationAsync( } catch (Exception ex) { - logger.LogError(ex, "Failed to parse config JSON: {Path}", configPath); + logger.LogError("Failed to parse config JSON: {Path} — {Message}", configPath, ex.Message); + logger.LogDebug(ex, "Config JSON parse exception details"); return (false, false); } @@ -204,6 +211,7 @@ await CreateInfrastructureImplementationAsync( { logger.LogWarning("No deploymentProjectPath specified, defaulting to .NET runtime"); } + logger.LogInformation(""); logger.LogInformation("Agent 365 Setup Infrastructure - Starting..."); logger.LogInformation("Subscription: {Sub}", subscriptionId); @@ -229,6 +237,7 @@ await CreateInfrastructureImplementationAsync( else { logger.LogInformation("==> Skipping Azure management authentication (--skipInfrastructure or External hosting)"); + logger.LogInformation(""); } var (principalId, anyAlreadyExisted) = await CreateInfrastructureAsync( @@ -247,7 +256,9 @@ await CreateInfrastructureImplementationAsync( needDeployment, skipInfra, externalHosting, - cancellationToken); + cancellationToken, + armApiService, + graphApiService); return (true, anyAlreadyExisted); } @@ -262,63 +273,56 @@ public static async Task ValidateAzureCliAuthenticationAsync( CancellationToken cancellationToken = default) { logger.LogInformation("==> Verifying Azure CLI authentication"); - - // Check if logged in - var accountCheck = await executor.ExecuteAsync("az", "account show", captureOutput: true, suppressErrorLogging: true, cancellationToken: cancellationToken); - if (!accountCheck.Success) + logger.LogInformation(""); + + // Use cached login hint from AzCliHelper (populated by requirements check). + // Falls back to spawning 'az account show' only on first call in this process. + var loginHint = await AzCliHelper.ResolveLoginHintAsync(); + if (loginHint == null) { logger.LogInformation("Azure CLI not authenticated. Initiating login with management scope..."); logger.LogInformation("A browser window will open for authentication. Please check your taskbar or browser if you don't see it."); var loginResult = await executor.ExecuteAsync("az", $"login --tenant {tenantId}", cancellationToken: cancellationToken); - + if (!loginResult.Success) { logger.LogError("Azure CLI login failed. Please run manually: az login --scope https://management.core.windows.net//.default"); return false; } - + logger.LogInformation("Azure CLI login successful!"); + AzCliHelper.InvalidateLoginHintCache(); await Task.Delay(2000, cancellationToken); } else { - logger.LogDebug("Azure CLI already authenticated"); + logger.LogDebug("Azure CLI already authenticated as {LoginHint}", loginHint); } - // Verify we have the management scope + // Verify we have the management scope (token is cached at process level by AzCliHelper). logger.LogDebug("Verifying access to Azure management resources..."); - var tokenCheck = await executor.ExecuteAsync( - "az", - "account get-access-token --resource https://management.core.windows.net/ --query accessToken -o tsv", - captureOutput: true, - suppressErrorLogging: true, - cancellationToken: cancellationToken); - - if (!tokenCheck.Success) + var managementToken = await AzCliHelper.AcquireAzCliTokenAsync(ArmApiService.ArmResource, tenantId); + + if (string.IsNullOrWhiteSpace(managementToken)) { logger.LogWarning("Unable to acquire management scope token. Attempting re-authentication..."); logger.LogInformation("A browser window will open for authentication."); - + var loginResult = await executor.ExecuteAsync("az", $"login --tenant {tenantId}", cancellationToken: cancellationToken); - + if (!loginResult.Success) { logger.LogError("Azure CLI login with management scope failed. Please run manually: az login --scope https://management.core.windows.net//.default"); return false; } - + logger.LogInformation("Azure CLI re-authentication successful!"); + AzCliHelper.InvalidateAzCliTokenCache(); await Task.Delay(2000, cancellationToken); - - var retryTokenCheck = await executor.ExecuteAsync( - "az", - "account get-access-token --resource https://management.core.windows.net/ --query accessToken -o tsv", - captureOutput: true, - suppressErrorLogging: true, - cancellationToken: cancellationToken); - - if (!retryTokenCheck.Success) + + var retryToken = await AzCliHelper.AcquireAzCliTokenAsync(ArmApiService.ArmResource, tenantId); + if (string.IsNullOrWhiteSpace(retryToken)) { logger.LogWarning("Still unable to acquire management scope token after re-authentication."); logger.LogWarning("Continuing anyway - you may encounter permission errors later."); @@ -357,7 +361,9 @@ public static async Task ValidateAzureCliAuthenticationAsync( bool needDeployment, bool skipInfra, bool externalHosting, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + ArmApiService? armApiService = null, + GraphApiService? graphApiService = null) { bool anyAlreadyExisted = false; string? principalId = null; @@ -368,6 +374,7 @@ public static async Task ValidateAzureCliAuthenticationAsync( var modeMessage = "External hosting (non-Azure)"; logger.LogInformation("==> Skipping Azure infrastructure ({Mode})", modeMessage); + logger.LogInformation(""); logger.LogInformation("Loading existing configuration..."); // Load existing generated config if available @@ -409,20 +416,26 @@ public static async Task ValidateAzureCliAuthenticationAsync( else { logger.LogInformation("==> Deploying App Service + enabling Managed Identity"); + logger.LogInformation(""); - // Set subscription context - try + // Resource group + // Use ArmApiService for a direct HTTP check (~0.5s) instead of az subprocess (~15-20s). + // Falls back to az CLI if ARM token is unavailable. + bool rgExistsResult; + var rgExistsArm = armApiService != null + ? await armApiService.ResourceGroupExistsAsync(subscriptionId, resourceGroup, tenantId, cancellationToken) + : null; + if (rgExistsArm.HasValue) { - await executor.ExecuteAsync("az", $"account set --subscription {subscriptionId}"); + rgExistsResult = rgExistsArm.Value; } - catch (Exception) + else { - logger.LogWarning("Failed to set az subscription context explicitly"); + var rgExists = await executor.ExecuteAsync("az", $"group exists -n {resourceGroup} --subscription {subscriptionId}", captureOutput: true); + rgExistsResult = rgExists.Success && rgExists.StandardOutput.Trim().Equals("true", StringComparison.OrdinalIgnoreCase); } - // Resource group - var rgExists = await executor.ExecuteAsync("az", $"group exists -n {resourceGroup} --subscription {subscriptionId}", captureOutput: true); - if (rgExists.Success && rgExists.StandardOutput.Trim().Equals("true", StringComparison.OrdinalIgnoreCase)) + if (rgExistsResult) { logger.LogInformation("Resource group already exists: {RG} (skipping creation)", resourceGroup); anyAlreadyExisted = true; @@ -434,15 +447,29 @@ public static async Task ValidateAzureCliAuthenticationAsync( } // App Service plan - bool planAlreadyExisted = await EnsureAppServicePlanExistsAsync(executor, logger, resourceGroup, planName, planSku, location, subscriptionId); + bool planAlreadyExisted = await EnsureAppServicePlanExistsAsync(executor, logger, resourceGroup, planName, planSku, location, subscriptionId, cancellationToken: cancellationToken, armApiService: armApiService, tenantId: tenantId); if (planAlreadyExisted) { anyAlreadyExisted = true; } // Web App - var webShow = await executor.ExecuteAsync("az", $"webapp show -g {resourceGroup} -n {webAppName} --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true); - if (!webShow.Success) + // Use ArmApiService for a direct HTTP check (~0.5s) instead of az subprocess (~15-20s). + bool webAppExists; + var webAppExistsArm = armApiService != null + ? await armApiService.WebAppExistsAsync(subscriptionId, resourceGroup, webAppName, tenantId, cancellationToken) + : null; + if (webAppExistsArm.HasValue) + { + webAppExists = webAppExistsArm.Value; + } + else + { + var webShow = await executor.ExecuteAsync("az", $"webapp show -g {resourceGroup} -n {webAppName} --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true); + webAppExists = webShow.Success; + } + + if (!webAppExists) { var runtime = await GetLinuxFxVersionForPlatformAsync(platform, deploymentProjectPath, executor, logger, cancellationToken); logger.LogInformation("Creating web app {App} with runtime {Runtime}", webAppName, runtime); @@ -520,12 +547,15 @@ public static async Task ValidateAzureCliAuthenticationAsync( { logger.LogInformation("Managed Identity principalId: {Id}", principalId); - // Use RetryHelper to verify MSI propagation to Azure AD with exponential backoff + // Use RetryHelper to verify MSI propagation to Azure AD with exponential backoff. + // Graph SP lookup (~200ms) replaces 'az ad sp show' (~30s) per retry attempt. var retryHelper = new RetryHelper(logger); logger.LogInformation("Verifying managed identity propagation in Azure AD..."); var msiPropagated = await retryHelper.ExecuteWithRetryAsync( async ct => { + if (graphApiService != null) + return await graphApiService.ServicePrincipalExistsAsync(tenantId, principalId, ct); var verifyMsi = await executor.ExecuteAsync("az", $"ad sp show --id {principalId}", captureOutput: true, suppressErrorLogging: true); return verifyMsi.Success; }, @@ -564,69 +594,78 @@ public static async Task ValidateAzureCliAuthenticationAsync( logger.LogInformation("Assigning current user as Website Contributor for the web app..."); try { - // Get the current signed-in user's object ID - var userResult = await executor.ExecuteAsync("az", "ad signed-in-user show --query id -o tsv", captureOutput: true, suppressErrorLogging: true); - if (userResult.Success && !string.IsNullOrWhiteSpace(userResult.StandardOutput)) + // Get the current signed-in user's object ID. + // Graph /v1.0/me (~200ms) replaces 'az ad signed-in-user show' (~30s). + string? userObjectId = null; + if (graphApiService != null) + userObjectId = await graphApiService.GetCurrentUserObjectIdAsync(tenantId, cancellationToken); + if (string.IsNullOrWhiteSpace(userObjectId)) { - var userObjectId = userResult.StandardOutput.Trim(); - + var userResult = await executor.ExecuteAsync("az", "ad signed-in-user show --query id -o tsv", captureOutput: true, suppressErrorLogging: true); + if (userResult.Success && !string.IsNullOrWhiteSpace(userResult.StandardOutput)) + userObjectId = userResult.StandardOutput.Trim(); + } + + if (!string.IsNullOrWhiteSpace(userObjectId)) + { + // Validate that userObjectId is a valid GUID to prevent command injection if (!Guid.TryParse(userObjectId, out _)) { logger.LogWarning("Retrieved user object ID is not a valid GUID: {UserId}", userObjectId); return (principalId, anyAlreadyExisted); } - + logger.LogDebug("Current user object ID: {UserId}", userObjectId); - // Create the WebApp resource scope var webAppScope = $"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.Web/sites/{webAppName}"; - // Assign the "Website Contributor" role to the user - // Website Contributor allows viewing logs and diagnostic info without full Owner permissions - var roleAssignResult = await executor.ExecuteAsync("az", - $"role assignment create --role \"Website Contributor\" --assignee-object-id {userObjectId} --scope {webAppScope} --assignee-principal-type User", - captureOutput: true, - suppressErrorLogging: true); + // Before attempting assignment, check whether the user already has sufficient + // access via inheritance (Owner or Contributor at subscription/RG level both + // supersede Website Contributor and include log access). + // ARM role assignments API (~300ms) replaces 'az role assignment list --include-inherited' (~35s). + string? existingRole = null; + if (armApiService != null) + existingRole = await armApiService.GetSufficientWebAppRoleAsync(subscriptionId, resourceGroup, webAppName, userObjectId, tenantId, cancellationToken); - if (roleAssignResult.Success) - { - logger.LogInformation("Successfully assigned Website Contributor role to current user"); - } - else if (roleAssignResult.StandardError.Contains("already exists", StringComparison.OrdinalIgnoreCase)) - { - // Role assignment already exists - this is fine - logger.LogDebug("Role assignment already exists: {Error}", roleAssignResult.StandardError.Trim()); - } - else if (roleAssignResult.StandardError.Contains("PrincipalNotFound", StringComparison.OrdinalIgnoreCase)) - { - // Principal not found (possibly using service principal) - logger.LogDebug("User principal not available: {Error}", roleAssignResult.StandardError.Trim()); - } - else + if (existingRole == null) { - logger.LogWarning("Could not assign Website Contributor role to user. Diagnostic logs may not be accessible. Error: {Error}", roleAssignResult.StandardError.Trim()); + // ARM call failed — fall back to az CLI + var existingRoleResult = await executor.ExecuteAsync("az", + $"role assignment list --assignee {userObjectId} --scope {webAppScope} --include-inherited" + + " --query \"[?roleDefinitionName=='Owner' || roleDefinitionName=='Contributor' || roleDefinitionName=='Website Contributor'].roleDefinitionName | [0]\"" + + " -o tsv", + captureOutput: true, + suppressErrorLogging: true); + existingRole = existingRoleResult.Success ? existingRoleResult.StandardOutput.Trim() : string.Empty; } - // Verify the role assignment - logger.LogInformation("Validating Website Contributor role assignment..."); - var verifyResult = await executor.ExecuteAsync("az", - $"role assignment list --scope {webAppScope} --assignee {userObjectId} --role \"Website Contributor\" --query \"[].roleDefinitionName\" -o tsv", - captureOutput: true, - suppressErrorLogging: true); - - if (verifyResult.Success && !string.IsNullOrWhiteSpace(verifyResult.StandardOutput)) + if (!string.IsNullOrWhiteSpace(existingRole)) { - logger.LogInformation("Current user is confirmed as Website Contributor for the web app"); + logger.LogInformation("User already has '{Role}' access on the web app — log access confirmed, skipping Website Contributor assignment", + existingRole); } else { - logger.LogWarning("WARNING: Could not verify Website Contributor role assignment"); - logger.LogWarning("You may need to manually assign the role via Azure Portal:"); - logger.LogWarning(" 1. Go to Azure Portal -> Your Web App"); - logger.LogWarning(" 2. Navigate to Access control (IAM)"); - logger.LogWarning(" 3. Add role assignment -> Website Contributor"); - logger.LogWarning("Without this role, you may not be able to access diagnostic logs and log streams"); + // Attempt assignment. If it fails (e.g. no roleAssignments/write permission), + // log a single warning with remediation guidance — no further verification needed. + var roleAssignResult = await executor.ExecuteAsync("az", + $"role assignment create --role \"Website Contributor\" --assignee-object-id {userObjectId} --scope {webAppScope} --assignee-principal-type User", + captureOutput: true, + suppressErrorLogging: true); + + if (roleAssignResult.Success) + { + logger.LogInformation("Successfully assigned Website Contributor role to current user"); + } + else + { + logger.LogWarning("Could not assign Website Contributor role to user. Diagnostic logs may not be accessible."); + logger.LogWarning("You may need to manually assign the role via Azure Portal:"); + logger.LogWarning(" 1. Go to Azure Portal -> Your Web App -> Access control (IAM)"); + logger.LogWarning(" 2. Add role assignment -> Website Contributor"); + logger.LogDebug("Role assignment error detail: {Error}", roleAssignResult.StandardError.Trim()); + } } } else @@ -728,18 +767,35 @@ private static async Task AzWarnAsync(CommandExecutor executor, ILogger logger, /// Returns true if plan already existed, false if newly created. /// internal static async Task EnsureAppServicePlanExistsAsync( - CommandExecutor executor, - ILogger logger, - string resourceGroup, - string planName, - string? planSku, + CommandExecutor executor, + ILogger logger, + string resourceGroup, + string planName, + string? planSku, string location, string subscriptionId, int maxRetries = 5, - int baseDelaySeconds = 3) + int baseDelaySeconds = 3, + CancellationToken cancellationToken = default, + ArmApiService? armApiService = null, + string tenantId = "") { - var planShow = await executor.ExecuteAsync("az", $"appservice plan show -g {resourceGroup} -n {planName} --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true); - if (planShow.Success) + // Use ArmApiService for a direct HTTP check (~0.5s) instead of az subprocess (~15-20s). + bool planExists; + var planExistsArm = armApiService != null + ? await armApiService.AppServicePlanExistsAsync(subscriptionId, resourceGroup, planName, tenantId, cancellationToken) + : null; + if (planExistsArm.HasValue) + { + planExists = planExistsArm.Value; + } + else + { + var planShow = await executor.ExecuteAsync("az", $"appservice plan show -g {resourceGroup} -n {planName} --subscription {subscriptionId}", captureOutput: true, suppressErrorLogging: true); + planExists = planShow.Success; + } + + if (planExists) { logger.LogInformation("App Service plan already exists: {Plan} (skipping creation)", planName); return true; // Already existed @@ -763,7 +819,15 @@ internal static async Task EnsureAppServicePlanExistsAsync( if (!string.IsNullOrWhiteSpace(createResult.StandardError)) { - logger.LogError("Error output: {Error}", createResult.StandardError); + // Strip non-actionable Python / az-CLI diagnostic lines (UserWarning, + // Readonly attribute warnings) so they don't surface as ERRORs for the user. + var cleanedError = string.Join( + Environment.NewLine, + createResult.StandardError + .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) + .Where(l => !IsNonActionableStderrLine(l))); + if (!string.IsNullOrWhiteSpace(cleanedError)) + logger.LogError("Error output: {Error}", cleanedError); } if (!string.IsNullOrWhiteSpace(createResult.StandardOutput)) @@ -814,12 +878,17 @@ internal static async Task EnsureAppServicePlanExistsAsync( } logger.LogInformation("App Service plan creation command completed successfully"); - + // Add small delay to allow Azure resource propagation - logger.LogInformation("Waiting for Azure resource propagation..."); - await Task.Delay(TimeSpan.FromSeconds(3)); + if (baseDelaySeconds > 0) + { + logger.LogInformation("Waiting for Azure resource propagation..."); + await Task.Delay(TimeSpan.FromSeconds(baseDelaySeconds), cancellationToken); + } - // Use RetryHelper to verify the plan was created successfully with exponential backoff + // Use RetryHelper to verify the plan was created successfully with exponential backoff. + // baseDelaySeconds controls both the propagation wait above and the inter-retry interval + // here — tests pass 0 to eliminate all waits; production uses the default of 3. var retryHelper = new RetryHelper(logger); logger.LogInformation("Verifying App Service plan creation..."); var planCreated = await retryHelper.ExecuteWithRetryAsync( @@ -831,7 +900,7 @@ internal static async Task EnsureAppServicePlanExistsAsync( result => !result, maxRetries, baseDelaySeconds, - CancellationToken.None); + cancellationToken); if (!planCreated) { @@ -881,7 +950,8 @@ public static async Task GetLinuxFxVersionForPlatformAsync( string? deploymentProjectPath, CommandExecutor executor, ILogger logger, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + int? retryDelayMsOverride = null) { if (platform != Models.ProjectPlatform.DotNet || string.IsNullOrWhiteSpace(deploymentProjectPath)) @@ -923,9 +993,10 @@ public static async Task GetLinuxFxVersionForPlatformAsync( if (attempt < MaxSdkValidationAttempts) { // Exponential backoff with cap: 500ms, 1000ms, 2000ms (capped at MaxRetryDelayMs) - var delayMs = Math.Min(InitialRetryDelayMs * (1 << (attempt - 1)), MaxRetryDelayMs); + var delayMs = retryDelayMsOverride + ?? Math.Min(InitialRetryDelayMs * (1 << (attempt - 1)), MaxRetryDelayMs); logger.LogWarning( - "dotnet --version check failed (attempt {Attempt}/{MaxAttempts}). Retrying in {DelayMs}ms...", + "dotnet --version check failed (attempt {Attempt}/{MaxAttempts}). Retrying in {DelayMs}ms...", attempt, MaxSdkValidationAttempts, delayMs); await Task.Delay(delayMs, cancellationToken); } @@ -994,5 +1065,23 @@ public static async Task GetLinuxFxVersionForPlatformAsync( private static string Short(string? text) => string.IsNullOrWhiteSpace(text) ? string.Empty : (text.Length <= 180 ? text.Trim() : text[..177] + "..."); + /// + /// Returns true for non-actionable stderr lines from the Python interpreter bundled + /// inside the Azure CLI (UserWarning, Readonly attribute warnings). These appear on + /// stderr even during successful invocations and must not surface as user-facing ERRORs. + /// + private static bool IsNonActionableStderrLine(string line) + { + var trimmed = line.AsSpan().TrimStart(); + if (trimmed.StartsWith("UserWarning:", StringComparison.OrdinalIgnoreCase)) + return true; + if (trimmed.StartsWith("WARNING: Readonly attribute name will be ignored", StringComparison.OrdinalIgnoreCase)) + return true; + // Python file/line references that accompany UserWarning (e.g. " warnings.warn(...)") + if (trimmed.StartsWith("warnings.warn(", StringComparison.Ordinal)) + return true; + return false; + } + #endregion } 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 7103dfc1..d8da9e32 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs @@ -328,6 +328,18 @@ await ConfigureCustomPermissionsAsync( return command; } + /// + /// Reads the required MCP server OAuth2 scopes from the tooling manifest file. + /// Returns an empty array when the manifest is absent or unreadable. + /// + internal static async Task ReadMcpScopesAsync(string manifestPath, ILogger logger) + { + var scopes = await ManifestHelper.GetRequiredScopesAsync(manifestPath); + if (scopes.Length == 0) + logger.LogDebug("No MCP scopes found in manifest at {ManifestPath} — MCP permissions will be skipped.", manifestPath); + return scopes; + } + /// /// Configures MCP server permissions (OAuth2 grants and inheritable permissions). /// Public method that can be called by AllSubcommand. @@ -350,37 +362,35 @@ public static async Task ConfigureMcpPermissionsAsync( try { - // Read scopes from ToolingManifest.json var manifestPath = Path.Combine(setupConfig.DeploymentProjectPath ?? string.Empty, McpConstants.ToolingManifestFileName); - var toolingScopes = await ManifestHelper.GetRequiredScopesAsync(manifestPath); + var toolingScopes = await ReadMcpScopesAsync(manifestPath, logger); var resourceAppId = ConfigConstants.GetAgent365ToolsResourceAppId(setupConfig.Environment); - // Configure all permissions using unified method - await SetupHelpers.EnsureResourcePermissionsAsync( - graphApiService, - blueprintService, - setupConfig, - resourceAppId, - "Agent 365 Tools", - toolingScopes, - logger, - addToRequiredResourceAccess: false, - setInheritablePermissions: true, - setupResults, - cancellationToken); + var specs = new List + { + new ResourcePermissionSpec(resourceAppId, "Agent 365 Tools", toolingScopes, SetInheritable: true), + }; + + var (_, _, consentGranted, _) = await BatchPermissionsOrchestrator.ConfigureAllPermissionsAsync( + graphApiService, blueprintService, setupConfig, + setupConfig.AgentBlueprintId!, setupConfig.TenantId, + specs, logger, setupResults, cancellationToken, + knownBlueprintSpObjectId: setupConfig.AgentBlueprintServicePrincipalObjectId); logger.LogInformation(""); - logger.LogInformation("MCP server permissions configured successfully"); + if (consentGranted) + logger.LogInformation("MCP server permissions configured successfully"); + else + logger.LogInformation("MCP server permissions configured; admin consent required"); logger.LogInformation(""); if (!iSetupAll) { logger.LogInformation("Next step: 'a365 setup permissions bot' to configure Bot API permissions"); } - // write changes to generated config await configService.SaveStateAsync(setupConfig); - return true; + return consentGranted; } catch (Exception mcpEx) { @@ -413,66 +423,43 @@ public static async Task ConfigureBotPermissionsAsync( SetupResults? setupResults = null, CancellationToken cancellationToken = default) { + if (string.IsNullOrWhiteSpace(setupConfig.AgentBlueprintId)) + { + logger.LogError("AgentBlueprintId is missing from configuration. Run 'a365 setup blueprint' first."); + return false; + } + logger.LogInformation(""); logger.LogInformation("Configuring Messaging Bot API permissions..."); logger.LogInformation(""); try { - // Configure Messaging Bot API permissions using unified method - // Note: Messaging Bot API is a first-party Microsoft service with custom OAuth2 scopes - // that are not published in the standard service principal permissions. - // We skip addToRequiredResourceAccess because the scopes won't be found there. - // The permissions appear in the portal via OAuth2 grants and inheritable permissions. - await SetupHelpers.EnsureResourcePermissionsAsync( - graphService, - blueprintService, - setupConfig, - ConfigConstants.MessagingBotApiAppId, - "Messaging Bot API", - new[] { "Authorization.ReadWrite", "user_impersonation" }, - logger, - addToRequiredResourceAccess: false, - setInheritablePermissions: true, - setupResults, - cancellationToken); - - // Configure Observability API permissions using unified method - // Note: Observability API is also a first-party Microsoft service - await SetupHelpers.EnsureResourcePermissionsAsync( - graphService, - blueprintService, - setupConfig, - ConfigConstants.ObservabilityApiAppId, - "Observability API", - new[] { "user_impersonation" }, - logger, - addToRequiredResourceAccess: false, - setInheritablePermissions: true, - setupResults, - cancellationToken); - - // Configure Power Platform API permissions using unified method - // Note: Using the Power Platform API (8578e004-a5c6-46e7-913e-12f58912df43) which is - // the Power Platform API for agent operations. This API exposes Connectivity.Connections.Read - // for reading Power Platform connections. - // Similar to Messaging Bot API, we skip addToRequiredResourceAccess because the scopes - // won't be found in the standard service principal permissions. - // The permissions appear in the portal via OAuth2 grants and inheritable permissions. - await SetupHelpers.EnsureResourcePermissionsAsync( - graphService, - blueprintService, - setupConfig, - PowerPlatformConstants.PowerPlatformApiResourceAppId, - "Power Platform API", - new[] { "Connectivity.Connections.Read" }, - logger, - addToRequiredResourceAccess: false, - setInheritablePermissions: true, - setupResults, - cancellationToken); + var specs = new List + { + new ResourcePermissionSpec( + ConfigConstants.MessagingBotApiAppId, + "Messaging Bot API", + new[] { "Authorization.ReadWrite", "user_impersonation" }, + SetInheritable: true), + new ResourcePermissionSpec( + ConfigConstants.ObservabilityApiAppId, + "Observability API", + new[] { "user_impersonation" }, + SetInheritable: true), + new ResourcePermissionSpec( + PowerPlatformConstants.PowerPlatformApiResourceAppId, + "Power Platform API", + new[] { "Connectivity.Connections.Read" }, + SetInheritable: true), + }; + + var (_, _, consentGranted, _) = await BatchPermissionsOrchestrator.ConfigureAllPermissionsAsync( + graphService, blueprintService, setupConfig, + setupConfig.AgentBlueprintId!, setupConfig.TenantId, + specs, logger, setupResults, cancellationToken, + knownBlueprintSpObjectId: setupConfig.AgentBlueprintServicePrincipalObjectId); - // write changes to generated config await configService.SaveStateAsync(setupConfig); logger.LogInformation(""); @@ -482,11 +469,11 @@ await SetupHelpers.EnsureResourcePermissionsAsync( { logger.LogInformation("Next step: Deploy your agent (run 'a365 deploy' if hosting on Azure)"); } - return true; + return consentGranted; } catch (Exception ex) { - logger.LogError(ex, "Failed to configure Bot API permissions: {Message}", ex.Message); + logger.LogError("Failed to configure Bot API permissions: {Message}", ex.Message); if (iSetupAll) { throw; @@ -500,7 +487,7 @@ await SetupHelpers.EnsureResourcePermissionsAsync( /// Standard (CLI-managed) permissions (MCP, Bot API, Graph, etc.) are never touched. /// OAuth2 grants for removed entries are also revoked on a best-effort basis. /// - private static async Task RemoveStaleCustomPermissionsAsync( + internal static async Task RemoveStaleCustomPermissionsAsync( ILogger logger, GraphApiService graphApiService, AgentBlueprintService blueprintService, @@ -675,6 +662,8 @@ await RemoveStaleCustomPermissionsAsync( } var hasValidationFailures = false; + var specList = new List(); + foreach (var customPerm in setupConfig.CustomBlueprintPermissions) { // Auto-resolve resource name if not provided @@ -697,7 +686,6 @@ await RemoveStaleCustomPermissionsAsync( } else { - // Fallback if lookup fails - use safe helper method customPerm.ResourceName = CreateFallbackResourceName(customPerm.ResourceAppId); logger.LogWarning(" - Could not resolve resource name, using fallback: {ResourceName}", customPerm.ResourceName); @@ -705,16 +693,12 @@ await RemoveStaleCustomPermissionsAsync( } catch (Exception ex) { - // Fallback if lookup fails - use safe helper method customPerm.ResourceName = CreateFallbackResourceName(customPerm.ResourceAppId); - logger.LogWarning(ex, " - Failed to auto-resolve resource name: {Message}. Using fallback: {ResourceName}", + logger.LogWarning(" - Failed to auto-resolve resource name: {Message}. Using fallback: {ResourceName}", ex.Message, customPerm.ResourceName); } } - logger.LogInformation("Configuring {ResourceName} ({ResourceAppId})...", - customPerm.ResourceName, customPerm.ResourceAppId); - // Validate var (isValid, errors) = customPerm.Validate(); if (!isValid) @@ -728,24 +712,23 @@ await RemoveStaleCustomPermissionsAsync( continue; } - // Use the same unified method as standard permissions - // Note: Agent Blueprints don't support requiredResourceAccess via v1.0 API - // (same limitation as CopilotStudio and MCP permissions) - await SetupHelpers.EnsureResourcePermissionsAsync( - graphApiService, - blueprintService, - setupConfig, + specList.Add(new ResourcePermissionSpec( customPerm.ResourceAppId, customPerm.ResourceName, customPerm.Scopes.ToArray(), - logger, - addToRequiredResourceAccess: false, // Skip requiredResourceAccess - not supported for Agent Blueprints - setInheritablePermissions: true, // Inheritable permissions work correctly - setupResults, - cancellationToken); - - logger.LogInformation(" - {ResourceName} configured successfully", - customPerm.ResourceName); + SetInheritable: true)); + } + + if (specList.Count > 0) + { + var (_, _, consentGranted, _) = await BatchPermissionsOrchestrator.ConfigureAllPermissionsAsync( + graphApiService, blueprintService, setupConfig, + setupConfig.AgentBlueprintId!, setupConfig.TenantId, + specList, logger, setupResults, cancellationToken, + knownBlueprintSpObjectId: setupConfig.AgentBlueprintServicePrincipalObjectId); + + if (!consentGranted) + hasValidationFailures = true; } logger.LogInformation(""); @@ -755,7 +738,6 @@ await SetupHelpers.EnsureResourcePermissionsAsync( logger.LogInformation("Custom blueprint permissions configured successfully"); logger.LogInformation(""); - // Save dynamic state changes to the generated config (CustomBlueprintPermissions is not persisted here) await configService.SaveStateAsync(setupConfig); return !hasValidationFailures; } @@ -767,8 +749,7 @@ await SetupHelpers.EnsureResourcePermissionsAsync( throw; } - // Only log when handling the error here (standalone command) - logger.LogError(ex, "Failed to configure custom blueprint permissions: {Message}", ex.Message); + logger.LogError("Failed to configure custom blueprint permissions: {Message}", ex.Message); return false; } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/README.md b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/README.md index b9390405..4aae9466 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/README.md +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/README.md @@ -12,10 +12,13 @@ This folder contains the workflow components for the `a365 setup` command. The s |-----------|------|-------------| | **AllSubcommand** | `AllSubcommand.cs` | Orchestrates the complete setup workflow (`a365 setup all`) | | **BlueprintSubcommand** | `BlueprintSubcommand.cs` | Creates agent blueprint application registration | +| **BlueprintCreationOptions** | `BlueprintCreationOptions.cs` | Options record for blueprint creation (e.g. `DeferConsent`) | | **InfrastructureSubcommand** | `InfrastructureSubcommand.cs` | Provisions Azure infrastructure (App Service, etc.) | | **PermissionsSubcommand** | `PermissionsSubcommand.cs` | Configures Graph API permissions and admin consent | +| **BatchPermissionsOrchestrator** | `BatchPermissionsOrchestrator.cs` | Three-phase batch permissions flow used by `setup all` and standalone permission commands | +| **ResourcePermissionSpec** | `ResourcePermissionSpec.cs` | Spec record describing a single resource's required permissions | | **RequirementsSubcommand** | `RequirementsSubcommand.cs` | Validates prerequisites (Azure CLI, permissions) | -| **SetupHelpers** | `SetupHelpers.cs` | Shared helper methods for setup operations | +| **SetupHelpers** | `SetupHelpers.cs` | Shared helper methods; `EnsureResourcePermissionsAsync` used by standalone callers and `CopilotStudioSubcommand` | | **SetupResults** | `SetupResults.cs` | Result models for setup operations | --- @@ -64,11 +67,21 @@ a365 setup permissions # Configure permissions only --- +## BatchPermissionsOrchestrator + +`BatchPermissionsOrchestrator.cs` implements a three-phase batch permissions flow used by `setup all` and the standalone `setup permissions` subcommands: + +- **Phase 1 — Resolve service principals** (non-admin): Pre-warms the delegated token and resolves all SP IDs once. `requiredResourceAccess` is not updated here — it is not supported for Agent Blueprints. +- **Phase 2 — Configure inherited permissions** (Agent ID Administrator or Global Administrator): Creates OAuth2 grants and sets inheritable permissions using IDs from Phase 1. A 403 response is caught silently and treated as insufficient role — one consolidated warning is emitted without additional API calls. +- **Phase 3 — Grant admin consent** (Global Administrator only, or URL for non-admins): Checks for existing consent before opening a browser. Returns a consolidated URL when the user lacks the Global Administrator role. + +`CopilotStudioSubcommand` is out of scope and continues to call `EnsureResourcePermissionsAsync` directly. + ## SetupHelpers The `SetupHelpers.cs` file contains shared functionality: -- **EnsureResourcePermissionsAsync** - Configures all three permission layers with retry logic +- **EnsureResourcePermissionsAsync** - Configures permissions for a single resource with retry logic; used by standalone `CopilotStudioSubcommand` and direct callers - **WaitForPermissionPropagationAsync** - Waits for Entra ID permission propagation - **ValidateConfigurationAsync** - Validates configuration before setup operations diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs index 788f4e26..8d68c668 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/RequirementsSubcommand.cs @@ -64,7 +64,8 @@ public static Command CreateCommand( } catch (Exception ex) { - logger.LogError(ex, "Requirements check failed: {Message}", ex.Message); + logger.LogError("Requirements check failed: {Message}", ex.Message); + logger.LogDebug(ex, "Requirements check failed exception details"); } }, configOption, verboseOption, categoryOption); @@ -195,7 +196,7 @@ private static List GetConfigRequirementChecks(AzureAuthValid // Location configuration — required for endpoint registration new LocationRequirementCheck(), - // Client app configuration validation + // Client app configuration validation (checks all required Graph permissions incl. UpdateAuthProperties.All) new ClientAppRequirementCheck(clientAppValidator), }; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/ResourcePermissionSpec.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/ResourcePermissionSpec.cs new file mode 100644 index 00000000..dbe2695c --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/ResourcePermissionSpec.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; + +/// +/// Describes a single resource whose permissions should be configured on the agent blueprint. +/// Used as input to . +/// +/// The application ID of the resource (e.g. Microsoft Graph, MCP Tools). +/// Human-readable display name used in log messages. +/// Delegated permission scopes to grant and (if SetInheritable is true) make inheritable. +/// +/// When true, the orchestrator configures inheritable permissions on the blueprint so that +/// agent instances automatically receive these scopes at creation time. +/// +internal record ResourcePermissionSpec( + string ResourceAppId, + string ResourceName, + string[] Scopes, + bool SetInheritable); 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 9ea0af0b..ef406c8f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs @@ -23,7 +23,6 @@ public static async Task DisplayVerificationInfoAsync(FileInfo setupConfigFile, { try { - logger.LogInformation("Generating verification information..."); var baseDir = setupConfigFile.DirectoryName ?? Environment.CurrentDirectory; var generatedConfigPath = Path.Combine(baseDir, "a365.generated.config.json"); @@ -37,31 +36,36 @@ public static async Task DisplayVerificationInfoAsync(FileInfo setupConfigFile, using var doc = await JsonDocument.ParseAsync(stream); var root = doc.RootElement; - logger.LogInformation(""); - logger.LogInformation("Verification URLs:"); - logger.LogInformation("=========================================="); + var urls = new List<(string Label, string Url)>(); // Azure Web App URL - if (root.TryGetProperty("AppServiceName", out var appServiceProp) && !string.IsNullOrWhiteSpace(appServiceProp.GetString())) + if (root.TryGetProperty("appServiceName", out var appServiceProp) && !string.IsNullOrWhiteSpace(appServiceProp.GetString())) { - var webAppUrl = $"https://{appServiceProp.GetString()}.azurewebsites.net"; - logger.LogInformation("Agent Web App: {Url}", webAppUrl); + urls.Add(("Agent Web App", $"https://{appServiceProp.GetString()}.azurewebsites.net")); } // Azure Resource Group - if (root.TryGetProperty("ResourceGroup", out var rgProp) && !string.IsNullOrWhiteSpace(rgProp.GetString())) + if (root.TryGetProperty("resourceGroup", out var rgProp) && !string.IsNullOrWhiteSpace(rgProp.GetString())) { - var resourceGroup = rgProp.GetString(); - logger.LogInformation("Azure Resource Group: https://portal.azure.com/#@/resource/subscriptions/{SubscriptionId}/resourceGroups/{ResourceGroup}", - root.TryGetProperty("SubscriptionId", out var subProp) ? subProp.GetString() : "{subscription}", - resourceGroup); + var subscriptionId = root.TryGetProperty("subscriptionId", out var subProp) ? subProp.GetString() : "{subscription}"; + urls.Add(("Azure Resource Group", $"https://portal.azure.com/#@/resource/subscriptions/{subscriptionId}/resourceGroups/{rgProp.GetString()}")); } // Entra ID Application - if (root.TryGetProperty("AgentBlueprintId", out var blueprintProp) && !string.IsNullOrWhiteSpace(blueprintProp.GetString())) + if (root.TryGetProperty("agentBlueprintId", out var blueprintProp) && !string.IsNullOrWhiteSpace(blueprintProp.GetString())) { - logger.LogInformation("Entra ID Application: https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/{AppId}", - blueprintProp.GetString()); + urls.Add(("Entra ID Application", $"https://portal.azure.com/#view/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/~/Overview/appId/{blueprintProp.GetString()}")); + } + + if (urls.Count == 0) + return; + + logger.LogInformation(""); + logger.LogInformation("Verification URLs:"); + + foreach (var (label, url) in urls) + { + logger.LogInformation("{Label}: {Url}", label, url); } } catch (Exception ex) @@ -76,128 +80,295 @@ public static async Task DisplayVerificationInfoAsync(FileInfo setupConfigFile, public static void DisplaySetupSummary(SetupResults results, ILogger logger) { logger.LogInformation(""); - logger.LogInformation("=========================================="); logger.LogInformation("Setup Summary"); - logger.LogInformation("=========================================="); - - // Show what succeeded + + var pendingAdminAction = !results.AdminConsentGranted && results.BatchPermissionsPhase2Completed; + + // Completed steps — [OK] only logger.LogInformation("Completed Steps:"); if (results.InfrastructureCreated) { - var status = results.InfrastructureAlreadyExisted ? "configured (already exists)" : "created"; + var status = results.InfrastructureAlreadyExisted ? "(already exists)" : "created"; logger.LogInformation(" [OK] Infrastructure {Status}", status); } if (results.BlueprintCreated) { - var status = results.BlueprintAlreadyExisted ? "configured (already exists)" : "created"; - logger.LogInformation(" [OK] Agent blueprint {Status} (Blueprint ID: {BlueprintId})", status, results.BlueprintId ?? "unknown"); - } - if (results.McpPermissionsConfigured && results.InheritablePermissionsConfigured) - { - var permStatus = results.McpPermissionsAlreadyExisted ? "verified" : "configured"; - var inheritStatus = results.InheritablePermissionsAlreadyExisted ? "verified" : "configured"; - logger.LogInformation(" [OK] MCP Tools permissions {PermStatus}, inheritable permissions {InheritStatus}", permStatus, inheritStatus); - } - if (results.BotApiPermissionsConfigured && results.BotInheritablePermissionsConfigured) - { - var permStatus = results.BotApiPermissionsAlreadyExisted ? "verified" : "configured"; - var inheritStatus = results.BotInheritablePermissionsAlreadyExisted ? "verified" : "configured"; - logger.LogInformation(" [OK] Messaging Bot API permissions {PermStatus}, inheritable permissions {InheritStatus}", permStatus, inheritStatus); + var status = results.BlueprintAlreadyExisted ? "(already exists)" : "created"; + logger.LogInformation(" [OK] Agent blueprint {Status} ID: {BlueprintId}", status, results.BlueprintId ?? "unknown"); } - if (results.GraphPermissionsConfigured && results.GraphInheritablePermissionsConfigured) + if (results.BatchPermissionsPhase2Completed) { - var permStatus = results.GraphPermissionsAlreadyExisted ? "verified" : "configured"; - var inheritStatus = results.GraphInheritablePermissionsAlreadyExisted ? "verified" : "configured"; - logger.LogInformation(" [OK] Microsoft Graph permissions {PermStatus}, inheritable permissions {InheritStatus}", permStatus, inheritStatus); - } - if (results.CustomPermissionsConfigured) - { - logger.LogInformation(" [OK] Custom blueprint permissions configured"); + logger.LogInformation(" [OK] Inheritable permissions configured and verified"); + if (results.AdminConsentGranted) + logger.LogInformation(" [OK] OAuth2 grants and admin consent configured"); } if (results.MessagingEndpointRegistered) { - var status = results.EndpointAlreadyExisted ? "configured (already exists)" : "created"; + var status = results.EndpointAlreadyExisted ? "(already exists)" : "created"; logger.LogInformation(" [OK] Messaging endpoint {Status}", status); } - - // Show what failed + + // Action required — shown as its own section so it isn't conflated with completed work + var hasActionRequired = pendingAdminAction || results.ClientSecretManualActionRequired; + if (hasActionRequired) + { + logger.LogInformation(""); + logger.LogInformation("Action Required:"); + if (results.ClientSecretManualActionRequired) + logger.LogInformation(" Client secret - must be created manually in Entra ID and added to a365.generated.config.json (see instructions above)"); + if (pendingAdminAction) + logger.LogInformation(" OAuth2 grants — Global Administrator must grant consent (see Next Steps)"); + } + + // Failed steps if (results.Errors.Count > 0) { logger.LogInformation(""); logger.LogInformation("Failed Steps:"); foreach (var error in results.Errors) - { logger.LogError(" [FAILED] {Error}", error); - } } - - // Show warnings + + // Warnings if (results.Warnings.Count > 0) { logger.LogInformation(""); logger.LogInformation("Warnings:"); foreach (var warning in results.Warnings) - { logger.LogInformation(" [WARN] {Warning}", warning); - } } - + logger.LogInformation(""); - + // Overall status + if (results.HasErrors) { logger.LogWarning("Setup completed with errors"); logger.LogInformation(""); logger.LogInformation("Recovery Actions:"); - - if (!results.McpPermissionsConfigured || !results.InheritablePermissionsConfigured) + + if (!results.BatchPermissionsPhase2Completed || (!results.AdminConsentGranted && !pendingAdminAction)) { - logger.LogInformation(" - MCP Tools Permissions: Run 'a365 setup permissions mcp' to retry"); + logger.LogInformation(" - Permissions: Run 'a365 setup all' to retry permission configuration"); } - - if (!results.BotApiPermissionsConfigured || !results.BotInheritablePermissionsConfigured) + } + + if (pendingAdminAction) + { + logger.LogInformation(""); + logger.LogInformation("Next Steps — Global Administrator action required:"); + logger.LogInformation(" OAuth2 permission grants require a Global Administrator."); + logger.LogInformation(" Option 1 — Run the CLI as a Global Administrator:"); + logger.LogInformation(" a365 setup admin --config-dir \"\""); + if (!string.IsNullOrWhiteSpace(results.CombinedConsentUrl)) { - logger.LogInformation(" - Messaging Bot API Permissions: Run 'a365 setup permissions bot' to retry"); + logger.LogInformation(" Option 2 — Share a single consent URL with your Global Administrator:"); + logger.LogInformation(" {ConsentUrl}", results.CombinedConsentUrl); } - - if (!results.GraphPermissionsConfigured || !results.GraphInheritablePermissionsConfigured) + else if (!string.IsNullOrWhiteSpace(results.AdminConsentUrl)) { - logger.LogInformation(" - Microsoft Graph Permissions: Run 'a365 setup blueprint' to retry"); + logger.LogInformation(" Alternatively, a Global Administrator can grant Graph consent at:"); + logger.LogInformation(" {ConsentUrl}", results.AdminConsentUrl); } + } - if (!results.CustomPermissionsConfigured && results.Errors.Any(e => e.Contains("custom", StringComparison.OrdinalIgnoreCase))) + if (!results.HasErrors && !hasActionRequired) + { + if (results.HasWarnings) { - logger.LogInformation(" - Custom Blueprint Permissions: Run 'a365 setup permissions custom' to retry"); - } + logger.LogInformation("Setup completed successfully with warnings"); + logger.LogInformation(""); + logger.LogInformation("Recovery Actions:"); + + if (!string.IsNullOrEmpty(results.GraphInheritablePermissionsError)) + { + logger.LogInformation(" - Graph Inheritable Permissions: Run 'a365 setup blueprint' to retry"); + } + + if (!string.IsNullOrEmpty(results.FederatedCredentialError)) + { + logger.LogInformation(" - Federated Identity Credential: Ensure the client app has 'AgentIdentityBlueprint.UpdateAuthProperties.All' consented,"); + logger.LogInformation(" then run 'a365 setup blueprint' to retry"); + } - if (!results.MessagingEndpointRegistered) + logger.LogInformation(""); + logger.LogInformation("Review warnings above and take action if needed"); + } + else { - logger.LogInformation(" - Messaging Endpoint: Run 'a365 setup blueprint --endpoint-only' to retry"); - logger.LogInformation(" If there's a conflicting endpoint, delete it first: a365 cleanup blueprint --endpoint-only"); + logger.LogInformation("Setup completed successfully"); + logger.LogInformation("All components configured correctly"); } } - else if (results.HasWarnings) + } + + /// + /// Populates resourceConsents[*].consentUrl in the generated config for all five required + /// resources. Called when the current user lacks the Global Administrator role so that the URLs + /// can be saved to a365.generated.config.json and shared with a tenant administrator. + /// + /// Display names of the resources for which URLs were saved. + internal static List PopulateAdminConsentUrls( + Agent365Config config, + string mcpResourceAppId, + IEnumerable mcpScopes) + { + var graphScopes = config.AgentApplicationScopes; + var urls = BuildAdminConsentUrls(config.TenantId, config.AgentBlueprintId!, graphScopes, mcpScopes); + + // Map resource names to App IDs for upsert into ResourceConsents + var appIdByName = new Dictionary(StringComparer.OrdinalIgnoreCase) { - logger.LogInformation("Setup completed successfully with warnings"); - logger.LogInformation(""); - logger.LogInformation("Recovery Actions:"); - - if (!string.IsNullOrEmpty(results.GraphInheritablePermissionsError)) + ["Microsoft Graph"] = AuthenticationConstants.MicrosoftGraphResourceAppId, + ["Agent 365 Tools"] = mcpResourceAppId, + ["Messaging Bot API"] = ConfigConstants.MessagingBotApiAppId, + ["Observability API"] = ConfigConstants.ObservabilityApiAppId, + ["Power Platform API"] = PowerPlatformConstants.PowerPlatformApiResourceAppId, + }; + + var populated = new List(); + foreach (var (resourceName, consentUrl) in urls) + { + if (!appIdByName.TryGetValue(resourceName, out var appId)) continue; + + var existing = config.ResourceConsents.FirstOrDefault( + rc => rc.ResourceAppId.Equals(appId, StringComparison.OrdinalIgnoreCase)); + if (existing is not null) { - logger.LogInformation(" - Graph Inheritable Permissions: Run 'a365 setup blueprint' to retry"); + existing.ConsentUrl = consentUrl; } - + else + { + config.ResourceConsents.Add(new Models.ResourceConsent + { + ResourceName = resourceName, + ResourceAppId = appId, + ConsentUrl = consentUrl, + ConsentGranted = false, + }); + } + populated.Add(resourceName); + } + return populated; + } + + /// + /// Builds a single /v2.0/adminconsent URL from fully-qualified scope URIs. + /// All callers must pass fully-qualified scopes (e.g. "https://graph.microsoft.com/User.Read"). + /// Each scope is individually Uri.EscapeDataString-encoded and joined with %20. + /// A random GUID state parameter is generated for CSRF protection. + /// + internal static string BuildAdminConsentUrl(string tenantId, string clientId, IEnumerable fullyQualifiedScopes) + { + var scopeParam = string.Join("%20", fullyQualifiedScopes.Select(Uri.EscapeDataString)); + var redirectEncoded = Uri.EscapeDataString(AuthenticationConstants.BlueprintConsentRedirectUri); + return $"https://login.microsoftonline.com/{tenantId}/v2.0/adminconsent?client_id={clientId}&scope={scopeParam}&redirect_uri={redirectEncoded}&state={Guid.NewGuid():N}"; + } + + /// + /// Builds per-resource admin consent URLs for all five required resources. + /// Graph and MCP scopes are taken from config; Bot API, Observability, and Power Platform + /// use corrected scope names derived from querying the tenant service principals. + /// + internal static List<(string ResourceName, string ConsentUrl)> BuildAdminConsentUrls( + string tenantId, + string blueprintClientId, + IEnumerable graphScopes, + IEnumerable mcpScopes) + { + var urls = new List<(string, string)>(); + + static string Build(string tenant, string client, string resourceUri, IEnumerable scopes) + => BuildAdminConsentUrl(tenant, client, scopes.Select(s => $"{resourceUri}/{s}")); + + var graphScopeList = graphScopes.ToList(); + if (graphScopeList.Count > 0) + urls.Add(("Microsoft Graph", Build(tenantId, blueprintClientId, AuthenticationConstants.MicrosoftGraphResourceUri, graphScopeList))); + + var mcpScopeList = mcpScopes.ToList(); + if (mcpScopeList.Count > 0) + urls.Add(("Agent 365 Tools", Build(tenantId, blueprintClientId, McpConstants.Agent365ToolsIdentifierUri, mcpScopeList))); + + urls.Add(("Messaging Bot API", Build(tenantId, blueprintClientId, ConfigConstants.MessagingBotApiIdentifierUri, new[] { ConfigConstants.MessagingBotApiAdminConsentScope }))); + urls.Add(("Observability API", Build(tenantId, blueprintClientId, ConfigConstants.ObservabilityApiIdentifierUri, new[] { ConfigConstants.ObservabilityApiAdminConsentScope }))); + urls.Add(("Power Platform API", Build(tenantId, blueprintClientId, PowerPlatformConstants.PowerPlatformApiIdentifierUri, new[] { PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead }))); + + return urls; + } + + /// + /// Builds a single combined /v2.0/adminconsent URL covering all five required resources. + /// All scope tokens from all resources are joined with %20 into one scope parameter, + /// allowing a Global Administrator to grant consent with a single browser visit. + /// + internal static string BuildCombinedConsentUrl( + string tenantId, + string blueprintClientId, + IEnumerable graphScopes, + IEnumerable mcpScopes) + { + var allScopes = new List(); + foreach (var s in graphScopes) + allScopes.Add($"{AuthenticationConstants.MicrosoftGraphResourceUri}/{s}"); + foreach (var s in mcpScopes) + allScopes.Add($"{McpConstants.Agent365ToolsIdentifierUri}/{s}"); + allScopes.Add($"{ConfigConstants.MessagingBotApiIdentifierUri}/{ConfigConstants.MessagingBotApiAdminConsentScope}"); + allScopes.Add($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiAdminConsentScope}"); + allScopes.Add($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}"); + return BuildAdminConsentUrl(tenantId, blueprintClientId, allScopes); + } + + /// + /// Displays the setup summary for 'a365 setup admin' — shows grant results and + /// a Graph Explorer query the administrator can use to verify the grants. + /// + public static void DisplayAdminSetupSummary( + SetupResults results, + string? blueprintSpObjectId, + ILogger logger) + { + logger.LogInformation(""); + logger.LogInformation("Admin Setup Summary"); + logger.LogInformation("Completed Steps:"); + + if (results.AdminConsentGranted) + { + logger.LogInformation(" [OK] OAuth2 grants configured (tenant-wide)"); + } + + if (results.Errors.Count > 0) + { logger.LogInformation(""); - logger.LogInformation("Review warnings above and take action if needed"); + logger.LogInformation("Failed Steps:"); + foreach (var error in results.Errors) + logger.LogError(" [FAILED] {Error}", error); } - else + + if (results.Warnings.Count > 0) { - logger.LogInformation("Setup completed successfully"); - logger.LogInformation("All components configured correctly"); + logger.LogInformation(""); + logger.LogInformation("Warnings:"); + foreach (var warning in results.Warnings) + logger.LogInformation(" [WARN] {Warning}", warning); } - - logger.LogInformation("=========================================="); + + logger.LogInformation(""); + + if (!string.IsNullOrWhiteSpace(blueprintSpObjectId)) + { + logger.LogInformation("Verify OAuth2 grants in Graph Explorer:"); + logger.LogInformation(" GET https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId eq '{BlueprintSpObjectId}'", blueprintSpObjectId); + } + + logger.LogInformation(""); + + if (results.HasErrors) + logger.LogWarning("Admin setup completed with errors"); + else if (results.HasWarnings) + logger.LogInformation("Admin setup completed with warnings"); + else + logger.LogInformation("Admin setup completed successfully"); } /// @@ -282,7 +453,8 @@ public static async Task EnsureResourcePermissionsAsync( resourceAppId, scopes, isDelegated: true, - ct); + ct, + requiredScopes: permissionGrantScopes); if (!addedResourceAccess) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs index 03feab3b..b5175f70 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs @@ -20,12 +20,44 @@ public class SetupResults public bool GraphInheritablePermissionsConfigured { get; set; } public bool CustomPermissionsConfigured { get; set; } + // Batch phase results — set by AllSubcommand after BatchPermissionsOrchestrator completes. + // These replace the per-resource flags for the setup all summary display. + + /// Phase 1: Service principal resolution completed for all specs. + public bool BatchPermissionsPhase1Completed { get; set; } + + /// Phase 2: OAuth2 grants and inheritable permissions configured for all resources. + public bool BatchPermissionsPhase2Completed { get; set; } + + /// + /// Phase 3: Admin consent was granted or already existed. + /// False with set means the user is non-admin and consent is pending. + /// + public bool AdminConsentGranted { get; set; } + /// /// Error message when Microsoft Graph inheritable permissions fail to configure. /// Non-null indicates failure. This is critical for agent token exchange functionality. /// public string? GraphInheritablePermissionsError { get; set; } + + /// + /// Whether the Federated Identity Credential was configured for the managed identity. + /// False (with FederatedCredentialError set) means agent token exchange may not work. + /// + public bool FederatedCredentialConfigured { get; set; } + + /// + /// Error message when Federated Identity Credential configuration failed. + /// + public string? FederatedCredentialError { get; set; } + /// + /// True when the client secret could not be created automatically and the user must + /// create it manually in Entra ID and re-run setup. Surfaces in the summary as Action Required. + /// + public bool ClientSecretManualActionRequired { get; set; } + // Idempotency tracking flags - track whether resources already existed (vs newly created) public bool InfrastructureAlreadyExisted { get; set; } public bool BlueprintAlreadyExisted { get; set; } @@ -38,6 +70,31 @@ public class SetupResults public bool GraphInheritablePermissionsAlreadyExisted { get; set; } public bool CustomPermissionsAlreadyExisted { get; set; } + /// + /// Consent URL to present when admin consent was not granted because the user lacks an admin role. + /// Non-null indicates a tenant administrator needs to complete consent at this URL. + /// + public string? AdminConsentUrl { get; set; } + + /// + /// Path to the generated config file where admin consent URLs were saved. + /// Non-null when the current user lacks the GA role and consent URLs have been written to + /// the resourceConsents[*].consentUrl fields in a365.generated.config.json. + /// + public string? ConsentUrlsSavedToPath { get; set; } + + /// + /// Display names of the resources for which consent URLs were saved. + /// Populated alongside . + /// + public List ConsentResourceNames { get; } = new(); + + /// + /// A single combined /v2.0/adminconsent URL covering all five required resources. + /// Populated alongside as a simpler handover option. + /// + public string? CombinedConsentUrl { get; set; } + public List Errors { get; } = new(); public List Warnings { get; } = new(); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs index dfea9ecc..e7e3d353 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/AuthenticationConstants.cs @@ -86,11 +86,84 @@ public static string[] GetRequiredRedirectUris(string clientAppId) /// public const string MicrosoftGraphResourceAppId = "00000003-0000-0000-c000-000000000000"; + /// + /// Microsoft Graph identifier URI (used for admin consent URL construction). + /// + public const string MicrosoftGraphResourceUri = "https://graph.microsoft.com"; + + /// + /// Redirect URI registered on the blueprint application to support the /v2.0/adminconsent flow. + /// AAD requires at least one redirect URI on the application — AADSTS500113 is returned otherwise. + /// This is the standard Entra Portal redirect URI used for admin consent; it shows a generic + /// "consent granted" page and requires no real endpoint on our side. + /// + public const string BlueprintConsentRedirectUri = "https://entra.microsoft.com/TokenAuthorize"; + + /// + /// Delegated scope for reading directory role assignments. + /// Retained as a named constant for use cases where a lower-privilege role-read scope is required. + /// + public const string RoleManagementReadDirectoryScope = "RoleManagement.Read.Directory"; + + /// + /// Delegated scope granted implicitly to all Microsoft Graph delegated tokens. + /// Used for /me and /me/transitiveMemberOf calls that require only basic user identity access. + /// + public const string UserReadScope = "User.Read"; + + /// + /// Well-known template ID for the "Global Administrator" built-in Entra role. + /// Required to grant tenant-wide admin consent interactively. + /// + public const string GlobalAdminRoleTemplateId = "62e90394-69f5-4237-9190-012177145e10"; + + /// + /// Well-known template ID for the "Agent ID Administrator" built-in Entra role. + /// Required to create or update inheritable permissions on agent blueprints. + /// + public const string AgentIdAdminRoleTemplateId = "db506228-d27e-4b7d-95e5-295956d6615f"; + + /// + /// Delegated scope for broad directory read access. + /// Required for /me/memberOf and other directory read operations. + /// + public const string DirectoryReadAllScope = "Directory.Read.All"; + + /// + /// Delegated scope for read/write access to Entra ID applications. + /// Used for FIC retrieval and deletion operations that are not yet covered by + /// more granular AgentIdentityBlueprint.* scopes. + /// + public const string ApplicationReadWriteAllScope = "Application.ReadWrite.All"; + + /// + /// Delegated scope required to delete an Agent Blueprint. + /// Per the Agent ID permissions reference, this is the correct scope for Delete operations. + /// + public const string AgentIdentityBlueprintDeleteRestoreAllScope = "AgentIdentityBlueprint.DeleteRestore.All"; + + /// + /// Delegated scope required to add or remove federated identity credentials and password credentials + /// on an Agent Blueprint. Per the Agent ID permissions reference, covers keyCredentials, + /// passwordCredentials, and federatedIdentityCredentials. Requires Global Administrator or + /// Agent ID Administrator role. + /// + public const string AgentIdentityBlueprintAddRemoveCredsAllScope = "AgentIdentityBlueprint.AddRemoveCreds.All"; + + /// + /// Delegated scope for full read/write access to an Agent Blueprint. + /// Includes all granular update permissions (UpdateAuthProperties, AddRemoveCreds, UpdateBranding). + /// Used for client secret creation where AddRemoveCreds.All may not yet be individually consented + /// on the client app — ReadWrite.All is already consented and avoids bundling + /// Directory.AccessAsUser.All that comes with Application.ReadWrite.All/.default. + /// + public const string AgentIdentityBlueprintReadWriteAllScope = "AgentIdentityBlueprint.ReadWrite.All"; + /// /// 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. /// @@ -99,8 +172,13 @@ public static string[] GetRequiredRedirectUris(string clientAppId) "Application.ReadWrite.All", "AgentIdentityBlueprint.ReadWrite.All", "AgentIdentityBlueprint.UpdateAuthProperties.All", + "AgentIdentityBlueprint.AddRemoveCreds.All", // Required for passwordCredentials and FICs during setup and cleanup "DelegatedPermissionGrant.ReadWrite.All", "Directory.Read.All" + // Note: RoleManagementReadDirectoryScope and AgentIdentityBlueprint.DeleteRestore.All are + // intentionally excluded. DeleteRestore.All is a cleanup-only scope acquired on-demand via + // interactive consent during 'a365 cleanup'. RoleManagementReadDirectoryScope is excluded + // because Directory.Read.All already covers the needed read operations. }; /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs index ba3ec790..3560ad35 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs @@ -53,11 +53,36 @@ public static class ConfigConstants /// public const string MessagingBotApiAppId = "5a807f24-c9de-44ee-a3a7-329e88a00ffc"; + /// + /// Messaging Bot API identifier URI (used for admin consent URL construction). + /// + public const string MessagingBotApiIdentifierUri = "https://botapi.skype.com"; + /// /// Observability API App ID /// public const string ObservabilityApiAppId = "9b975845-388f-4429-889e-eab1ef63949c"; + /// + /// Observability API identifier URI (uses api:// scheme — no public https URI registered). + /// + public const string ObservabilityApiIdentifierUri = "api://9b975845-388f-4429-889e-eab1ef63949c"; + + /// + /// Messaging Bot API scope used for admin consent URL construction. + /// Note: the orchestrator grants "Authorization.ReadWrite" + "user_impersonation" via OAuth2 + /// permission grants; this scope name is what the /adminconsent endpoint accepts for the + /// same resource and maps to the same effective consent. + /// + public const string MessagingBotApiAdminConsentScope = "AgentData.ReadWrite"; + + /// + /// Observability API scope used for admin consent URL construction. + /// Note: the orchestrator grants "user_impersonation" via OAuth2 permission grants; this + /// scope is the consent-URL-facing name for the same resource. + /// + public const string ObservabilityApiAdminConsentScope = "Maven.ReadWrite.All"; + /// /// Production deployment environment /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/GraphApiConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/GraphApiConstants.cs index 7d49a22d..514a4f5d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/GraphApiConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/GraphApiConstants.cs @@ -4,19 +4,24 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Constants; /// -/// Constants for Microsoft Graph API endpoints and resources +/// Constants for Microsoft Graph API endpoints and resources. +/// All constants are expressed relative to so that +/// sovereign-cloud support only requires changing that one value. /// public static class GraphApiConstants { /// - /// Base URL for Microsoft Graph API + /// Base URL for the Microsoft Graph API (commercial cloud). + /// Override per-config via for + /// sovereign clouds: "https://graph.microsoft.us" (GCC High / DoD) or + /// "https://microsoftgraph.chinacloudapi.cn" (China 21Vianet). /// public const string BaseUrl = "https://graph.microsoft.com"; /// - /// Resource identifier for Microsoft Graph API (used in Azure CLI token acquisition) + /// Resource identifier used in Azure CLI token acquisition (base URL + trailing slash). /// - public const string Resource = "https://graph.microsoft.com/"; + public static string GetResource(string graphBaseUrl) => graphBaseUrl.TrimEnd('/') + "/"; /// /// Endpoint versions @@ -35,7 +40,18 @@ public static class Versions } /// - /// Common Microsoft Graph permission scopes + /// Builds a fully-qualified Graph URL from a base URL and a relative path. + /// If already starts with "http" it is returned unchanged. + /// + public static string BuildUrl(string graphBaseUrl, string relativePath) + => relativePath.StartsWith("http", StringComparison.OrdinalIgnoreCase) + ? relativePath + : $"{graphBaseUrl.TrimEnd('/')}{relativePath}"; + + /// + /// Common Microsoft Graph permission scopes (commercial cloud defaults). + /// For sovereign clouds build the scope string via + /// $"{graphBaseUrl}/Application.ReadWrite.All". /// public static class Scopes { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs index 51bdbb96..d597798c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/McpConstants.cs @@ -12,6 +12,11 @@ public static class McpConstants // Agent 365 Tools App IDs for different environments public const string Agent365ToolsProdAppId = "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1"; + /// + /// Agent 365 Tools identifier URI (used for admin consent URL construction). + /// + public const string Agent365ToolsIdentifierUri = "https://agent365.svc.cloud.microsoft"; + /// /// Name of the tooling manifest file /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/PowerPlatformConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/PowerPlatformConstants.cs index 3b61dfb0..d85eb550 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/PowerPlatformConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/PowerPlatformConstants.cs @@ -13,6 +13,11 @@ public static class PowerPlatformConstants /// public const string PowerPlatformApiResourceAppId = "8578e004-a5c6-46e7-913e-12f58912df43"; + /// + /// Power Platform API identifier URI (used for admin consent URL construction). + /// + public const string PowerPlatformApiIdentifierUri = "https://api.powerplatform.com"; + /// /// Delegated permission scope names for resource applications. /// @@ -22,5 +27,10 @@ public static class PermissionNames /// Power Platform API - CopilotStudio.Copilots.Invoke permission scope name /// public const string PowerPlatformCopilotStudioInvoke = "CopilotStudio.Copilots.Invoke"; + + /// + /// Power Platform API scope used for admin consent URL construction. + /// + public const string ConnectivityConnectionsRead = "Connectivity.Connections.Read"; } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/CleanExitException.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/CleanExitException.cs new file mode 100644 index 00000000..2ac4dbc0 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/CleanExitException.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.Cli.Exceptions; + +/// +/// Signals a clean, intentional process exit with a specific exit code. +/// Thrown by ExitWithCleanup() to avoid calling Environment.Exit() directly inside +/// async System.CommandLine handlers, which can deadlock on all platforms when +/// CancelOnProcessTermination middleware is active. +/// Caught by UseExceptionHandler in Program.cs, which sets context.ExitCode and +/// returns normally — letting the runtime exit cleanly without Environment.Exit. +/// +public sealed class CleanExitException : Exception +{ + public int ExitCode { get; } + + public CleanExitException(int exitCode) : base($"Exit {exitCode}") + { + ExitCode = exitCode; + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ExceptionHandler.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ExceptionHandler.cs index 3907ddfc..fc7ed031 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ExceptionHandler.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Exceptions/ExceptionHandler.cs @@ -64,8 +64,10 @@ public static void HandleAgent365Exception(Agent365Exception ex, ILogger? logger } /// - /// Exits the application with proper cleanup: flushes console output and resets colors. - /// Use this instead of Environment.Exit to ensure logger output is visible. + /// Signals a clean, intentional exit with the given exit code. + /// Throws CleanExitException instead of calling Environment.Exit() directly, + /// which avoids deadlocks on all platforms when System.CommandLine's + /// CancelOnProcessTermination middleware is active. /// /// The exit code to return (0 for success, non-zero for errors) [System.Diagnostics.CodeAnalysis.DoesNotReturn] @@ -74,6 +76,6 @@ public static void ExitWithCleanup(int exitCode) Console.Out.Flush(); Console.Error.Flush(); Console.ResetColor(); - Environment.Exit(exitCode); + throw new CleanExitException(exitCode); } } \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/TenantDetectionHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/TenantDetectionHelper.cs index 5dea90a3..a67f54b4 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/TenantDetectionHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Helpers/TenantDetectionHelper.cs @@ -18,7 +18,7 @@ public static class TenantDetectionHelper /// Optional configuration containing tenant ID /// Logger for output messages /// Detected tenant ID or null if not found - public static async Task DetectTenantIdAsync(Agent365Config? config, ILogger logger) + public static async Task DetectTenantIdAsync(Agent365Config? config, ILogger logger, CommandExecutor? executor = null) { // First, try to get tenant ID from config if (config != null && !string.IsNullOrWhiteSpace(config.TenantId)) @@ -31,7 +31,7 @@ public static class TenantDetectionHelper try { - var executor = new CommandExecutor( + executor ??= new CommandExecutor( Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); var result = await executor.ExecuteAsync( diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs index 167fe8be..d4001b17 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/Agent365Config.cs @@ -149,6 +149,16 @@ private static void ValidateGuid(string value, string fieldName, List er [JsonPropertyName("needDeployment")] public bool NeedDeployment { get; init; } = true; + /// + /// Base URL for Microsoft Graph API. + /// Override this to target sovereign / government clouds: + /// GCC High / DoD : "https://graph.microsoft.us" + /// China (21Vianet): "https://microsoftgraph.chinacloudapi.cn" + /// Defaults to "https://graph.microsoft.com" when omitted. + /// + [JsonPropertyName("graphBaseUrl")] + public string GraphBaseUrl { get; init; } = Constants.GraphApiConstants.BaseUrl; + #endregion #region Authentication Configuration @@ -420,9 +430,14 @@ public string BotName public string? BotMsaAppId { get; set; } /// - /// Messaging endpoint URL for the bot. + /// Messaging endpoint URL for the agent (stored in generated config as "messagingEndpoint"). + /// [JsonIgnore] prevents a duplicate-key collision with the static + /// property when Agent365Config is serialized directly via System.Text.Json (both would emit + /// the same "messagingEndpoint" key). GetGeneratedConfig() uses reflection to read + /// [JsonPropertyName] independently, so persistence to the generated config file is unaffected. /// - [JsonPropertyName("botMessagingEndpoint")] + [JsonIgnore] + [JsonPropertyName("messagingEndpoint")] public string? BotMessagingEndpoint { get; set; } #endregion diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/ResourceConsent.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/ResourceConsent.cs index 6a5ae956..51a52ce4 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/ResourceConsent.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/ResourceConsent.cs @@ -24,8 +24,8 @@ public class ResourceConsent /// /// Admin consent URL for granting permissions via browser. - /// Only populated for resources requiring interactive consent (e.g., Microsoft Graph). - /// API-based grants (Bot API, Observability API) do not require consent URLs. + /// Populated for all five required resources when the current user lacks the Global Administrator + /// role. A tenant administrator can open each URL to grant AllPrincipals consent interactively. /// [JsonPropertyName("consentUrl")] public string? ConsentUrl { get; set; } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/RoleCheckResult.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/RoleCheckResult.cs new file mode 100644 index 00000000..b39a1079 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/RoleCheckResult.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Microsoft.Agents.A365.DevTools.Cli.Models; + +/// +/// Represents the result of a directory role membership check. +/// +public enum RoleCheckResult +{ + /// Role is confirmed active — proceed with confidence or skip redundant work. + HasRole, + + /// Role is confirmed absent — fail fast with a clear message. + DoesNotHaveRole, + + /// + /// Check failed (e.g. network error, throttling, auth failure) — attempt the operation + /// anyway and let the API surface the real error rather than blocking on a false negative. + /// + Unknown +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs index cf52a5c4..09d9c276 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Program.cs @@ -136,6 +136,7 @@ await Task.WhenAll( var deploymentService = serviceProvider.GetRequiredService(); var botConfigurator = serviceProvider.GetRequiredService(); var graphApiService = serviceProvider.GetRequiredService(); + var armApiService = serviceProvider.GetRequiredService(); var agentBlueprintService = serviceProvider.GetRequiredService(); var blueprintLookupService = serviceProvider.GetRequiredService(); var federatedCredentialService = serviceProvider.GetRequiredService(); @@ -146,8 +147,9 @@ await Task.WhenAll( // Add commands rootCommand.AddCommand(DevelopCommand.CreateCommand(developLogger, configService, executor, authService, graphApiService, agentBlueprintService, processService)); rootCommand.AddCommand(DevelopMcpCommand.CreateCommand(developLogger, toolingService)); + var confirmationProvider = serviceProvider.GetRequiredService(); rootCommand.AddCommand(SetupCommand.CreateCommand(setupLogger, configService, executor, - deploymentService, botConfigurator, azureAuthValidator, platformDetector, graphApiService, agentBlueprintService, blueprintLookupService, federatedCredentialService, clientAppValidator)); + deploymentService, botConfigurator, azureAuthValidator, platformDetector, graphApiService, agentBlueprintService, blueprintLookupService, federatedCredentialService, clientAppValidator, confirmationProvider, armApiService)); rootCommand.AddCommand(CreateInstanceCommand.CreateCommand(createInstanceLogger, configService, executor, botConfigurator, graphApiService)); rootCommand.AddCommand(DeployCommand.CreateCommand(deployLogger, configService, executor, @@ -158,7 +160,6 @@ await Task.WhenAll( var configLogger = configLoggerFactory.CreateLogger("ConfigCommand"); var wizardService = serviceProvider.GetRequiredService(); var manifestTemplateService = serviceProvider.GetRequiredService(); - var confirmationProvider = serviceProvider.GetRequiredService(); rootCommand.AddCommand(ConfigCommand.CreateCommand(configLogger, wizardService: wizardService, clientAppValidator: clientAppValidator)); rootCommand.AddCommand(QueryEntraCommand.CreateCommand(queryEntraLogger, configService, executor, graphApiService, agentBlueprintService)); rootCommand.AddCommand(CleanupCommand.CreateCommand(cleanupLogger, configService, botConfigurator, executor, agentBlueprintService, confirmationProvider, federatedCredentialService, azureAuthValidator)); @@ -170,7 +171,15 @@ await Task.WhenAll( .UseDefaults() .UseExceptionHandler((exception, context) => { - if (exception is Agent365Exception myEx) + if (exception is CleanExitException cleanExit) + { + context.ExitCode = cleanExit.ExitCode; + } + else if (exception is OperationCanceledException) + { + context.ExitCode = 1; + } + else if (exception is Agent365Exception myEx) { ExceptionHandler.HandleAgent365Exception(myEx, logFilePath: logFilePath); context.ExitCode = myEx.ExitCode; @@ -194,6 +203,21 @@ await Task.WhenAll( var parser = builder.Build(); return await parser.InvokeAsync(args); } + catch (Exception ex) + { + // Catch anything that escapes before or after the System.CommandLine pipeline + // (e.g. DI setup failures, exceptions in InvokeAsync itself). + // Log the full details to the file; show only a clean one-liner to the user. + startupLogger.LogCritical(ex, "Unhandled exception in CLI startup"); + Console.ForegroundColor = ConsoleColor.Red; + Console.Error.WriteLine($"ERROR: {ex.Message}"); + Console.ResetColor(); + Console.Error.WriteLine(); + if (!string.IsNullOrEmpty(logFilePath)) + Console.Error.WriteLine($"For details, see the log file at: {logFilePath}"); + Console.Error.WriteLine("If this error persists, please report it at: https://github.com/microsoft/Agent365-devTools/issues"); + return 1; + } finally { Console.ResetColor(); @@ -281,6 +305,7 @@ private static void ConfigureServices(IServiceCollection services, LogLevel mini services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs index 3bdd85f8..aaa66491 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/A365CreateInstanceRunner.cs @@ -200,7 +200,7 @@ string GetConfig(string name) => if (agentIdentityScopes.Count == 0) { _logger.LogWarning("No agent identity scopes available, falling back to Graph default"); - agentIdentityScopes.Add("https://graph.microsoft.com/.default"); + agentIdentityScopes.Add($"{GraphApiConstants.BaseUrl}/.default"); } var usageLocation = GetConfig("agentUserUsageLocation"); @@ -488,7 +488,7 @@ string GetConfig(string name) => { using var delegatedClient = HttpClientFactory.CreateAuthenticatedClient(delegatedToken, correlationId: correlationId); - var meResponse = await delegatedClient.GetAsync("https://graph.microsoft.com/v1.0/me", ct); + using var meResponse = await delegatedClient.GetAsync($"{GraphApiConstants.BaseUrl}/v1.0/me", ct); if (meResponse.IsSuccessStatusCode) { var meJson = await meResponse.Content.ReadAsStringAsync(ct); @@ -504,7 +504,7 @@ string GetConfig(string name) => } // Create agent identity via service principal endpoint - var createIdentityUrl = "https://graph.microsoft.com/beta/serviceprincipals/Microsoft.Graph.AgentIdentity"; + var createIdentityUrl = $"{GraphApiConstants.BaseUrl}/beta/serviceprincipals/Microsoft.Graph.AgentIdentity"; var identityBody = new JsonObject { ["displayName"] = displayName, @@ -516,7 +516,7 @@ string GetConfig(string name) => { identityBody["sponsors@odata.bind"] = new JsonArray { - $"https://graph.microsoft.com/v1.0/users/{currentUserId}" + $"{GraphApiConstants.BaseUrl}/v1.0/users/{currentUserId}" }; } @@ -615,11 +615,11 @@ string GetConfig(string name) => { new KeyValuePair("client_id", clientId), new KeyValuePair("client_secret", clientSecret), - new KeyValuePair("scope", "https://graph.microsoft.com/.default"), + new KeyValuePair("scope", $"{GraphApiConstants.BaseUrl}/.default"), new KeyValuePair("grant_type", "client_credentials") }); - var response = await httpClient.PostAsync(tokenEndpoint, requestBody, ct); + using var response = await httpClient.PostAsync(tokenEndpoint, requestBody, ct); if (!response.IsSuccessStatusCode) { @@ -692,7 +692,7 @@ string GetConfig(string name) => // Check if user already exists try { - var checkUserUrl = $"https://graph.microsoft.com/beta/users/{Uri.EscapeDataString(userPrincipalName)}"; + var checkUserUrl = $"{GraphApiConstants.BaseUrl}/beta/users/{Uri.EscapeDataString(userPrincipalName)}"; var checkResponse = await httpClient.GetAsync(checkUserUrl, ct); if (checkResponse.IsSuccessStatusCode) @@ -716,7 +716,7 @@ string GetConfig(string name) => // Create agent user var mailNickname = userPrincipalName.Split('@')[0]; - var createUserUrl = "https://graph.microsoft.com/beta/users"; + var createUserUrl = $"{GraphApiConstants.BaseUrl}/beta/users"; var userBody = new JsonObject { ["@odata.type"] = "microsoft.graph.agentUser", @@ -783,7 +783,7 @@ private async Task AssignManagerAsync( using var httpClient = HttpClientFactory.CreateAuthenticatedClient(graphToken, correlationId: correlationId); // Look up manager by email - var managerUrl = $"https://graph.microsoft.com/v1.0/users?$filter=mail eq '{managerEmail}'"; + var managerUrl = $"{GraphApiConstants.BaseUrl}/v1.0/users?$filter=mail eq '{managerEmail}'"; var managerResponse = await httpClient.GetAsync(managerUrl, ct); if (!managerResponse.IsSuccessStatusCode) @@ -807,10 +807,10 @@ private async Task AssignManagerAsync( var managerName = manager["displayName"]?.GetValue(); // Assign manager - var assignManagerUrl = $"https://graph.microsoft.com/v1.0/users/{userId}/manager/$ref"; + var assignManagerUrl = $"{GraphApiConstants.BaseUrl}/v1.0/users/{userId}/manager/$ref"; var assignBody = new JsonObject { - ["@odata.id"] = $"https://graph.microsoft.com/v1.0/users/{managerId}" + ["@odata.id"] = $"{GraphApiConstants.BaseUrl}/v1.0/users/{managerId}" }; var assignResponse = await httpClient.PutAsync( @@ -953,7 +953,7 @@ private async Task AssignLicensesAsync( if (!string.IsNullOrWhiteSpace(usageLocation)) { _logger.LogInformation(" - Setting usage location: {Location}", usageLocation); - var updateUserUrl = $"https://graph.microsoft.com/v1.0/users/{userId}"; + var updateUserUrl = $"{GraphApiConstants.BaseUrl}/v1.0/users/{userId}"; var updateBody = new JsonObject { ["usageLocation"] = usageLocation @@ -973,7 +973,7 @@ private async Task AssignLicensesAsync( // Assign licenses _logger.LogInformation(" - Assigning Microsoft 365 licenses"); - var assignLicenseUrl = $"https://graph.microsoft.com/v1.0/users/{userId}/assignLicense"; + var assignLicenseUrl = $"{GraphApiConstants.BaseUrl}/v1.0/users/{userId}/assignLicense"; var licenseBody = new JsonObject { ["addLicenses"] = new JsonArray @@ -1064,7 +1064,7 @@ private async Task RequestAdminConsentAsync( { var spResult = await _executor.ExecuteAsync( "az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '{appId}'\"", + $"rest --method GET --url \"{GraphApiConstants.BaseUrl}/v1.0/servicePrincipals?$filter=appId eq '{appId}'\"", captureOutput: true, suppressErrorLogging: true, cancellationToken: cancellationToken); @@ -1089,7 +1089,7 @@ private async Task RequestAdminConsentAsync( { var grants = await _executor.ExecuteAsync( "az", - $"rest --method GET --url \"https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spId}'\"", + $"rest --method GET --url \"{GraphApiConstants.BaseUrl}/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spId}'\"", captureOutput: true, suppressErrorLogging: true, cancellationToken: cancellationToken); @@ -1161,8 +1161,8 @@ private async Task VerifyServicePrincipalExistsAsync( using var httpClient = HttpClientFactory.CreateAuthenticatedClient(graphToken, correlationId: correlationId); // Query for service principal by appId - var spUrl = $"https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '{appId}'"; - var response = await httpClient.GetAsync(spUrl, ct); + var spUrl = $"{GraphApiConstants.BaseUrl}/v1.0/servicePrincipals?$filter=appId eq '{appId}'"; + using var response = await httpClient.GetAsync(spUrl, ct); if (!response.IsSuccessStatusCode) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs index c99dbfe6..47f6c583 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs @@ -243,7 +243,8 @@ private string BuildGetMCPServerUrl(string environment) var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); _logger.LogInformation("Acquiring access token for audience: {Audience}", audience); - var authToken = await _authService.GetAccessTokenAsync(audience); + var loginHint = await AzCliHelper.ResolveLoginHintAsync(); + var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -257,7 +258,7 @@ private string BuildGetMCPServerUrl(string environment) LogRequest("GET", endpointUrl); // Make request - var response = await httpClient.GetAsync(endpointUrl, cancellationToken); + using var response = await httpClient.GetAsync(endpointUrl, cancellationToken); // Validate response using common helper var (isSuccess, responseContent) = await ValidateResponseAsync(response, "list environments", cancellationToken); @@ -321,7 +322,8 @@ private string BuildGetMCPServerUrl(string environment) var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); _logger.LogInformation("Acquiring access token for audience: {Audience}", audience); - var authToken = await _authService.GetAccessTokenAsync(audience); + var loginHint = await AzCliHelper.ResolveLoginHintAsync(); + var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -335,7 +337,7 @@ private string BuildGetMCPServerUrl(string environment) LogRequest("GET", endpointUrl); // Make request - var response = await httpClient.GetAsync(endpointUrl, cancellationToken); + using var response = await httpClient.GetAsync(endpointUrl, cancellationToken); // Validate response using common helper var (isSuccess, responseContent) = await ValidateResponseAsync(response, "list MCP servers", cancellationToken); @@ -394,7 +396,8 @@ private string BuildGetMCPServerUrl(string environment) var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); _logger.LogInformation("Acquiring access token for audience: {Audience}", audience); - var authToken = await _authService.GetAccessTokenAsync(audience); + var loginHint = await AzCliHelper.ResolveLoginHintAsync(); + var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -415,7 +418,7 @@ private string BuildGetMCPServerUrl(string environment) LogRequest("POST", endpointUrl, requestPayload); // Make request - var response = await httpClient.PostAsync(endpointUrl, jsonContent, cancellationToken); + using var response = await httpClient.PostAsync(endpointUrl, jsonContent, cancellationToken); // Validate response using common helper var (isSuccess, responseContent) = await ValidateResponseAsync(response, "publish MCP server", cancellationToken); @@ -480,7 +483,8 @@ public async Task UnpublishServerAsync( var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); _logger.LogInformation("Acquiring access token for audience: {Audience}", audience); - var authToken = await _authService.GetAccessTokenAsync(audience); + var loginHint = await AzCliHelper.ResolveLoginHintAsync(); + var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -494,7 +498,7 @@ public async Task UnpublishServerAsync( LogRequest("DELETE", endpointUrl); // Make request - var response = await httpClient.DeleteAsync(endpointUrl, cancellationToken); + using var response = await httpClient.DeleteAsync(endpointUrl, cancellationToken); // Validate response using common helper var (isSuccess, _) = await ValidateResponseAsync(response, "unpublish MCP server", cancellationToken); @@ -540,7 +544,8 @@ public async Task ApproveServerAsync( var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); _logger.LogInformation("Acquiring access token for audience: {Audience}", audience); - var authToken = await _authService.GetAccessTokenAsync(audience); + var loginHint = await AzCliHelper.ResolveLoginHintAsync(); + var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -555,7 +560,7 @@ public async Task ApproveServerAsync( // Make request with empty content var content = new StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); - var response = await httpClient.PostAsync(endpointUrl, content, cancellationToken); + using var response = await httpClient.PostAsync(endpointUrl, content, cancellationToken); // Validate response using common helper var (isSuccess, responseContent) = await ValidateResponseAsync(response, "approve MCP server", cancellationToken); @@ -601,7 +606,8 @@ public async Task BlockServerAsync( var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); _logger.LogInformation("Acquiring access token for audience: {Audience}", audience); - var authToken = await _authService.GetAccessTokenAsync(audience); + var loginHint = await AzCliHelper.ResolveLoginHintAsync(); + var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -616,7 +622,7 @@ public async Task BlockServerAsync( // Make request with empty content var content = new StringContent(string.Empty, System.Text.Encoding.UTF8, "application/json"); - var response = await httpClient.PostAsync(endpointUrl, content, cancellationToken); + using var response = await httpClient.PostAsync(endpointUrl, content, cancellationToken); // Validate response using common helper var (isSuccess, responseContent) = await ValidateResponseAsync(response, "block MCP server", cancellationToken); @@ -651,7 +657,8 @@ public async Task GetServerInfoAsync(string serverName, Cancellation var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); _logger.LogInformation("Acquiring access token for audience: {Audience}", audience); - var authToken = await _authService.GetAccessTokenAsync(audience); + var loginHint = await AzCliHelper.ResolveLoginHintAsync(); + var authToken = await _authService.GetAccessTokenAsync(audience, userId: loginHint); if (string.IsNullOrWhiteSpace(authToken)) { _logger.LogError("Failed to acquire authentication token"); @@ -692,7 +699,7 @@ public async Task GetServerInfoAsync(string serverName, Cancellation request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream")); // Send the request - var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); + using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); if (!response.IsSuccessStatusCode) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs index e1735b04..e79243a8 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AgentBlueprintService.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.Extensions.Logging; @@ -67,9 +68,9 @@ public string? CustomClientAppId /// /// Delete an Agent Blueprint application using the special agentIdentityBlueprint endpoint. - /// + /// /// SPECIAL AUTHENTICATION REQUIREMENTS: - /// Agent Blueprint deletion requires the AgentIdentityBlueprint.ReadWrite.All delegated permission scope. + /// Agent Blueprint deletion requires a delegated permission scope. /// This scope is not available through Azure CLI tokens, so we use interactive authentication via /// the token provider (same authentication method used during blueprint creation in the setup command). /// @@ -85,16 +86,14 @@ public virtual async Task DeleteAgentBlueprintAsync( try { _logger.LogInformation("Deleting agent blueprint application: {BlueprintId}", blueprintId); - - // Agent Blueprint deletion requires special delegated permission scope - var requiredScopes = new[] { "AgentIdentityBlueprint.ReadWrite.All" }; - - _logger.LogInformation("Acquiring access token with AgentIdentityBlueprint.ReadWrite.All scope..."); - _logger.LogInformation("A browser window will open for authentication."); - - // Use the special agentIdentityBlueprint endpoint for deletion + + var requiredScopes = new[] { AuthenticationConstants.AgentIdentityBlueprintDeleteRestoreAllScope }; + + _logger.LogInformation("Acquiring access token with AgentIdentityBlueprint.DeleteRestore.All scope..."); + _logger.LogInformation("An authentication dialog will appear to complete sign-in."); + var deletePath = $"/beta/applications/{blueprintId}/microsoft.graph.agentIdentityBlueprint"; - + // Use GraphDeleteAsync with the special scopes required for blueprint operations var success = await _graphApiService.GraphDeleteAsync( tenantId, @@ -102,7 +101,7 @@ public virtual async Task DeleteAgentBlueprintAsync( cancellationToken, treatNotFoundAsSuccess: true, scopes: requiredScopes); - + if (success) { _logger.LogInformation("Agent blueprint application deleted successfully"); @@ -111,7 +110,7 @@ public virtual async Task DeleteAgentBlueprintAsync( { _logger.LogError("Failed to delete agent blueprint application"); } - + return success; } catch (Exception ex) @@ -138,11 +137,11 @@ public virtual async Task DeleteAgentIdentityAsync( { _logger.LogInformation("Deleting agent identity application: {ApplicationId}", applicationId); - // Agent Identity deletion requires special delegated permission scope - var requiredScopes = new[] { "AgentIdentityBlueprint.ReadWrite.All" }; + // Agent Identity deletion requires the same DeleteRestore scope as blueprint deletion. + var requiredScopes = new[] { AuthenticationConstants.AgentIdentityBlueprintDeleteRestoreAllScope }; - _logger.LogInformation("Acquiring access token with AgentIdentityBlueprint.ReadWrite.All scope..."); - _logger.LogInformation("A browser window will open for authentication."); + _logger.LogInformation("Acquiring access token with AgentIdentityBlueprint.DeleteRestore.All scope..."); + _logger.LogInformation("An authentication dialog will appear to complete sign-in."); // Use the special servicePrincipals endpoint for deletion var deletePath = $"/beta/servicePrincipals/{applicationId}"; @@ -177,7 +176,7 @@ public virtual async Task> GetAgentInstancesFor string blueprintId, CancellationToken cancellationToken = default) { - var requiredScopes = new[] { "AgentIdentityBlueprint.ReadWrite.All" }; + var requiredScopes = new[] { AuthenticationConstants.AgentIdentityBlueprintReadWriteAllScope }; var encodedId = Uri.EscapeDataString(blueprintId); // Fetch agent identity SPs and agent users for this blueprint sequentially to avoid races on shared HTTP headers @@ -299,7 +298,7 @@ public virtual async Task DeleteAgentUserAsync( { _logger.LogInformation("Deleting agentic user: {AgentUserId}", agentUserId); - var requiredScopes = new[] { "AgentIdentityBlueprint.ReadWrite.All" }; + var requiredScopes = new[] { AuthenticationConstants.AgentIdentityBlueprintReadWriteAllScope }; var deletePath = $"/beta/agentUsers/{agentUserId}"; var success = await _graphApiService.GraphDeleteAsync( @@ -372,7 +371,7 @@ public virtual async Task DeleteAgentUserAsync( if (desiredSet.IsSubsetOf(currentSet)) { - _logger.LogInformation("Inheritable permissions already exist for blueprint {Blueprint} resource {Resource}", blueprintObjectId, resourceAppId); + _logger.LogDebug("Inheritable permissions already exist for blueprint {Blueprint} resource {Resource}", blueprintObjectId, resourceAppId); return (ok: true, alreadyExists: true, error: null); } @@ -395,7 +394,7 @@ public virtual async Task DeleteAgentUserAsync( return (ok: false, alreadyExists: false, error: "PATCH failed"); } - _logger.LogInformation("Patched inheritable permissions for blueprint {Blueprint} resource {Resource}", blueprintObjectId, resourceAppId); + _logger.LogDebug("Patched inheritable permissions for blueprint {Blueprint} resource {Resource}", blueprintObjectId, resourceAppId); return (ok: true, alreadyExists: false, error: null); } @@ -416,11 +415,16 @@ public virtual async Task DeleteAgentUserAsync( var err = string.IsNullOrWhiteSpace(createdResp.Body) ? $"HTTP {createdResp.StatusCode} {createdResp.ReasonPhrase}" : createdResp.Body; - _logger.LogError("Failed to create inheritable permissions: {Status} {Reason} Body: {Body}", createdResp.StatusCode, createdResp.ReasonPhrase, createdResp.Body); + // 403 means insufficient role (Agent ID Administrator required) — expected for + // non-admin users; logged at debug to avoid noise. Other failures are warnings. + if ((int)createdResp.StatusCode == 403) + _logger.LogDebug("Inheritable permissions not set (insufficient role): {Status} Body: {Body}", createdResp.StatusCode, createdResp.Body); + else + _logger.LogWarning("Failed to create inheritable permissions: {Status} {Reason} Body: {Body}", createdResp.StatusCode, createdResp.ReasonPhrase, createdResp.Body); return (ok: false, alreadyExists: false, error: err); } - _logger.LogInformation("Created inheritable permissions for blueprint {Blueprint} resource {Resource}", blueprintObjectId, resourceAppId); + _logger.LogDebug("Created inheritable permissions for blueprint {Blueprint} resource {Resource}", blueprintObjectId, resourceAppId); return (ok: true, alreadyExists: false, error: null); } catch (Exception ex) @@ -610,8 +614,15 @@ public virtual async Task ReplaceOauth2PermissionGrantAsync( scope = desiredScopeString }; - var created = await _graphApiService.GraphPostAsync(tenantId, "/v1.0/oauth2PermissionGrants", payload, ct); - return created != null; + var grantResponse = await _graphApiService.GraphPostWithResponseAsync(tenantId, "/v1.0/oauth2PermissionGrants", payload, ct); + if (!grantResponse.IsSuccess) + { + if (grantResponse.StatusCode == 403) + _logger.LogWarning("Creating oauth2PermissionGrant requires the Global Administrator role (status 403). An admin must grant consent for these permissions."); + else + _logger.LogError("Failed to create oauth2PermissionGrant: {Status} {Reason}", grantResponse.StatusCode, grantResponse.ReasonPhrase); + } + return grantResponse.IsSuccess; } public virtual async Task CreateOrUpdateOauth2PermissionGrantAsync( @@ -643,8 +654,15 @@ public virtual async Task CreateOrUpdateOauth2PermissionGrantAsync( resourceId = resourceSpObjectId, scope = desiredScopeString }; - var created = await _graphApiService.GraphPostAsync(tenantId, "/v1.0/oauth2PermissionGrants", payload, ct); - return created != null; // success if response parsed + var grantResponse = await _graphApiService.GraphPostWithResponseAsync(tenantId, "/v1.0/oauth2PermissionGrants", payload, ct); + if (!grantResponse.IsSuccess) + { + if (grantResponse.StatusCode == 403) + _logger.LogWarning("Creating oauth2PermissionGrant requires the Global Administrator role (status 403). An admin must grant consent for these permissions."); + else + _logger.LogError("Failed to create oauth2PermissionGrant: {Status} {Reason}", grantResponse.StatusCode, grantResponse.ReasonPhrase); + } + return grantResponse.IsSuccess; } // Merge scopes if needed @@ -680,12 +698,13 @@ public virtual async Task AddRequiredResourceAccessAsync( string resourceAppId, IEnumerable scopes, bool isDelegated = true, - CancellationToken ct = default) + CancellationToken ct = default, + IEnumerable? requiredScopes = null) { try { // Get the application object by appId - var appsDoc = await _graphApiService.GraphGetAsync(tenantId, $"/v1.0/applications?$filter=appId eq '{appId}'&$select=id,requiredResourceAccess", ct); + var appsDoc = await _graphApiService.GraphGetAsync(tenantId, $"/v1.0/applications?$filter=appId eq '{appId}'&$select=id,requiredResourceAccess", ct, scopes: requiredScopes); if (appsDoc == null) { _logger.LogError("Failed to retrieve application with appId {AppId}", appId); @@ -707,7 +726,7 @@ public virtual async Task AddRequiredResourceAccessAsync( var objectId = idProp.GetString()!; // Get the resource service principal to look up permission IDs - var resourceSp = await _graphApiService.LookupServicePrincipalByAppIdAsync(tenantId, resourceAppId, ct); + var resourceSp = await _graphApiService.LookupServicePrincipalByAppIdAsync(tenantId, resourceAppId, ct, requiredScopes); if (string.IsNullOrEmpty(resourceSp)) { _logger.LogError("Resource service principal not found for appId {ResourceAppId}", resourceAppId); @@ -715,7 +734,7 @@ public virtual async Task AddRequiredResourceAccessAsync( } // Get the resource SP's published permissions - var resourceSpDoc = await _graphApiService.GraphGetAsync(tenantId, $"/v1.0/servicePrincipals/{resourceSp}?$select=oauth2PermissionScopes,appRoles", ct); + var resourceSpDoc = await _graphApiService.GraphGetAsync(tenantId, $"/v1.0/servicePrincipals/{resourceSp}?$select=oauth2PermissionScopes,appRoles", ct, scopes: requiredScopes); if (resourceSpDoc == null) { _logger.LogError("Failed to retrieve resource service principal {ResourceSp}", resourceSp); @@ -827,7 +846,7 @@ public virtual async Task AddRequiredResourceAccessAsync( requiredResourceAccess = resourceAccessList }; - var updated = await _graphApiService.GraphPatchAsync(tenantId, $"/v1.0/applications/{objectId}", patchPayload, ct); + var updated = await _graphApiService.GraphPatchAsync(tenantId, $"/v1.0/applications/{objectId}", patchPayload, ct, scopes: requiredScopes); if (updated) { _logger.LogInformation("Successfully added required resource access for {ResourceAppId} to application {AppId}", resourceAppId, appId); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs new file mode 100644 index 00000000..2fec7fc4 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ArmApiService.cs @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Microsoft.Agents.A365.DevTools.Cli.Services.Internal; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using System.Net; +using System.Net.Http.Headers; +using System.Text.Json; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Service for Azure Resource Manager (ARM) existence checks via direct HTTP. +/// Replaces subprocess-based 'az group exists', 'az appservice plan show', and +/// 'az webapp show' calls — each drops from ~15-20s to ~0.5s. +/// Token acquisition is handled by AzCliHelper (process-level cache shared with +/// other services using the management endpoint). +/// +public class ArmApiService : IDisposable +{ + private const string ArmBaseUrl = "https://management.azure.com"; + internal const string ArmResource = "https://management.core.windows.net/"; + private const string ResourceGroupApiVersion = "2021-04-01"; + private const string AppServiceApiVersion = "2022-03-01"; + + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + + // Allow injecting a custom HttpMessageHandler for unit testing. + public ArmApiService(ILogger logger, HttpMessageHandler? handler = null) + { + _logger = logger; + _httpClient = handler != null ? new HttpClient(handler) : HttpClientFactory.CreateAuthenticatedClient(); + } + + // Parameterless constructor to ease test mocking/substitution frameworks. + public ArmApiService() + : this(NullLogger.Instance, null) + { + } + + public void Dispose() => _httpClient.Dispose(); + + private async Task EnsureArmHeadersAsync(string tenantId, CancellationToken ct) + { + var token = await AzCliHelper.AcquireAzCliTokenAsync(ArmResource, tenantId); + if (string.IsNullOrWhiteSpace(token)) + { + _logger.LogWarning("Unable to acquire ARM access token for tenant {TenantId}", tenantId); + return false; + } + _httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", token.ReplaceLineEndings(string.Empty).Trim()); + return true; + } + + /// + /// Checks whether a resource group exists in the given subscription. + /// Returns null if the ARM token cannot be acquired (caller should fall back to az CLI). + /// + public virtual async Task ResourceGroupExistsAsync( + string subscriptionId, + string resourceGroup, + string tenantId, + CancellationToken ct = default) + { + if (!await EnsureArmHeadersAsync(tenantId, ct)) + return null; + + var url = $"{ArmBaseUrl}/subscriptions/{subscriptionId}/resourcegroups/{resourceGroup}?api-version={ResourceGroupApiVersion}"; + _logger.LogDebug("ARM GET resource group: {ResourceGroup}", resourceGroup); + + try + { + using var response = await _httpClient.GetAsync(url, ct); + _logger.LogDebug("ARM resource group check: {StatusCode}", response.StatusCode); + if (response.StatusCode == HttpStatusCode.OK) return true; + if (response.StatusCode == HttpStatusCode.NotFound) return false; + return null; // 401/403/5xx — caller falls back to az CLI + } + catch (Exception ex) + { + _logger.LogDebug(ex, "ARM resource group check failed — will fall back to az CLI"); + return null; + } + } + + /// + /// Checks whether an App Service plan exists. + /// Returns null if the ARM token cannot be acquired. + /// + public virtual async Task AppServicePlanExistsAsync( + string subscriptionId, + string resourceGroup, + string planName, + string tenantId, + CancellationToken ct = default) + { + if (!await EnsureArmHeadersAsync(tenantId, ct)) + return null; + + var url = $"{ArmBaseUrl}/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.Web/serverfarms/{planName}?api-version={AppServiceApiVersion}"; + _logger.LogDebug("ARM GET app service plan: {PlanName}", planName); + + try + { + using var response = await _httpClient.GetAsync(url, ct); + _logger.LogDebug("ARM app service plan check: {StatusCode}", response.StatusCode); + if (response.StatusCode == HttpStatusCode.OK) return true; + if (response.StatusCode == HttpStatusCode.NotFound) return false; + return null; // 401/403/5xx — caller falls back to az CLI + } + catch (Exception ex) + { + _logger.LogDebug(ex, "ARM app service plan check failed — will fall back to az CLI"); + return null; + } + } + + /// + /// Checks whether a web app exists. + /// Returns null if the ARM token cannot be acquired. + /// + public virtual async Task WebAppExistsAsync( + string subscriptionId, + string resourceGroup, + string webAppName, + string tenantId, + CancellationToken ct = default) + { + if (!await EnsureArmHeadersAsync(tenantId, ct)) + return null; + + var url = $"{ArmBaseUrl}/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.Web/sites/{webAppName}?api-version={AppServiceApiVersion}"; + _logger.LogDebug("ARM GET web app: {WebAppName}", webAppName); + + try + { + using var response = await _httpClient.GetAsync(url, ct); + _logger.LogDebug("ARM web app check: {StatusCode}", response.StatusCode); + if (response.StatusCode == HttpStatusCode.OK) return true; + if (response.StatusCode == HttpStatusCode.NotFound) return false; + return null; // 401/403/5xx — caller falls back to az CLI + } + catch (Exception ex) + { + _logger.LogDebug(ex, "ARM web app check failed — will fall back to az CLI"); + return null; + } + } + + // Built-in Azure RBAC role definition GUIDs (stable across all tenants/subscriptions). + private static readonly Dictionary RoleGuidToName = new(StringComparer.OrdinalIgnoreCase) + { + ["8e3af657-a8ff-443c-a75c-2fe8c4bcb635"] = "Owner", + ["b24988ac-6180-42a0-ab88-20f7382dd24c"] = "Contributor", + ["de139f84-1756-47ae-9be6-808fbbe84772"] = "Website Contributor", + }; + + /// + /// Checks whether the user already has a sufficient Azure RBAC role (Owner, Contributor, or + /// Website Contributor) on the web app or any parent scope (resource group / subscription). + /// Replaces 'az role assignment list --assignee ... --include-inherited' (~35s) with a + /// direct ARM HTTP call (~300ms). + /// + /// Returns: non-empty role name if found, empty string if not found, + /// null if the HTTP call fails (caller should fall back to az CLI or attempt assignment). + /// + public virtual async Task GetSufficientWebAppRoleAsync( + string subscriptionId, + string resourceGroup, + string webAppName, + string userObjectId, + string tenantId, + CancellationToken ct = default) + { + if (!await EnsureArmHeadersAsync(tenantId, ct)) + return null; + + var webAppScope = $"/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.Web/sites/{webAppName}"; + var url = $"{ArmBaseUrl}/subscriptions/{subscriptionId}/providers/Microsoft.Authorization/roleAssignments" + + $"?api-version=2022-04-01&$filter=assignedTo('{userObjectId}')"; + _logger.LogDebug("ARM GET role assignments for user {UserId} in subscription {Sub}", userObjectId, subscriptionId); + + try + { + using var response = await _httpClient.GetAsync(url, ct); + if (!response.IsSuccessStatusCode) + { + _logger.LogDebug("ARM role assignment check returned {StatusCode}", response.StatusCode); + return null; + } + + var body = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(body); + if (!doc.RootElement.TryGetProperty("value", out var assignments)) + return string.Empty; + + foreach (var assignment in assignments.EnumerateArray()) + { + if (!assignment.TryGetProperty("properties", out var props)) continue; + + var scope = props.TryGetProperty("scope", out var s) ? s.GetString() ?? string.Empty : string.Empty; + var roleDefId = props.TryGetProperty("roleDefinitionId", out var r) ? r.GetString() ?? string.Empty : string.Empty; + + // Scope must be at or above the web app in the hierarchy for inheritance to apply. + if (!webAppScope.StartsWith(scope, StringComparison.OrdinalIgnoreCase)) continue; + + // Extract the GUID from the full role definition resource ID. + var roleGuid = roleDefId.Contains('/') ? roleDefId[(roleDefId.LastIndexOf('/') + 1)..] : roleDefId; + if (RoleGuidToName.TryGetValue(roleGuid, out var roleName)) + return roleName; + } + + return string.Empty; // Authenticated successfully, no sufficient role found + } + catch (Exception ex) + { + _logger.LogDebug(ex, "ARM role assignment check failed — will fall back to az CLI"); + return null; + } + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs index 6d8ea44d..826eec1d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs @@ -7,6 +7,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.Helpers; using System.Text.Json; namespace Microsoft.Agents.A365.DevTools.Cli.Services; @@ -21,7 +22,7 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services; /// /// TOKEN CACHING: /// - Cache Location: %LocalApplicationData%\Agent365\token-cache.json (Windows) -/// - Cache Key Format: {resourceUrl}:tenant:{tenantId} +/// - Cache Key Format: {resourceUrl}:tenant:{tenantId}[:user:{userId}] /// - Cache Expiration: Validated with 5-minute buffer before token expiry /// - Reuse Across Commands: All CLI commands share the same token cache /// @@ -64,15 +65,21 @@ public async Task GetAccessTokenAsync( bool forceRefresh = false, string? clientId = null, IEnumerable? scopes = null, - bool useInteractiveBrowser = true) + bool useInteractiveBrowser = true, + string? userId = null) { - // Build cache key based on resource and tenant only + // Build cache key based on resource, tenant, and user identity. + // Including userId ensures that cached tokens are not shared across different users + // (e.g., a developer's cached token is not reused when an admin runs cleanup). // Azure AD returns tokens with all consented scopes regardless of which scopes are requested, // so we don't include scopes in the cache key to avoid duplicate cache entries for the same token. // The scopes parameter is still passed to Azure AD for incremental consent and validation. string cacheKey = string.IsNullOrWhiteSpace(tenantId) ? resourceUrl : $"{resourceUrl}:tenant:{tenantId}"; + if (!string.IsNullOrWhiteSpace(userId)) + cacheKey = $"{cacheKey}:user:{userId}"; + _logger.LogDebug("ATG cache key: {CacheKey}", cacheKey); // Try to load cached token for this cache key if (!forceRefresh && File.Exists(_tokenCachePath)) @@ -118,7 +125,7 @@ public async Task GetAccessTokenAsync( // Authenticate interactively with specific tenant and scopes _logger.LogInformation("Authentication required for Agent 365 Tools"); - var token = await AuthenticateInteractivelyAsync(resourceUrl, tenantId, clientId, scopes, useInteractiveBrowser); + var token = await AuthenticateInteractivelyAsync(resourceUrl, tenantId, clientId, scopes, useInteractiveBrowser, loginHint: userId); // Cache the token with the appropriate cache key await CacheTokenAsync(cacheKey, token); @@ -135,11 +142,12 @@ public async Task GetAccessTokenAsync( /// Optional explicit scopes to request. If not provided, uses .default scope pattern /// If true, uses browser authentication with redirect URI; if false, uses device code flow. Default is false for backward compatibility. private async Task AuthenticateInteractivelyAsync( - string resourceUrl, - string? tenantId = null, + string resourceUrl, + string? tenantId = null, string? clientId = null, IEnumerable? explicitScopes = null, - bool useInteractiveBrowser = false) + bool useInteractiveBrowser = false, + string? loginHint = null) { // Declare variables outside try block so they're available in catch for logging string effectiveTenantId = tenantId ?? "unknown"; @@ -220,7 +228,7 @@ private async Task AuthenticateInteractivelyAsync( _logger.LogInformation("Please sign in with your Microsoft account and grant consent for the requested permissions."); _logger.LogInformation(""); - credential = CreateBrowserCredential(effectiveClientId, effectiveTenantId); + credential = CreateBrowserCredential(effectiveClientId, effectiveTenantId, loginHint: loginHint); } else { @@ -344,6 +352,8 @@ private bool IsTokenExpired(TokenInfo token) /// Optional tenant ID for single-tenant authentication /// Force token refresh even if cached token is valid /// Optional client ID for authentication. If not provided, uses PowerShell client ID + /// Optional UPN/email to pre-select the account for WAM and silent acquisition. + /// When provided, WAM will target this identity instead of the first cached account. /// Access token with the requested scopes public async Task GetAccessTokenWithScopesAsync( string resourceAppId, @@ -351,19 +361,20 @@ public async Task GetAccessTokenWithScopesAsync( string? tenantId = null, bool forceRefresh = false, string? clientId = null, - bool useInteractiveBrowser = true) + bool useInteractiveBrowser = true, + string? userId = null) { if (string.IsNullOrWhiteSpace(resourceAppId)) throw new ArgumentException("Resource App ID cannot be empty", nameof(resourceAppId)); - + if (scopes == null || !scopes.Any()) throw new ArgumentException("At least one scope must be specified", nameof(scopes)); - _logger.LogInformation("Requesting token for resource {ResourceAppId} with explicit scopes: {Scopes}", + _logger.LogInformation("Requesting token for resource {ResourceAppId} with explicit scopes: {Scopes}", resourceAppId, string.Join(", ", scopes)); // Delegate to the consolidated GetAccessTokenAsync method - return await GetAccessTokenAsync(resourceAppId, tenantId, forceRefresh, clientId, scopes, useInteractiveBrowser); + return await GetAccessTokenAsync(resourceAppId, tenantId, forceRefresh, clientId, scopes, useInteractiveBrowser, userId); } /// @@ -384,7 +395,8 @@ public async Task GetAccessTokenForMcpAsync(string resourceUrl, string? // Use the existing method for backward compatibility // For explicit scope control, callers should use GetAccessTokenWithScopesAsync - return await GetAccessTokenAsync(resourceUrl, tenantId, forceRefresh); + var loginHint = await AzCliHelper.ResolveLoginHintAsync(); + return await GetAccessTokenAsync(resourceUrl, tenantId, forceRefresh, userId: loginHint); } /// @@ -513,8 +525,8 @@ public bool ValidateScopesForResource(string resourceUrl, string? manifestPath = /// Creates a browser credential for interactive authentication. /// Protected virtual to allow substitution in tests. /// - protected virtual TokenCredential CreateBrowserCredential(string clientId, string tenantId) - => new MsalBrowserCredential(clientId, tenantId, redirectUri: null, _logger); + protected virtual TokenCredential CreateBrowserCredential(string clientId, string tenantId, string? loginHint = null) + => new MsalBrowserCredential(clientId, tenantId, redirectUri: null, _logger, loginHint: loginHint); /// /// Creates a DeviceCodeCredential configured for interactive device code authentication. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureAuthValidator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureAuthValidator.cs index ae714363..8a04e200 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureAuthValidator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/AzureAuthValidator.cs @@ -26,12 +26,12 @@ public AzureAuthValidator(ILogger logger, CommandExecutor ex /// /// The expected subscription ID to validate against. If null, only checks authentication. /// True if authenticated and subscription matches (if specified), false otherwise. - public virtual async Task ValidateAuthenticationAsync(string? expectedSubscriptionId = null) + public virtual async Task ValidateAuthenticationAsync(string? expectedSubscriptionId = null, CancellationToken ct = default) { try { // Check Azure CLI authentication by trying to get current account - var result = await _executor.ExecuteAsync("az", "account show --output json", captureOutput: true, suppressErrorLogging: true); + var result = await _executor.ExecuteAsync("az", "account show --output json", captureOutput: true, suppressErrorLogging: true, cancellationToken: ct); if (!result.Success) { @@ -71,7 +71,7 @@ public virtual async Task ValidateAuthenticationAsync(string? expectedSubs _logger.LogError("Failed to parse Azure account information: {Message}", ex.Message); return false; } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "Failed to validate Azure CLI authentication"); return false; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/BotConfigurator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/BotConfigurator.cs index d59f6829..d389c333 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/BotConfigurator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/BotConfigurator.cs @@ -78,6 +78,9 @@ public async Task CreateEndpointWithAgentBlueprintAs var cleanedOutput = JsonDeserializationHelper.CleanAzureCliJsonOutput(subscriptionResult.StandardOutput); var subscriptionInfo = JsonSerializer.Deserialize(cleanedOutput); var tenantId = subscriptionInfo.GetProperty("tenantId").GetString(); + var currentUser = subscriptionInfo.TryGetProperty("user", out var userProp) && + userProp.TryGetProperty("name", out var nameProp) + ? nameProp.GetString() : null; if (string.IsNullOrEmpty(tenantId)) { @@ -94,22 +97,10 @@ public async Task CreateEndpointWithAgentBlueprintAs var createEndpointUrl = EndpointHelper.GetCreateEndpointUrl(config.Environment); _logger.LogInformation("Calling create endpoint directly..."); - - // Get authentication token interactively (unless skip-auth is specified) - string? authToken = null; - _logger.LogInformation("Getting authentication token..."); + _logger.LogDebug("Create endpoint URL: {Url}", createEndpointUrl); // Determine the audience (App ID) based on the environment var audience = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); - authToken = await _authService.GetAccessTokenAsync(audience, tenantId); - - if (string.IsNullOrWhiteSpace(authToken)) - { - _logger.LogError("Failed to acquire authentication token"); - return EndpointRegistrationResult.Failed; - } - _logger.LogInformation("Successfully acquired access token"); - var normalizedLocation = NormalizeLocation(location); var createEndpointBody = new JsonObject { @@ -122,30 +113,50 @@ public async Task CreateEndpointWithAgentBlueprintAs ["Environment"] = EndpointHelper.GetDeploymentEnvironment(config.Environment), ["ClusterCategory"] = EndpointHelper.GetClusterCategory(config.Environment) }; - // Use helper to create authenticated HTTP client - using var httpClient = Services.Internal.HttpClientFactory.CreateAuthenticatedClient(authToken, correlationId: correlationId); - // Call the endpoint - _logger.LogInformation("Making request to create endpoint (Location: {Location}).", normalizedLocation); + // Attempt the request up to twice: first with a cached token, then with a + // force-refreshed token if the backend rejects with "Invalid roles". + // The "Invalid roles" 400 means the token's wids claim does not yet include + // the Agent ID role — this happens when a role was assigned after the token + // was cached. A forced refresh picks up the new role assignment. + for (int attempt = 0; attempt < 2; attempt++) + { + bool forceRefresh = attempt > 0; - var response = await httpClient.PostAsync(createEndpointUrl, - new StringContent(createEndpointBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json")); + _logger.LogInformation("Getting authentication token..."); + var authToken = await _authService.GetAccessTokenAsync(audience, tenantId, forceRefresh: forceRefresh, userId: currentUser); + + if (string.IsNullOrWhiteSpace(authToken)) + { + _logger.LogError("Failed to acquire authentication token"); + return EndpointRegistrationResult.Failed; + } + _logger.LogInformation("Successfully acquired access token"); + + using var httpClient = Services.Internal.HttpClientFactory.CreateAuthenticatedClient(authToken, correlationId: correlationId); + + _logger.LogInformation("Making request to create endpoint (Location: {Location}).", normalizedLocation); + + using var response = await httpClient.PostAsync(createEndpointUrl, + new StringContent(createEndpointBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json")); + + if (response.IsSuccessStatusCode) + { + _logger.LogInformation("Successfully received response from create endpoint"); + return EndpointRegistrationResult.Created; + } - if (!response.IsSuccessStatusCode) - { var errorContent = await response.Content.ReadAsStringAsync(); - - // Check for "already exists" condition - must be bot/endpoint-specific to avoid false positives - // Valid patterns: + + // Check for "already exists" condition — must be bot/endpoint-specific to avoid false positives. // 1. HTTP 409 Conflict (standard REST pattern for resource conflicts) // 2. HTTP 500 with bot-specific "already exists" message (Azure Bot Service pattern) - // - Must contain "already exists" AND at least one bot-specific keyword bool isBotAlreadyExists = response.StatusCode == System.Net.HttpStatusCode.Conflict || (errorContent.Contains(AlreadyExistsErrorMessage, StringComparison.OrdinalIgnoreCase) && (errorContent.Contains("bot", StringComparison.OrdinalIgnoreCase) || errorContent.Contains("endpoint", StringComparison.OrdinalIgnoreCase) || errorContent.Contains(endpointName, StringComparison.OrdinalIgnoreCase))); - + if (isBotAlreadyExists) { _logger.LogWarning("Endpoint '{EndpointName}' {AlreadyExistsMessage} in the resource group", endpointName, AlreadyExistsErrorMessage); @@ -156,10 +167,34 @@ public async Task CreateEndpointWithAgentBlueprintAs _logger.LogInformation(" 2. Register new endpoint: a365 setup blueprint --endpoint-only"); return EndpointRegistrationResult.AlreadyExists; } - - // Log error only for actual failures (not idempotent "already exists" scenarios) + _logger.LogError("Failed to call create endpoint. Status: {Status}", response.StatusCode); - + + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized && attempt == 0) + { + _logger.LogWarning( + "ATG returned 401 Unauthorized — cached token may be stale or belong to a different user. " + + "Retrying with a fresh token..."); + continue; + } + + if (TryGetErrorCode(errorContent) == "Invalid roles") + { + if (attempt == 0) + { + _logger.LogWarning( + "Access token does not include the required Agent ID role — " + + "this can happen when a role was assigned after the token was cached. " + + "Retrying with a fresh token..."); + continue; + } + + var apiMessage = TryGetErrorMessage(errorContent); + if (!string.IsNullOrWhiteSpace(apiMessage)) + _logger.LogError("{Message}", apiMessage); + return EndpointRegistrationResult.Failed; + } + if (errorContent.Contains("Failed to provision bot resource via Azure Management API. Status: BadRequest", StringComparison.OrdinalIgnoreCase)) { _logger.LogError("Please ensure that the Agent 365 CLI is supported in the selected region ('{Location}') and that your web app name ('{EndpointName}') is globally unique.", location, endpointName); @@ -175,8 +210,8 @@ public async Task CreateEndpointWithAgentBlueprintAs return EndpointRegistrationResult.Failed; } - _logger.LogInformation("Successfully received response from create endpoint"); - return EndpointRegistrationResult.Created; + // Unreachable — the loop always returns. Satisfies the compiler. + return EndpointRegistrationResult.Failed; } catch (Exception ex) { @@ -236,6 +271,10 @@ public async Task DeleteEndpointWithAgentBlueprintAsync( var cleanedOutput = JsonDeserializationHelper.CleanAzureCliJsonOutput(subscriptionResult.StandardOutput); var subscriptionInfo = JsonSerializer.Deserialize(cleanedOutput); var tenantId = subscriptionInfo.GetProperty("tenantId").GetString(); + var currentUser = subscriptionInfo.TryGetProperty("user", out var userProp) && + userProp.TryGetProperty("name", out var nameProp) + ? nameProp.GetString() : null; + _logger.LogDebug("ATG token request — current user from az account: {CurrentUser}", currentUser ?? "(null)"); if (string.IsNullOrEmpty(tenantId)) { @@ -255,24 +294,11 @@ public async Task DeleteEndpointWithAgentBlueprintAsync( _logger.LogInformation("Environment: {Env}", config.Environment); _logger.LogInformation("Endpoint URL: {Url}", deleteEndpointUrl); - // Get authentication token interactively (unless skip-auth is specified) - string? authToken = null; - _logger.LogInformation("Getting authentication token..."); - // Determine the audience (App ID) based on the environment var audience = ConfigConstants.GetAgent365ToolsResourceAppId(config.Environment); _logger.LogInformation("Environment: {Environment}, Audience: {Audience}", config.Environment, audience); - authToken = await _authService.GetAccessTokenAsync(audience, tenantId); - - if (string.IsNullOrWhiteSpace(authToken)) - { - _logger.LogError("Failed to acquire authentication token"); - return false; - } - _logger.LogInformation("Successfully acquired access token"); - var normalizedLocation = NormalizeLocation(location); var deleteEndpointBody = new JsonObject { @@ -283,11 +309,7 @@ public async Task DeleteEndpointWithAgentBlueprintAsync( ["Environment"] = EndpointHelper.GetDeploymentEnvironment(config.Environment), ["ClusterCategory"] = EndpointHelper.GetClusterCategory(config.Environment) }; - // Use helper to create authenticated HTTP client - using var httpClient = Services.Internal.HttpClientFactory.CreateAuthenticatedClient(authToken, correlationId: correlationId); - // Call the endpoint - _logger.LogInformation("Making request to delete endpoint (Location: {Location}).", normalizedLocation); _logger.LogInformation("Delete request payload:"); _logger.LogInformation(" AzureBotServiceInstanceName: {Name}", endpointName); _logger.LogInformation(" AppId: {AppId}", agentBlueprintId); @@ -295,17 +317,41 @@ public async Task DeleteEndpointWithAgentBlueprintAsync( _logger.LogInformation(" Location: {Location}", normalizedLocation); _logger.LogInformation(" Environment: {Environment}", EndpointHelper.GetDeploymentEnvironment(config.Environment)); - using var request = new HttpRequestMessage(HttpMethod.Delete, deleteEndpointUrl); - request.Content = new StringContent(deleteEndpointBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"); - var response = await httpClient.SendAsync(request); + // Attempt the request up to twice: first with a cached token, then with a + // force-refreshed token if ATG rejects with 401 Unauthorized (stale/wrong-user token). + for (int attempt = 0; attempt < 2; attempt++) + { + bool forceRefresh = attempt > 0; + _logger.LogInformation("Getting authentication token..."); + var authToken = await _authService.GetAccessTokenAsync(audience, tenantId, forceRefresh: forceRefresh, userId: currentUser); + + if (string.IsNullOrWhiteSpace(authToken)) + { + _logger.LogError("Failed to acquire authentication token"); + return false; + } + _logger.LogInformation("Successfully acquired access token"); + + using var httpClient = Services.Internal.HttpClientFactory.CreateAuthenticatedClient(authToken, correlationId: correlationId); + + _logger.LogInformation("Making request to delete endpoint (Location: {Location}).", normalizedLocation); + + using var request = new HttpRequestMessage(HttpMethod.Delete, deleteEndpointUrl); + request.Content = new StringContent(deleteEndpointBody.ToJsonString(), System.Text.Encoding.UTF8, "application/json"); + using var response = await httpClient.SendAsync(request); + + if (response.IsSuccessStatusCode) + { + _logger.LogInformation("Successfully received response from delete endpoint"); + return true; + } - if (!response.IsSuccessStatusCode) - { // Read error content ONCE for all error handling var errorContent = await response.Content.ReadAsStringAsync(); + // Check if resource was not found - this is success for deletion (idempotent) - if (response.StatusCode == System.Net.HttpStatusCode.NotFound || + if (response.StatusCode == System.Net.HttpStatusCode.NotFound || response.StatusCode == System.Net.HttpStatusCode.BadRequest) { // For BadRequest, verify it's actually "not found" scenario @@ -335,32 +381,48 @@ public async Task DeleteEndpointWithAgentBlueprintAsync( return true; // Not found is success for deletion } } + + // Retry on 401 Unauthorized — cached token may be stale or belong to a different user. + if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized && attempt == 0) + { + _logger.LogWarning( + "ATG returned 401 Unauthorized — cached token may be stale or belong to a different user. " + + "Retrying with a fresh token..."); + continue; + } + + // Retry on "Invalid roles" 400 — the token's wids claim does not yet include + // the required Agent ID role. This happens when the role was assigned after the + // token was cached. A forced refresh picks up the updated role assignment. + if (response.StatusCode == System.Net.HttpStatusCode.BadRequest && + TryGetErrorCode(errorContent) == "Invalid roles" && attempt == 0) + { + _logger.LogWarning( + "Access token does not include the required Agent ID role — " + + "this can happen when a role was assigned after the token was cached. " + + "Retrying with a fresh token..."); + continue; + } + // Real error - log and return false + _logger.LogError("Failed to delete bot endpoint. Status: {Status}", response.StatusCode); try { var errorJson = JsonSerializer.Deserialize(errorContent); if (errorJson.TryGetProperty("error", out var errorMessage)) - { - var error = errorMessage.GetString(); - _logger.LogError("Failed to delete bot endpoint. Status: {Status}", response.StatusCode); - _logger.LogError("{Error}", error); - } + _logger.LogError("{Error}", errorMessage.GetString()); else - { - _logger.LogError("Failed to delete bot endpoint. Status: {Status}", response.StatusCode); _logger.LogError("Error response: {Error}", errorContent); - } } catch { - _logger.LogError("Failed to delete bot endpoint. Status: {Status}", response.StatusCode); _logger.LogError("Error response: {Error}", errorContent); } return false; } - _logger.LogInformation("Successfully received response from delete endpoint"); - return true; + // Unreachable — the loop always returns. Satisfies the compiler. + return false; } catch (AzureAuthenticationException ex) { @@ -385,6 +447,42 @@ public async Task DeleteEndpointWithAgentBlueprintAsync( } } + /// + /// Parses a JSON error response and returns the value of the top-level "error" field, + /// which is a stable machine-readable code. Returns null if parsing fails or field is absent. + /// + private static string? TryGetErrorCode(string? content) + { + if (string.IsNullOrWhiteSpace(content)) return null; + try + { + using var doc = JsonDocument.Parse(content); + if (doc.RootElement.TryGetProperty("error", out var errorElement) && + errorElement.ValueKind == JsonValueKind.String) + { + return errorElement.GetString(); + } + } + catch { /* ignore parse errors */ } + return null; + } + + private static string? TryGetErrorMessage(string? content) + { + if (string.IsNullOrWhiteSpace(content)) return null; + try + { + using var doc = JsonDocument.Parse(content); + if (doc.RootElement.TryGetProperty("message", out var messageElement) && + messageElement.ValueKind == JsonValueKind.String) + { + return messageElement.GetString(); + } + } + catch { /* ignore parse errors */ } + return null; + } + private string NormalizeLocation(string location) { // Normalize location: Remove spaces and convert to lowercase (e.g., "Canada Central" -> "canadacentral") diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs index f2ae0cc5..eb155eb4 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ClientAppValidator.cs @@ -3,7 +3,6 @@ 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.Services.Helpers; using Microsoft.Extensions.Logging; using System.Text.Json; @@ -13,19 +12,18 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services; /// /// Validates that a client app exists and has the required permissions for a365 CLI operations. +/// Uses GraphApiService for direct HTTP calls to Microsoft Graph, eliminating az-subprocess overhead +/// (~20-30s per call) from the requirements check phase. /// public sealed class ClientAppValidator : IClientAppValidator { private readonly ILogger _logger; - private readonly CommandExecutor _executor; + private readonly GraphApiService _graphApiService; - private const string GraphApiBaseUrl = "https://graph.microsoft.com/v1.0"; - private const string GraphTokenResource = "https://graph.microsoft.com"; - - public ClientAppValidator(ILogger logger, CommandExecutor executor) + public ClientAppValidator(ILogger logger, GraphApiService graphApiService) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + _graphApiService = graphApiService ?? throw new ArgumentNullException(nameof(graphApiService)); } /// @@ -64,18 +62,8 @@ public async Task EnsureValidClientAppAsync( try { - // Step 2: Acquire Graph token - var graphToken = await AcquireGraphTokenAsync(ct); - if (string.IsNullOrWhiteSpace(graphToken)) - { - throw ClientAppValidationException.ValidationFailed( - "Failed to acquire Microsoft Graph access token", - new List { "Ensure you are logged in with 'az login'" }, - clientAppId); - } - - // Step 3: Verify app exists - var appInfo = await GetClientAppInfoAsync(clientAppId, graphToken, ct); + // Step 2: Verify app exists (token acquisition is handled inside GraphApiService) + var appInfo = await GetClientAppInfoAsync(clientAppId, tenantId, ct); if (appInfo == null) { throw ClientAppValidationException.AppNotFound(clientAppId, tenantId); @@ -83,38 +71,64 @@ public async Task EnsureValidClientAppAsync( _logger.LogDebug("Found client app: {DisplayName} ({AppId})", appInfo.DisplayName, clientAppId); - // 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 + // Step 3: Validate permissions in manifest + var missingPermissions = await ValidatePermissionsConfiguredAsync(appInfo, tenantId, ct); + + // Step 3.5: For any unresolvable permissions (beta APIs), check oauth2PermissionGrants as fallback if (missingPermissions.Count > 0) { - var consentedPermissions = await GetConsentedPermissionsAsync(clientAppId, graphToken, ct); + var consentedPermissions = await GetConsentedPermissionsAsync(clientAppId, tenantId, 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.LogDebug("Found {Count} consented permissions via oauth2PermissionGrants (including beta APIs)", consentedPermissions.Count); } } - + + // Step 3.6: Auto-provision any remaining missing permissions (self-healing) + if (missingPermissions.Count > 0) + { + _logger.LogInformation("Auto-provisioning {Count} missing permission(s): {Permissions}", + missingPermissions.Count, string.Join(", ", missingPermissions)); + + var provisioned = await EnsurePermissionsConfiguredAsync(appInfo, missingPermissions, clientAppId, tenantId, ct); + + if (provisioned) + { + // Re-fetch fresh app info and re-validate to confirm provisioning succeeded + var freshAppInfo = await GetClientAppInfoAsync(clientAppId, tenantId, ct); + if (freshAppInfo != null) + { + missingPermissions = await ValidatePermissionsConfiguredAsync(freshAppInfo, tenantId, ct); + + // Re-run the consent fallback check on the remaining missing list + if (missingPermissions.Count > 0) + { + var consentedAfterProvision = await GetConsentedPermissionsAsync(clientAppId, tenantId, ct); + missingPermissions.RemoveAll(p => consentedAfterProvision.Contains(p, StringComparer.OrdinalIgnoreCase)); + } + } + } + } + if (missingPermissions.Count > 0) { throw ClientAppValidationException.MissingPermissions(clientAppId, missingPermissions); } - // Step 5: Verify admin consent - if (!await ValidateAdminConsentAsync(clientAppId, graphToken, ct)) + // Step 4: Verify admin consent + if (!await ValidateAdminConsentAsync(clientAppId, tenantId, ct)) { throw ClientAppValidationException.MissingAdminConsent(clientAppId); } - // Step 6: Verify and fix redirect URIs - await EnsureRedirectUrisAsync(clientAppId, graphToken, ct); + // Step 5: Verify and fix redirect URIs + await EnsureRedirectUrisAsync(clientAppId, tenantId, ct); - // Step 7: Verify and fix public client flows (required for device code fallback on non-Windows) - await EnsurePublicClientFlowsEnabledAsync(clientAppId, graphToken, ct); + // Step 6: Verify and fix public client flows (required for device code fallback on non-Windows) + await EnsurePublicClientFlowsEnabledAsync(clientAppId, tenantId, ct); _logger.LogDebug("Client app validation successful for {ClientAppId}", clientAppId); } @@ -146,34 +160,30 @@ public async Task EnsureValidClientAppAsync( /// Automatically adds missing redirect URIs if needed (self-healing). /// /// The client app ID - /// Microsoft Graph access token + /// The tenant ID /// Cancellation token public async Task EnsureRedirectUrisAsync( string clientAppId, - string graphToken, + string tenantId, CancellationToken ct = default) { ArgumentException.ThrowIfNullOrWhiteSpace(clientAppId); - ArgumentException.ThrowIfNullOrWhiteSpace(graphToken); + ArgumentException.ThrowIfNullOrWhiteSpace(tenantId); try { _logger.LogDebug("Checking redirect URIs for client app {ClientAppId}", clientAppId); - // Get current redirect URIs - var appCheckResult = await _executor.ExecuteAsync( - "az", - $"rest --method GET --url \"{GraphApiBaseUrl}/applications?$filter=appId eq '{CommandStringHelper.EscapePowerShellString(clientAppId)}'&$select=id,publicClient\" --headers \"Authorization=Bearer {CommandStringHelper.EscapePowerShellString(graphToken)}\"", - cancellationToken: ct); + using var appDoc = await _graphApiService.GraphGetAsync(tenantId, + $"/v1.0/applications?$filter=appId eq '{clientAppId}'&$select=id,publicClient", ct); - if (!appCheckResult.Success) + if (appDoc == null) { - _logger.LogWarning("Could not verify redirect URIs: {Error}", appCheckResult.StandardError); + _logger.LogWarning("Could not verify redirect URIs: Graph request failed"); return; } - var sanitizedOutput = JsonDeserializationHelper.CleanAzureCliJsonOutput(appCheckResult.StandardOutput); - var response = JsonNode.Parse(sanitizedOutput); + var response = JsonNode.Parse(appDoc.RootElement.GetRawText()); var apps = response?["value"]?.AsArray(); if (apps == null || apps.Count == 0) @@ -184,13 +194,13 @@ public async Task EnsureRedirectUrisAsync( var app = apps[0]!.AsObject(); var objectId = app["id"]?.GetValue(); - + if (string.IsNullOrWhiteSpace(objectId)) { _logger.LogWarning("Could not get application object ID for redirect URI update"); return; } - + var publicClient = app["publicClient"]?.AsObject(); var currentRedirectUris = publicClient?["redirectUris"]?.AsArray() ?.Select(uri => uri?.GetValue()) @@ -215,19 +225,18 @@ public async Task EnsureRedirectUrisAsync( string.Join(", ", missingUris)); var allUris = currentRedirectUris.Union(missingUris).ToList(); - var urisJson = string.Join(",", allUris.Select(uri => $"\"{uri}\"")); + var urisArray = new JsonArray(); + foreach (var uri in allUris) + urisArray.Add(JsonValue.Create(uri)); - var patchBody = $"{{\"publicClient\":{{\"redirectUris\":[{urisJson}]}}}}"; - // Escape the JSON body for PowerShell: replace " with "" - var escapedBody = patchBody.Replace("\"", "\"\""); - var patchResult = await _executor.ExecuteAsync( - "az", - $"rest --method PATCH --url \"{GraphApiBaseUrl}/applications/{CommandStringHelper.EscapePowerShellString(objectId)}\" --headers \"Content-Type=application/json\" \"Authorization=Bearer {CommandStringHelper.EscapePowerShellString(graphToken)}\" --body \"{escapedBody}\"", - cancellationToken: ct); + var patchSuccess = await _graphApiService.GraphPatchAsync(tenantId, + $"/v1.0/applications/{objectId}", + new JsonObject { ["publicClient"] = new JsonObject { ["redirectUris"] = urisArray } }, + ct); - if (!patchResult.Success) + if (!patchSuccess) { - _logger.LogWarning("Failed to update redirect URIs: {Error}", patchResult.StandardError); + _logger.LogWarning("Failed to update redirect URIs"); return; } @@ -248,26 +257,23 @@ public async Task EnsureRedirectUrisAsync( /// private async Task EnsurePublicClientFlowsEnabledAsync( string clientAppId, - string graphToken, + string tenantId, CancellationToken ct = default) { try { _logger.LogDebug("Checking 'Allow public client flows' for client app {ClientAppId}", clientAppId); - var appCheckResult = await _executor.ExecuteAsync( - "az", - $"rest --method GET --url \"{GraphApiBaseUrl}/applications?$filter=appId eq '{CommandStringHelper.EscapePowerShellString(clientAppId)}'&$select=id,isFallbackPublicClient\" --headers \"Authorization=Bearer {CommandStringHelper.EscapePowerShellString(graphToken)}\"", - cancellationToken: ct); + using var appDoc = await _graphApiService.GraphGetAsync(tenantId, + $"/v1.0/applications?$filter=appId eq '{clientAppId}'&$select=id,isFallbackPublicClient", ct); - if (!appCheckResult.Success) + if (appDoc == null) { - _logger.LogWarning("Could not check 'Allow public client flows': {Error}", appCheckResult.StandardError); + _logger.LogWarning("Could not check 'Allow public client flows': Graph request failed"); return; } - var sanitizedOutput = JsonDeserializationHelper.CleanAzureCliJsonOutput(appCheckResult.StandardOutput); - var response = JsonNode.Parse(sanitizedOutput); + var response = JsonNode.Parse(appDoc.RootElement.GetRawText()); var apps = response?["value"]?.AsArray(); if (apps == null || apps.Count == 0) @@ -295,16 +301,14 @@ private async Task EnsurePublicClientFlowsEnabledAsync( _logger.LogInformation("Enabling 'Allow public client flows' on app registration (required for device code authentication fallback)."); _logger.LogInformation("Run 'a365 setup requirements' at any time to re-verify and auto-fix this setting."); - var patchBody = "{\"isFallbackPublicClient\":true}"; - var escapedBody = patchBody.Replace("\"", "\"\""); - var patchResult = await _executor.ExecuteAsync( - "az", - $"rest --method PATCH --url \"{GraphApiBaseUrl}/applications/{CommandStringHelper.EscapePowerShellString(objectId)}\" --headers \"Content-Type=application/json\" \"Authorization=Bearer {CommandStringHelper.EscapePowerShellString(graphToken)}\" --body \"{escapedBody}\"", - cancellationToken: ct); + var patchSuccess = await _graphApiService.GraphPatchAsync(tenantId, + $"/v1.0/applications/{objectId}", + new { isFallbackPublicClient = true }, + ct); - if (!patchResult.Success) + if (!patchSuccess) { - _logger.LogWarning("Failed to enable 'Allow public client flows': {Error}", patchResult.StandardError); + _logger.LogWarning("Failed to enable 'Allow public client flows'"); return; } @@ -316,97 +320,262 @@ private async Task EnsurePublicClientFlowsEnabledAsync( } } - #region Private Helper Methods - - private async Task AcquireGraphTokenAsync(CancellationToken ct) + /// + /// Auto-provisions missing permissions onto the client app registration (self-healing). + /// Patches requiredResourceAccess to add missing permission GUIDs, then tries to extend + /// the existing oauth2PermissionGrant scope so the consent is effective immediately. + /// Returns true if the requiredResourceAccess patch succeeded; false if it could not be applied. + /// + private async Task EnsurePermissionsConfiguredAsync( + ClientAppInfo appInfo, + List missingPermissions, + string clientAppId, + string tenantId, + CancellationToken ct) { - _logger.LogDebug("Acquiring Microsoft Graph token for validation..."); - - var tokenResult = await _executor.ExecuteAsync( - "az", - $"account get-access-token --resource {GraphTokenResource} --query accessToken -o tsv", - suppressErrorLogging: true, - cancellationToken: ct); - - if (!tokenResult.Success || string.IsNullOrWhiteSpace(tokenResult.StandardOutput)) + try { - _logger.LogDebug("Token acquisition failed: {Error}", tokenResult.StandardError); - return null; - } + // Resolve permission GUIDs for the missing permission names + var permissionNameToIdMap = await ResolvePermissionIdsAsync(tenantId, ct); - return tokenResult.StandardOutput.Trim(); - } + // Build an updated requiredResourceAccess array, inserting the missing GUIDs + // into (or alongside) the Microsoft Graph resource entry. + var updatedResourceAccess = new JsonArray(); + bool graphEntryFound = false; - private async Task GetClientAppInfoAsync(string clientAppId, string graphToken, CancellationToken ct) - { - _logger.LogDebug("Checking if client app exists in tenant..."); - - var appCheckResult = await _executor.ExecuteAsync( - "az", - $"rest --method GET --url \"{GraphApiBaseUrl}/applications?$filter=appId eq '{CommandStringHelper.EscapePowerShellString(clientAppId)}'&$select=id,appId,displayName,requiredResourceAccess\" --headers \"Authorization=Bearer {CommandStringHelper.EscapePowerShellString(graphToken)}\"", - suppressErrorLogging: true, - 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.LogDebug("Azure CLI token is stale due to Continuous Access Evaluation. Attempting token refresh..."); - - // Force token refresh - var refreshResult = await _executor.ExecuteAsync( - "az", - $"account get-access-token --resource {GraphTokenResource} --query accessToken -o tsv", - suppressErrorLogging: true, - cancellationToken: ct); - - if (refreshResult.Success && !string.IsNullOrWhiteSpace(refreshResult.StandardOutput)) + if (appInfo.RequiredResourceAccess != null) + { + foreach (var resourceNode in appInfo.RequiredResourceAccess) { - var freshToken = refreshResult.StandardOutput.Trim(); - _logger.LogDebug("Token refreshed successfully, retrying..."); - - // Retry with fresh token - var retryResult = await _executor.ExecuteAsync( - "az", - $"rest --method GET --url \"{GraphApiBaseUrl}/applications?$filter=appId eq '{CommandStringHelper.EscapePowerShellString(clientAppId)}'&$select=id,appId,displayName,requiredResourceAccess\" --headers \"Authorization=Bearer {CommandStringHelper.EscapePowerShellString(freshToken)}\"", - suppressErrorLogging: true, - cancellationToken: ct); - - if (retryResult.Success) + var resourceObj = resourceNode?.AsObject(); + if (resourceObj == null) continue; + + var resourceAppId = resourceObj["resourceAppId"]?.GetValue(); + if (string.Equals(resourceAppId, AuthenticationConstants.MicrosoftGraphResourceAppId, StringComparison.OrdinalIgnoreCase)) { - appCheckResult = retryResult; + graphEntryFound = true; + + // Collect existing permission IDs + var existingAccess = resourceObj["resourceAccess"]?.AsArray(); + var existingIds = existingAccess? + .Select(a => a?.AsObject()?["id"]?.GetValue()) + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Select(id => id!) + .ToHashSet(StringComparer.OrdinalIgnoreCase) + ?? new HashSet(StringComparer.OrdinalIgnoreCase); + + // Clone existing entries + var newAccess = new JsonArray(); + if (existingAccess != null) + { + foreach (var item in existingAccess) + newAccess.Add(item?.DeepClone()); + } + + // Append each missing permission that could be resolved + foreach (var permName in missingPermissions) + { + if (permissionNameToIdMap.TryGetValue(permName, out var permId) + && !existingIds.Contains(permId)) + { + newAccess.Add(new JsonObject + { + ["id"] = permId, + ["type"] = "Scope" + }); + _logger.LogDebug("Staging permission for manifest: {Permission} ({Id})", permName, permId); + } + } + + updatedResourceAccess.Add(new JsonObject + { + ["resourceAppId"] = AuthenticationConstants.MicrosoftGraphResourceAppId, + ["resourceAccess"] = newAccess + }); } else { - // Token refresh succeeded but the Graph call still rejected it — the revocation - // is server-side and cannot be silently recovered. Throw explicitly so the - // caller shows "token revoked" rather than "app not found". - _logger.LogDebug("App query failed after token refresh: {Error}", retryResult.StandardError); - throw ClientAppValidationException.TokenRevoked(clientAppId); + updatedResourceAccess.Add(resourceNode?.DeepClone()); } } } - - if (!appCheckResult.Success) + + if (!graphEntryFound) { - if (IsCaeError(appCheckResult.StandardError)) - throw ClientAppValidationException.TokenRevoked(clientAppId); + // No existing Microsoft Graph entry — create one from scratch + var newAccess = new JsonArray(); + foreach (var permName in missingPermissions) + { + if (permissionNameToIdMap.TryGetValue(permName, out var permId)) + { + newAccess.Add(new JsonObject + { + ["id"] = permId, + ["type"] = "Scope" + }); + } + } + updatedResourceAccess.Add(new JsonObject + { + ["resourceAppId"] = AuthenticationConstants.MicrosoftGraphResourceAppId, + ["resourceAccess"] = newAccess + }); + } - _logger.LogDebug("App query failed: {Error}", appCheckResult.StandardError); - return null; + var patchSuccess = await _graphApiService.GraphPatchAsync(tenantId, + $"/v1.0/applications/{appInfo.ObjectId}", + new JsonObject { ["requiredResourceAccess"] = updatedResourceAccess }, + ct); + + if (!patchSuccess) + { + _logger.LogWarning("Failed to update app registration with missing permissions"); + return false; } + + _logger.LogInformation("Added {Count} permission(s) to app registration: {Permissions}", + missingPermissions.Count, string.Join(", ", missingPermissions)); + + // Best-effort: also extend the existing oauth2PermissionGrant so consent takes effect immediately + await TryExtendConsentGrantScopesAsync(clientAppId, missingPermissions, tenantId, ct); + + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Error auto-provisioning permissions (non-fatal): {Message}", ex.Message); + return false; } + } + + /// + /// Best-effort: appends new scope names to the existing oauth2PermissionGrant so that the + /// delegated consent is effective without requiring a fresh admin consent flow. + /// Silently logs and returns on any failure. + /// + private async Task TryExtendConsentGrantScopesAsync( + string clientAppId, + List newScopes, + string tenantId, + CancellationToken ct) + { + try + { + // Look up the service principal for the client app + using var spDoc = await _graphApiService.GraphGetAsync(tenantId, + $"/v1.0/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id", ct); + + if (spDoc == null) return; + + var spJson = JsonNode.Parse(spDoc.RootElement.GetRawText()); + var spObjectId = spJson?["value"]?.AsArray().FirstOrDefault()?.AsObject()["id"]?.GetValue(); + if (string.IsNullOrWhiteSpace(spObjectId)) return; + + // Find the oauth2PermissionGrant that targets Microsoft Graph + using var grantsDoc = await _graphApiService.GraphGetAsync(tenantId, + $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", ct); + + if (grantsDoc == null) return; + + var grantsJson = JsonNode.Parse(grantsDoc.RootElement.GetRawText()); + var grants = grantsJson?["value"]?.AsArray(); + if (grants == null) return; + + // Look up the Microsoft Graph service principal ID to match against resourceId + string? graphSpObjectId = null; + using var graphSpDoc = await _graphApiService.GraphGetAsync(tenantId, + $"/v1.0/servicePrincipals?$filter=appId eq '{AuthenticationConstants.MicrosoftGraphResourceAppId}'&$select=id", ct); + + if (graphSpDoc != null) + { + var graphSpJson = JsonNode.Parse(graphSpDoc.RootElement.GetRawText()); + graphSpObjectId = graphSpJson?["value"]?.AsArray().FirstOrDefault()?.AsObject()["id"]?.GetValue(); + } + + foreach (var grantNode in grants) + { + var grant = grantNode?.AsObject(); + if (grant == null) continue; + + var grantId = grant["id"]?.GetValue(); + var resourceId = grant["resourceId"]?.GetValue(); + var existingScope = grant["scope"]?.GetValue() ?? string.Empty; + + // Match on the Microsoft Graph resource (by SP object ID if available, always fallback to scope content) + bool isGraphGrant = (!string.IsNullOrWhiteSpace(graphSpObjectId) && + string.Equals(resourceId, graphSpObjectId, StringComparison.OrdinalIgnoreCase)) + || AuthenticationConstants.RequiredClientAppPermissions + .Any(p => existingScope.Contains(p, StringComparison.OrdinalIgnoreCase)); + + if (!isGraphGrant || string.IsNullOrWhiteSpace(grantId)) continue; + + // Append any scopes not already in the grant + var existingScopes = existingScope.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + var scopesToAdd = newScopes.Where(s => !existingScopes.Contains(s)).ToList(); + if (scopesToAdd.Count == 0) continue; + + var updatedScope = string.Join(' ', existingScopes.Concat(scopesToAdd)); + + var patchSuccess = await _graphApiService.GraphPatchAsync(tenantId, + $"/v1.0/oauth2PermissionGrants/{grantId}", + new { scope = updatedScope }, + ct); - var sanitizedOutput = JsonDeserializationHelper.CleanAzureCliJsonOutput(appCheckResult.StandardOutput); - var appResponse = JsonNode.Parse(sanitizedOutput); - var apps = appResponse?["value"]?.AsArray(); + if (patchSuccess) + { + _logger.LogInformation("Extended consent grant with scope(s): {Scopes}", string.Join(", ", scopesToAdd)); + } + else + { + _logger.LogDebug("Could not extend consent grant (may require admin role)"); + } - if (apps == null || apps.Count == 0) + break; // Only one grant per resource + } + } + catch (Exception ex) { - return null; + _logger.LogDebug("TryExtendConsentGrantScopesAsync failed (non-fatal): {Message}", ex.Message); } + } + + #region Private Helper Methods + + private async Task GetClientAppInfoAsync(string clientAppId, string tenantId, CancellationToken ct) + { + _logger.LogDebug("Checking if client app exists in tenant..."); + + const string path = "/v1.0/applications?$filter=appId eq '{0}'&$select=id,appId,displayName,requiredResourceAccess"; + var graphResponse = await _graphApiService.GraphGetWithResponseAsync(tenantId, + string.Format(path, clientAppId), ct); + + if (graphResponse == null || !graphResponse.IsSuccess) + { + // Only retry on 401 — a stale token due to CAE revocation. Transient errors (503, + // network failure) surface the real error to the caller rather than masking it as + // "token revoked". StatusCode 0 means token acquisition itself failed. + if (graphResponse?.StatusCode != 401) + { + _logger.LogDebug("Graph app query failed with {StatusCode} — not retrying", graphResponse?.StatusCode); + return null; + } + + _logger.LogDebug("Graph app query returned 401 — invalidating token cache and retrying (possible CAE revocation)"); + AzCliHelper.InvalidateAzCliTokenCache(); + graphResponse = await _graphApiService.GraphGetWithResponseAsync(tenantId, + string.Format(path, clientAppId), ct); + + if (!graphResponse.IsSuccess) + throw ClientAppValidationException.TokenRevoked(clientAppId); + } + + using var doc = graphResponse.Json; + if (doc == null) return null; + + var response = JsonNode.Parse(doc.RootElement.GetRawText()); + var apps = response?["value"]?.AsArray(); + if (apps == null || apps.Count == 0) return null; var app = apps[0]!.AsObject(); return new ClientAppInfo( @@ -417,7 +586,7 @@ private async Task EnsurePublicClientFlowsEnabledAsync( private async Task> ValidatePermissionsConfiguredAsync( ClientAppInfo appInfo, - string graphToken, + string tenantId, CancellationToken ct) { var missingPermissions = new List(); @@ -457,7 +626,7 @@ private async Task> ValidatePermissionsConfiguredAsync( // Resolve ALL permission IDs dynamically from Microsoft Graph // This ensures compatibility across different tenants and API versions - var permissionNameToIdMap = await ResolvePermissionIdsAsync(graphToken, ct); + var permissionNameToIdMap = await ResolvePermissionIdsAsync(tenantId, ct); // Check each required permission foreach (var permissionName in AuthenticationConstants.RequiredClientAppPermissions) @@ -485,26 +654,24 @@ private async Task> ValidatePermissionsConfiguredAsync( /// 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) + private async Task> ResolvePermissionIdsAsync(string tenantId, CancellationToken ct) { var permissionNameToIdMap = new Dictionary(); try { - var graphSpResult = await _executor.ExecuteAsync( - "az", - $"rest --method GET --url \"{GraphApiBaseUrl}/servicePrincipals?$filter=appId eq '{CommandStringHelper.EscapePowerShellString(AuthenticationConstants.MicrosoftGraphResourceAppId)}'&$select=id,oauth2PermissionScopes\" --headers \"Authorization=Bearer {CommandStringHelper.EscapePowerShellString(graphToken)}\"", - cancellationToken: ct); + using var doc = await _graphApiService.GraphGetAsync(tenantId, + $"/v1.0/servicePrincipals?$filter=appId eq '{AuthenticationConstants.MicrosoftGraphResourceAppId}'&$select=id,oauth2PermissionScopes", + ct); - if (!graphSpResult.Success) + if (doc == null) { _logger.LogWarning("Failed to query Microsoft Graph for permission definitions"); return permissionNameToIdMap; } - var sanitizedOutput = JsonDeserializationHelper.CleanAzureCliJsonOutput(graphSpResult.StandardOutput); - var graphSpResponse = JsonNode.Parse(sanitizedOutput); - var graphSps = graphSpResponse?["value"]?.AsArray(); + var response = JsonNode.Parse(doc.RootElement.GetRawText()); + var graphSps = response?["value"]?.AsArray(); if (graphSps == null || graphSps.Count == 0) { @@ -546,27 +713,24 @@ private async Task> ResolvePermissionIdsAsync(string /// 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) + private async Task> GetConsentedPermissionsAsync(string clientAppId, string tenantId, 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 '{CommandStringHelper.EscapePowerShellString(clientAppId)}'&$select=id\" --headers \"Authorization=Bearer {CommandStringHelper.EscapePowerShellString(graphToken)}\"", - cancellationToken: ct); + using var spDoc = await _graphApiService.GraphGetAsync(tenantId, + $"/v1.0/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id", ct); - if (!spCheckResult.Success) + if (spDoc == null) { _logger.LogDebug("Could not query service principal for consent check"); return consentedPermissions; } - var sanitizedOutput = JsonDeserializationHelper.CleanAzureCliJsonOutput(spCheckResult.StandardOutput); - var spResponse = JsonNode.Parse(sanitizedOutput); - var servicePrincipals = spResponse?["value"]?.AsArray(); + var spJson = JsonNode.Parse(spDoc.RootElement.GetRawText()); + var servicePrincipals = spJson?["value"]?.AsArray(); if (servicePrincipals == null || servicePrincipals.Count == 0) { @@ -583,20 +747,17 @@ private async Task> GetConsentedPermissionsAsync(string clientAp } // Get oauth2PermissionGrants - var grantsResult = await _executor.ExecuteAsync( - "az", - $"rest --method GET --url \"{GraphApiBaseUrl}/oauth2PermissionGrants?$filter=clientId eq '{CommandStringHelper.EscapePowerShellString(spObjectId)}'\" --headers \"Authorization=Bearer {CommandStringHelper.EscapePowerShellString(graphToken)}\"", - cancellationToken: ct); + using var grantsDoc = await _graphApiService.GraphGetAsync(tenantId, + $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", ct); - if (!grantsResult.Success) + if (grantsDoc == null) { _logger.LogDebug("Could not query oauth2PermissionGrants"); return consentedPermissions; } - var sanitizedGrantsOutput = JsonDeserializationHelper.CleanAzureCliJsonOutput(grantsResult.StandardOutput); - var grantsResponse = JsonNode.Parse(sanitizedGrantsOutput); - var grants = grantsResponse?["value"]?.AsArray(); + var grantsJson = JsonNode.Parse(grantsDoc.RootElement.GetRawText()); + var grants = grantsJson?["value"]?.AsArray(); if (grants == null || grants.Count == 0) { @@ -608,7 +769,7 @@ private async Task> GetConsentedPermissionsAsync(string clientAp { var grantObj = grant?.AsObject(); var scope = grantObj?["scope"]?.GetValue(); - + if (!string.IsNullOrWhiteSpace(scope)) { var scopes = scope.Split(' ', StringSplitOptions.RemoveEmptyEntries); @@ -629,25 +790,22 @@ private async Task> GetConsentedPermissionsAsync(string clientAp return consentedPermissions; } - private async Task ValidateAdminConsentAsync(string clientAppId, string graphToken, CancellationToken ct) + private async Task ValidateAdminConsentAsync(string clientAppId, string tenantId, CancellationToken ct) { _logger.LogDebug("Checking admin consent status for {ClientAppId}", clientAppId); // Get service principal for the app - var spCheckResult = await _executor.ExecuteAsync( - "az", - $"rest --method GET --url \"{GraphApiBaseUrl}/servicePrincipals?$filter=appId eq '{CommandStringHelper.EscapePowerShellString(clientAppId)}'&$select=id,appId\" --headers \"Authorization=Bearer {CommandStringHelper.EscapePowerShellString(graphToken)}\"", - cancellationToken: ct); + using var spDoc = await _graphApiService.GraphGetAsync(tenantId, + $"/v1.0/servicePrincipals?$filter=appId eq '{clientAppId}'&$select=id,appId", ct); - if (!spCheckResult.Success) + if (spDoc == null) { - _logger.LogDebug("Could not verify service principal (may not exist yet): {Error}", spCheckResult.StandardError); + _logger.LogDebug("Could not verify service principal (may not exist yet)"); return true; // Best-effort check - will be verified during first interactive authentication } - var sanitizedOutput = JsonDeserializationHelper.CleanAzureCliJsonOutput(spCheckResult.StandardOutput); - var spResponse = JsonNode.Parse(sanitizedOutput); - var servicePrincipals = spResponse?["value"]?.AsArray(); + var spJson = JsonNode.Parse(spDoc.RootElement.GetRawText()); + var servicePrincipals = spJson?["value"]?.AsArray(); if (servicePrincipals == null || servicePrincipals.Count == 0) { @@ -665,20 +823,17 @@ private async Task ValidateAdminConsentAsync(string clientAppId, string gr } // Check OAuth2 permission grants - var grantsCheckResult = await _executor.ExecuteAsync( - "az", - $"rest --method GET --url \"{GraphApiBaseUrl}/oauth2PermissionGrants?$filter=clientId eq '{CommandStringHelper.EscapePowerShellString(spObjectId)}'\" --headers \"Authorization=Bearer {CommandStringHelper.EscapePowerShellString(graphToken)}\"", - cancellationToken: ct); + using var grantsDoc = await _graphApiService.GraphGetAsync(tenantId, + $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{spObjectId}'", ct); - if (!grantsCheckResult.Success) + if (grantsDoc == null) { - _logger.LogDebug("Could not verify admin consent status: {Error}", grantsCheckResult.StandardError); + _logger.LogDebug("Could not verify admin consent status"); return true; // Best-effort check } - var sanitizedGrantsOutput = JsonDeserializationHelper.CleanAzureCliJsonOutput(grantsCheckResult.StandardOutput); - var grantsResponse = JsonNode.Parse(sanitizedGrantsOutput); - var grants = grantsResponse?["value"]?.AsArray(); + var grantsJson = JsonNode.Parse(grantsDoc.RootElement.GetRawText()); + var grants = grantsJson?["value"]?.AsArray(); if (grants == null || grants.Count == 0) { @@ -712,11 +867,6 @@ private async Task ValidateAdminConsentAsync(string clientAppId, string gr #region Helper Types - private static bool IsCaeError(string errorOutput) => - errorOutput.Contains("TokenIssuedBeforeRevocationTimestamp", StringComparison.OrdinalIgnoreCase) || - errorOutput.Contains("TokenCreatedWithOutdatedPolicies", StringComparison.OrdinalIgnoreCase) || - errorOutput.Contains("InvalidAuthenticationToken", StringComparison.OrdinalIgnoreCase); - private record ClientAppInfo(string ObjectId, string DisplayName, JsonArray? RequiredResourceAccess); #endregion diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/CommandExecutor.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/CommandExecutor.cs index d9fa6a4d..4142964a 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/CommandExecutor.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/CommandExecutor.cs @@ -86,7 +86,18 @@ public virtual async Task ExecuteAsync( process.BeginErrorReadLine(); } - await process.WaitForExitAsync(cancellationToken); + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + // User pressed Ctrl+C — kill the child immediately so stdin is + // released and the console is not stuck on a zombie subprocess. + try { if (!process.HasExited) process.Kill(entireProcessTree: true); } + catch (Exception killEx) { _logger.LogDebug(killEx, "Failed to kill process after cancellation"); } + throw; + } var result = new CommandResult { @@ -177,13 +188,14 @@ public virtual async Task ExecuteWithStreamingAsync( if (args.Data != null) { errorBuilder.AppendLine(args.Data); - // Azure CLI writes informational messages to stderr with "WARNING:" prefix - // Strip it for cleaner output + // Azure CLI writes informational messages to stderr with "WARNING:" prefix. + // Strip it for cleaner output. var cleanData = IsAzureCliCommand(command) ? StripAzureWarningPrefix(args.Data) : args.Data; - // Skip blank lines that result from stripping az cli prefixes - if (!string.IsNullOrWhiteSpace(cleanData)) + // Suppress blank lines and known non-actionable Python / az-CLI + // diagnostic lines that leak onto stderr even on successful calls. + if (!string.IsNullOrWhiteSpace(cleanData) && !IsNonActionableStderrLine(cleanData)) { Console.WriteLine($"{outputPrefix}{cleanData}"); } @@ -196,7 +208,18 @@ public virtual async Task ExecuteWithStreamingAsync( // If not interactive and we redirected stdin we could implement scripted input later. - await process.WaitForExitAsync(cancellationToken); + try + { + await process.WaitForExitAsync(cancellationToken); + } + catch (OperationCanceledException) + { + // User pressed Ctrl+C — kill the child immediately so stdin is + // released and the console is not stuck on a zombie subprocess. + try { if (!process.HasExited) process.Kill(entireProcessTree: true); } + catch (Exception killEx) { _logger.LogDebug(killEx, "Failed to kill process after cancellation"); } + throw; + } var result = new CommandResult { @@ -245,6 +268,37 @@ private string StripAzureWarningPrefix(string message) return message; } + /// + /// Returns true for well-known non-actionable stderr lines that should be suppressed + /// from the console entirely. These originate from the Python interpreter bundled + /// inside the Azure CLI and appear on stderr even during fully successful calls: + /// + /// 1. Python 32-bit-on-64-bit UserWarning — emitted by the cryptography package + /// (e.g. "UserWarning: You are using cryptography on a 32-bit Python..."). + /// Cannot be silenced via az CLI flags; it is purely informational. + /// + /// 2. Azure SDK "Readonly attribute name will be ignored" reflection warning — + /// appears after StripAzureWarningPrefix() has removed the "WARNING:" prefix. + /// It is an internal SDK model issue, not actionable by end-users. + /// + /// Both classes of message are captured in StandardError for diagnostic purposes + /// (visible in the log file) but must not surface on the console as fake ERRORs. + /// + private static bool IsNonActionableStderrLine(string line) + { + var trimmed = line.AsSpan().TrimStart(); + // Python warnings module: "UserWarning: ..." + if (trimmed.StartsWith("UserWarning:", StringComparison.OrdinalIgnoreCase)) + return true; + // Azure SDK model reflection warning (\"WARNING:\" prefix already stripped). + if (trimmed.StartsWith("Readonly attribute name will be ignored", StringComparison.OrdinalIgnoreCase)) + return true; + // Python warnings module call site line that follows a UserWarning line. + if (trimmed.StartsWith("warnings.warn(", StringComparison.Ordinal)) + return true; + return false; + } + private const string JwtTokenPrefix = "eyJ"; private const int JwtTokenDotCount = 2; private const int MinimumJwtTokenLength = 100; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs index 3996c603..16df7ea8 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigService.cs @@ -1,867 +1,882 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; -using System.Text.Json; -using System.Text.RegularExpressions; -using Microsoft.Extensions.Logging; -using Microsoft.Agents.A365.DevTools.Cli.Models; -using Microsoft.Agents.A365.DevTools.Cli.Constants; -using Microsoft.Agents.A365.DevTools.Cli.Exceptions; - -namespace Microsoft.Agents.A365.DevTools.Cli.Services; - -/// -/// Implementation of configuration service for Agent 365 CLI. -/// Handles loading, saving, and validating the two-file configuration model. -/// -public class ConfigService : IConfigService -{ - /// - /// Gets the global directory path for config files. - /// Cross-platform implementation following XDG Base Directory Specification: - /// - Windows: %LocalAppData%\Microsoft.Agents.A365.DevTools.Cli - /// - Linux/Mac: $XDG_CONFIG_HOME/a365 (default: ~/.config/a365) - /// - public static string GetGlobalConfigDirectory() - { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - var localAppData = Environment.GetEnvironmentVariable("LocalAppData"); - if (!string.IsNullOrEmpty(localAppData)) - return Path.Combine(localAppData, AuthenticationConstants.ApplicationName); - - // Fallback to SpecialFolder if environment variable not set - var fallbackPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - return Path.Combine(fallbackPath, AuthenticationConstants.ApplicationName); - } - else - { - // On non-Windows, use XDG Base Directory Specification - // https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html - var xdgConfigHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); - if (!string.IsNullOrEmpty(xdgConfigHome)) - return Path.Combine(xdgConfigHome, "a365"); - - // Default to ~/.config/a365 if XDG_CONFIG_HOME not set - var home = Environment.GetEnvironmentVariable("HOME"); - if (!string.IsNullOrEmpty(home)) - return Path.Combine(home, ".config", "a365"); - - // Final fallback to current directory - return Environment.CurrentDirectory; - } - } - - /// - /// Gets the logs directory path for CLI command execution logs. - /// Follows Microsoft CLI patterns (Azure CLI, .NET CLI). - /// - Windows: %LocalAppData%\Microsoft.Agents.A365.DevTools.Cli\logs\ - /// - Linux/Mac: ~/.config/a365/logs/ - /// - public static string GetLogsDirectory() - { - var configDir = GetGlobalConfigDirectory(); - var logsDir = Path.Combine(configDir, "logs"); - - // Ensure directory exists - try - { - Directory.CreateDirectory(logsDir); - } - catch - { - // If we can't create the logs directory, fall back to temp - logsDir = Path.Combine(Path.GetTempPath(), "a365-logs"); - Directory.CreateDirectory(logsDir); - } - - return logsDir; - } - - /// - /// Gets the log file path for a specific command. - /// Always overwrites - keeps only the latest run for debugging. - /// - /// Name of the command (e.g., "setup", "deploy", "create-instance") - /// Full path to the command log file (e.g., "a365.setup.log") - public static string GetCommandLogPath(string commandName) - { - var logsDir = GetLogsDirectory(); - return Path.Combine(logsDir, $"a365.{commandName}.log"); - } - - /// - /// Gets the full path to a config file in the global directory. - /// - private static string GetGlobalConfigPath(string fileName) - { - return Path.Combine(GetGlobalConfigDirectory(), fileName); - } - - private static string GetGlobalGeneratedConfigPath() - { - return GetGlobalConfigPath("a365.generated.config.json"); - } - - /// - /// Syncs a config file to the global directory for portability. - /// This allows CLI commands to run from any directory. - /// - private async Task SyncConfigToGlobalDirectoryAsync(string fileName, string content, bool throwOnError = false) - { - try - { - var globalDir = GetGlobalConfigDirectory(); - Directory.CreateDirectory(globalDir); - - var globalPath = GetGlobalConfigPath(fileName); - - // Write the config content to the global directory - await File.WriteAllTextAsync(globalPath, content); - - _logger?.LogDebug("Synced configuration to global directory: {Path}", globalPath); - return true; - } - catch (Exception ex) - { - _logger?.LogWarning(ex, "Failed to sync {FileName} to global directory. CLI may not work from other directories.", fileName); - if (throwOnError) throw; - return false; - } - } - - public static void WarnIfLocalGeneratedConfigIsStale(string? localPath, ILogger? logger = null) - { - if (string.IsNullOrEmpty(localPath) || !File.Exists(localPath)) return; - var globalPath = GetGlobalGeneratedConfigPath(); - if (!File.Exists(globalPath)) return; - - try - { - // Compare the lastUpdated timestamps from INSIDE the JSON content, not file system timestamps - // This is because SaveStateAsync writes local first, then global, creating a small time difference - // in file system timestamps even though the content (and lastUpdated field) are identical - var localJson = File.ReadAllText(localPath); - var globalJson = File.ReadAllText(globalPath); - - using var localDoc = JsonDocument.Parse(localJson); - using var globalDoc = JsonDocument.Parse(globalJson); - - var localRoot = localDoc.RootElement; - var globalRoot = globalDoc.RootElement; - - // Get lastUpdated from both files - if (!localRoot.TryGetProperty("lastUpdated", out var localUpdated)) return; - if (!globalRoot.TryGetProperty("lastUpdated", out var globalUpdated)) return; - - // Compare the raw string values instead of DateTime objects to avoid timezone conversion issues - var localTimeStr = localUpdated.GetString(); - var globalTimeStr = globalUpdated.GetString(); - - // If the timestamps are identical as strings, they're from the same save operation - if (localTimeStr == globalTimeStr) - { - return; // Same save operation, no warning needed - } - - // If timestamps differ, parse and compare them - var localTime = localUpdated.GetDateTime(); - var globalTime = globalUpdated.GetDateTime(); - - // Only warn if the content timestamps differ (meaning they're from different save operations) - // TODO: Current design uses local folder data even if it's older than %LocalAppData%. - // This needs to be revisited to determine if we should: - // 1. Always prefer %LocalAppData% as authoritative source - // 2. Prompt user to choose which config to use - // 3. Auto-sync from newer to older location - if (globalTime > localTime) - { - var msg = $"Warning: The local generated config (at {localPath}) is older than the global config (at {globalPath}). You may be using stale configuration. Consider syncing or running setup again."; - if (logger != null) - logger.LogDebug(msg); - else - { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine(msg); - Console.ResetColor(); - } - } - } - catch (Exception) - { - // If we can't parse or compare, just skip the warning rather than crashing - // This method is a helpful check, not critical functionality - return; - } - } - - private readonly ILogger? _logger; - - private static readonly JsonSerializerOptions DefaultJsonOptions = new() - { - PropertyNameCaseInsensitive = true, - WriteIndented = true, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull - }; - - public ConfigService(ILogger? logger = null) - { - _logger = logger; - } - - /// - public async Task LoadAsync( - string configPath = "a365.config.json", - string statePath = "a365.generated.config.json") - { - // SMART PATH RESOLUTION: - // If configPath is absolute or contains directory separators, resolve statePath relative to it - // This ensures generated config is loaded from the same directory as the main config - string resolvedStatePath = statePath; - - if (Path.IsPathRooted(configPath) || configPath.Contains(Path.DirectorySeparatorChar) || configPath.Contains(Path.AltDirectorySeparatorChar)) - { - // Config path is absolute or relative with directory - resolve state path in same directory - var configDir = Path.GetDirectoryName(configPath); - if (!string.IsNullOrEmpty(configDir)) - { - // Extract just the filename from statePath (in case caller passed a full path) - var stateFileName = Path.GetFileName(statePath); - resolvedStatePath = Path.Combine(configDir, stateFileName); - _logger?.LogDebug("Resolved state path to: {StatePath} (same directory as config)", resolvedStatePath); - } - } - - // Resolve config file path - var resolvedConfigPath = FindConfigFile(configPath) ?? configPath; - - // Validate static config file exists - if (!File.Exists(resolvedConfigPath)) - { - throw new ConfigFileNotFoundException(resolvedConfigPath); - } - - // Load static configuration (required) - var staticJson = await File.ReadAllTextAsync(resolvedConfigPath); - var staticConfig = JsonSerializer.Deserialize(staticJson, DefaultJsonOptions) - ?? throw new JsonException($"Failed to deserialize static configuration from {resolvedConfigPath}"); - - _logger?.LogDebug("Loaded static configuration from: {ConfigPath}", resolvedConfigPath); - - // Sync static config to global directory if loaded from current directory - // This ensures portability - user can run CLI commands from any directory - var currentDirConfigPath = Path.Combine(Environment.CurrentDirectory, configPath); - bool loadedFromCurrentDir = Path.GetFullPath(resolvedConfigPath).Equals( - Path.GetFullPath(currentDirConfigPath), - StringComparison.OrdinalIgnoreCase); - - if (loadedFromCurrentDir) - { - await SyncConfigToGlobalDirectoryAsync(Path.GetFileName(configPath), staticJson, throwOnError: false); - } - - // Try to find state file (use resolved path first, then fallback to search) - string? actualStatePath = null; - - // First, try the resolved state path (same directory as config) - if (File.Exists(resolvedStatePath)) - { - actualStatePath = resolvedStatePath; - _logger?.LogDebug("Found state file at resolved path: {StatePath}", actualStatePath); - } - else - { - // Fallback: search for state file - actualStatePath = FindConfigFile(Path.GetFileName(statePath)); - if (actualStatePath != null) - { - _logger?.LogDebug("Found state file via search: {StatePath}", actualStatePath); - } - } - - // Warn if local generated config is stale (only if loading the default state file) - if (Path.GetFileName(resolvedStatePath).Equals("a365.generated.config.json", StringComparison.OrdinalIgnoreCase)) - { - WarnIfLocalGeneratedConfigIsStale(actualStatePath, _logger); - } - - // Load dynamic state if exists (optional) - if (actualStatePath != null && File.Exists(actualStatePath)) - { - var stateJson = await File.ReadAllTextAsync(actualStatePath); - var stateData = JsonSerializer.Deserialize(stateJson, DefaultJsonOptions); - - // Merge dynamic properties into static config - MergeDynamicProperties(staticConfig, stateData); - _logger?.LogDebug("Merged dynamic state from: {StatePath}", actualStatePath); - } - else - { - _logger?.LogDebug("No dynamic state file found at: {StatePath}", resolvedStatePath); - } - - // Validate the merged configuration - var validationResult = await ValidateAsync(staticConfig); - if (!validationResult.IsValid) - { - _logger?.LogError("Configuration validation failed:"); - foreach (var error in validationResult.Errors) - { - _logger?.LogError(" * {Error}", error); - } - - // Convert validation errors to structured exception - var validationErrors = validationResult.Errors - .Select(e => ParseValidationError(e)) - .ToList(); - - throw new Exceptions.ConfigurationValidationException(resolvedConfigPath, validationErrors); - } - - // Log warnings if any - if (validationResult.Warnings.Count > 0) - if (validationResult.Warnings.Count > 0) - { - foreach (var warning in validationResult.Warnings) - { - _logger?.LogWarning(" * {Warning}", warning); - } - } - - return staticConfig; - } - - /// - public async Task SaveStateAsync( - Agent365Config config, - string statePath = "a365.generated.config.json") - { - // Extract only dynamic (get/set) properties - var dynamicData = ExtractDynamicProperties(config); - - // Update metadata - dynamicData["lastUpdated"] = DateTime.UtcNow; - dynamicData["cliVersion"] = GetCliVersion(); - - // Serialize to JSON - var json = JsonSerializer.Serialize(dynamicData, DefaultJsonOptions); - - // If an absolute path is provided, use it directly (for testing and explicit control) - if (Path.IsPathRooted(statePath)) - { - try - { - await File.WriteAllTextAsync(statePath, json); - _logger?.LogDebug("Saved dynamic state to absolute path: {StatePath}", statePath); - return; - } - catch (Exception ex) - { - _logger?.LogError(ex, "Failed to save dynamic state to: {StatePath}", statePath); - throw; - } - } - - // For relative paths, check if we're in a project directory (has local static config) - var staticConfigPath = Path.Combine(Environment.CurrentDirectory, ConfigConstants.DefaultConfigFileName); - bool hasLocalStaticConfig = File.Exists(staticConfigPath); - - if (hasLocalStaticConfig) - { - // We're in a project directory - save state locally only - // This ensures each project maintains its own independent configuration - var currentDirPath = Path.Combine(Environment.CurrentDirectory, statePath); - try - { - await File.WriteAllTextAsync(currentDirPath, json); - _logger?.LogDebug("Saved dynamic state to local project directory: {StatePath}", currentDirPath); - } - catch (Exception ex) - { - _logger?.LogError(ex, "Failed to save dynamic state to: {StatePath}", currentDirPath); - throw; - } - } - else - { - // Not in a project directory - save to global directory for portability - // This allows CLI commands to work when run from any directory - await SyncConfigToGlobalDirectoryAsync(statePath, json, throwOnError: true); - _logger?.LogDebug("Saved dynamic state to global directory (no local static config found)"); - } - } - - /// - public async Task ValidateAsync(Agent365Config config) - { - var errors = new List(); - var warnings = new List(); - - ValidateRequired(config.TenantId, nameof(config.TenantId), errors); - ValidateGuid(config.TenantId, nameof(config.TenantId), errors); - - if (config.NeedDeployment) - { - // Validate required static properties - ValidateRequired(config.SubscriptionId, nameof(config.SubscriptionId), errors); - ValidateRequired(config.ResourceGroup, nameof(config.ResourceGroup), errors); - ValidateRequired(config.Location, nameof(config.Location), errors); - ValidateRequired(config.AppServicePlanName, nameof(config.AppServicePlanName), errors); - ValidateRequired(config.WebAppName, nameof(config.WebAppName), errors); - - // Validate GUID formats - ValidateGuid(config.SubscriptionId, nameof(config.SubscriptionId), errors); - - // Validate Azure naming conventions - ValidateResourceGroupName(config.ResourceGroup, errors); - ValidateAppServicePlanName(config.AppServicePlanName, errors); - ValidateWebAppName(config.WebAppName, errors); - } - else - { - // Only validate bot messaging endpoint - ValidateRequired(config.MessagingEndpoint, nameof(config.MessagingEndpoint), errors); - ValidateUrl(config.MessagingEndpoint, nameof(config.MessagingEndpoint), errors); - } - - // Validate dynamic properties if they exist - if (config.ManagedIdentityPrincipalId != null) - { - ValidateGuid(config.ManagedIdentityPrincipalId, nameof(config.ManagedIdentityPrincipalId), errors); - } - - if (config.AgenticAppId != null) - { - ValidateGuid(config.AgenticAppId, nameof(config.AgenticAppId), errors); - } - - if (config.BotId != null) - { - ValidateGuid(config.BotId, nameof(config.BotId), errors); - } - - if (config.BotMsaAppId != null) - { - ValidateGuid(config.BotMsaAppId, nameof(config.BotMsaAppId), errors); - } - - // Validate URLs if present - if (config.BotMessagingEndpoint != null) - { - ValidateUrl(config.BotMessagingEndpoint, nameof(config.BotMessagingEndpoint), errors); - } - - // Add warnings for best practices - if (string.IsNullOrEmpty(config.AgentDescription)) - { - warnings.Add("AgentDescription is not set. Consider adding a description for better user experience."); - } - - // AgentIdentityScopes and AgentApplicationScopes are now hardcoded defaults - no validation needed - - var result = errors.Count == 0 - ? ValidationResult.Success() - : new ValidationResult { IsValid = false, Errors = errors, Warnings = warnings }; - - if (!result.IsValid) - { - _logger?.LogWarning("Configuration validation failed with {ErrorCount} errors", errors.Count); - } - - return await Task.FromResult(result); - } - - /// - public Task ConfigExistsAsync(string configPath = "a365.config.json") - { - var resolvedPath = FindConfigFile(configPath); - return Task.FromResult(resolvedPath != null); - } - - /// - public Task StateExistsAsync(string statePath = "a365.generated.config.json") - { - var resolvedPath = FindConfigFile(statePath); - return Task.FromResult(resolvedPath != null); - } - - /// - public async Task CreateDefaultConfigAsync( - string configPath = "a365.config.json", - Agent365Config? templateConfig = null) - { - // Only update in current directory if it already exists - var config = templateConfig ?? new Agent365Config - { - TenantId = string.Empty, - SubscriptionId = string.Empty, - ResourceGroup = string.Empty, - Location = string.Empty, - AppServicePlanName = string.Empty, - AppServicePlanSku = "B1", // Default SKU that works for development - WebAppName = string.Empty, - AgentIdentityDisplayName = string.Empty, - // AgentIdentityScopes and AgentApplicationScopes are now hardcoded defaults - DeploymentProjectPath = string.Empty, - AgentDescription = string.Empty - }; - - // Only serialize static (init) properties for the config file - var staticData = ExtractStaticProperties(config); - var json = JsonSerializer.Serialize(staticData, DefaultJsonOptions); - - var currentDirPath = Path.Combine(Environment.CurrentDirectory, configPath); - if (File.Exists(currentDirPath)) - { - await File.WriteAllTextAsync(currentDirPath, json); - _logger?.LogInformation("Updated configuration at: {ConfigPath}", currentDirPath); - } - } - - /// - public async Task InitializeStateAsync(string statePath = "a365.generated.config.json") - { - // Create in current directory if no path components, otherwise use as-is - var targetPath = Path.IsPathRooted(statePath) || statePath.Contains(Path.DirectorySeparatorChar) - ? statePath - : Path.Combine(Environment.CurrentDirectory, statePath); - - var emptyState = new Dictionary - { - ["lastUpdated"] = DateTime.UtcNow, - ["cliVersion"] = GetCliVersion() - }; - - var json = JsonSerializer.Serialize(emptyState, DefaultJsonOptions); - await File.WriteAllTextAsync(targetPath, json); - _logger?.LogInformation("Initialized empty state file at: {StatePath}", targetPath); - } - - #region Config File Resolution - - /// - /// Searches for a config file in multiple standard locations. - /// - /// The config file name to search for - /// The full path to the config file if found, otherwise null - private static string? FindConfigFile(string fileName) - { - // 1. Current directory - var currentDirPath = Path.Combine(Environment.CurrentDirectory, fileName); - if (File.Exists(currentDirPath)) - return currentDirPath; - - // 2. Global config directory (use consistent path resolution) - var globalConfigPath = Path.Combine(GetGlobalConfigDirectory(), fileName); - if (File.Exists(globalConfigPath)) - return globalConfigPath; - - // Not found - return null; - } - - /// - /// Gets the path to the static configuration file (a365.config.json). - /// Searches current directory first, then global config directory. - /// - /// Full path if found, otherwise null - public static string? GetConfigFilePath() - { - return FindConfigFile("a365.config.json"); - } - - /// - /// Gets the path to the generated configuration file (a365.generated.config.json). - /// Searches current directory first, then global config directory. - /// - /// Full path if found, otherwise null - public static string? GetGeneratedConfigFilePath() - { - return FindConfigFile("a365.generated.config.json"); - } - - #endregion - - #region Private Helper Methods - - /// - /// Merges dynamic properties from JSON into the config object. - /// - private void MergeDynamicProperties(Agent365Config config, JsonElement stateData) - { - var type = typeof(Agent365Config); - var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); - - foreach (var prop in properties) - { - // Only process properties with public setter (not init-only) - if (!HasPublicSetter(prop)) continue; - - var jsonName = GetJsonPropertyName(prop); - if (stateData.TryGetProperty(jsonName, out var value)) - { - try - { - var convertedValue = ConvertJsonElement(value, prop.PropertyType); - prop.SetValue(config, convertedValue); - } - catch (Exception ex) - { - // Log warning but continue - don't fail entire load for one bad property - _logger?.LogWarning(ex, "Failed to set property {PropertyName}", prop.Name); - } - } - } - } - - /// - /// Extracts only dynamic (get/set) properties from the config object. - /// - private Dictionary ExtractDynamicProperties(Agent365Config config) - { - var result = new Dictionary(); - var type = typeof(Agent365Config); - var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); - - foreach (var prop in properties) - { - // Only include properties with public setter (not init-only) - if (!HasPublicSetter(prop)) continue; - - var jsonName = GetJsonPropertyName(prop); - var value = prop.GetValue(config); - result[jsonName] = value; - } - - return result; - } - - /// - /// Extracts only static (init) properties from the config object. - /// - private Dictionary ExtractStaticProperties(Agent365Config config) - { - var result = new Dictionary(); - var type = typeof(Agent365Config); - var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); - - foreach (var prop in properties) - { - // Only include properties without public setter (init-only) - if (HasPublicSetter(prop)) continue; - - var jsonName = GetJsonPropertyName(prop); - var value = prop.GetValue(config); - - // Skip null values for cleaner JSON - if (value != null) - { - result[jsonName] = value; - } - } - - return result; - } - - /// - /// Checks if a property has a public setter (not init-only). - /// - private bool HasPublicSetter(PropertyInfo prop) - { - var setMethod = prop.GetSetMethod(); - if (setMethod == null) return false; - - // Check if it's an init-only property - var returnParam = setMethod.ReturnParameter; - var modifiers = returnParam.GetRequiredCustomModifiers(); - return !modifiers.Contains(typeof(IsExternalInit)); - } - - /// - /// Gets the JSON property name from JsonPropertyName attribute or property name. - /// - private string GetJsonPropertyName(PropertyInfo prop) - { - var attr = prop.GetCustomAttribute(); - return attr?.Name ?? prop.Name; - } - - /// - /// Converts JsonElement to the target property type. - /// - private object? ConvertJsonElement(JsonElement element, Type targetType) - { - if (element.ValueKind == JsonValueKind.Null) - return null; - - // Handle nullable types - var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType; - - if (underlyingType == typeof(string)) - return element.ValueKind == JsonValueKind.String - ? element.GetString() - : element.GetRawText(); // fallback: convert any other JSON type to string - - if (underlyingType == typeof(int)) - return element.GetInt32(); - - if (underlyingType == typeof(bool)) - { - if (element.ValueKind == JsonValueKind.True) return true; - if (element.ValueKind == JsonValueKind.False) return false; - if (element.ValueKind == JsonValueKind.String && - bool.TryParse(element.GetString(), out var result)) - return result; - - return element.GetBoolean(); - } - - if (underlyingType == typeof(DateTime)) - return element.GetDateTime(); - - if (underlyingType == typeof(Guid)) - return element.GetGuid(); - - if (underlyingType == typeof(List)) - { - var list = new List(); - foreach (var item in element.EnumerateArray()) - { - list.Add(item.GetString() ?? string.Empty); - } - return list; - } - - // For complex types, deserialize - return JsonSerializer.Deserialize(element.GetRawText(), targetType, DefaultJsonOptions); - } - - /// - /// Gets the current CLI version. - /// - private string GetCliVersion() - { - var assembly = Assembly.GetExecutingAssembly(); - var version = assembly.GetName().Version; - return version?.ToString() ?? "1.0.0"; - } - - #endregion - - #region Validation Helpers - - private void ValidateRequired(string? value, string propertyName, List errors) - { - if (string.IsNullOrWhiteSpace(value)) - { - errors.Add($"{propertyName} is required but was not provided."); - } - } - - private void ValidateGuid(string? value, string propertyName, List errors) - { - if (string.IsNullOrWhiteSpace(value)) return; - - if (!Guid.TryParse(value, out _)) - { - errors.Add($"{propertyName} must be a valid GUID format."); - } - } - - private void ValidateUrl(string? value, string propertyName, List errors) - { - if (string.IsNullOrWhiteSpace(value)) return; - - if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || - (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) - { - errors.Add($"{propertyName} must be a valid HTTP or HTTPS URL."); - } - } - - private void ValidateResourceGroupName(string? value, List errors) - { - if (string.IsNullOrWhiteSpace(value)) return; - - if (value.Length > 90) - { - errors.Add("ResourceGroup name must not exceed 90 characters."); - } - - if (!Regex.IsMatch(value, @"^[a-zA-Z0-9_\-\.()]+$")) - { - errors.Add("ResourceGroup name can only contain alphanumeric characters, underscores, hyphens, periods, and parentheses."); - } - } - - public static void ValidateAppServicePlanName(string? value, List errors) - { - if (string.IsNullOrWhiteSpace(value)) return; - - if (value.Length > 40) - { - errors.Add("AppServicePlanName must not exceed 40 characters."); - } - - if (!System.Text.RegularExpressions.Regex.IsMatch(value, @"^[a-zA-Z0-9\-]+$")) - { - errors.Add("AppServicePlanName can only contain alphanumeric characters and hyphens."); - } - } - - private void ValidateWebAppName(string? value, List errors) - { - if (string.IsNullOrWhiteSpace(value)) return; - - // Azure App Service names: 2-60 characters (not 64 as sometimes documented) - // Must contain only alphanumeric characters and hyphens - // Cannot start or end with a hyphen - // Must be globally unique - - if (value.Length < 2 || value.Length > 60) - { - errors.Add($"WebAppName must be between 2 and 60 characters (currently {value.Length} characters)."); - } - - // Check for invalid characters (only alphanumeric and hyphens allowed) - if (!Regex.IsMatch(value, @"^[a-zA-Z0-9\-]+$")) - { - errors.Add("WebAppName can only contain alphanumeric characters and hyphens (no underscores or other special characters)."); - } - - // Check if starts or ends with hyphen - if (value.StartsWith('-') || value.EndsWith('-')) - { - errors.Add("WebAppName cannot start or end with a hyphen."); - } - } - - /// - /// Parses a validation error message into a ValidationError object. - /// Error format: "PropertyName must ..." or "PropertyName: error message" - /// - private Exceptions.ValidationError ParseValidationError(string errorMessage) - { - // Try to extract field name from error message - // Common patterns: - // - "PropertyName must ..." - // - "PropertyName: error message" - // - "PropertyName is required ..." - - var parts = errorMessage.Split(new[] { ' ', ':' }, 2, StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 2) - { - var fieldName = parts[0].Trim(); - var message = parts[1].Trim(); - return new Exceptions.ValidationError(fieldName, message); - } - - // Fallback: treat entire message as the error - return new Exceptions.ValidationError("Configuration", errorMessage); - } - - #endregion +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Exceptions; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services; + +/// +/// Implementation of configuration service for Agent 365 CLI. +/// Handles loading, saving, and validating the two-file configuration model. +/// +public class ConfigService : IConfigService +{ + /// + /// Gets the global directory path for config files. + /// Cross-platform implementation following XDG Base Directory Specification: + /// - Windows: %LocalAppData%\Microsoft.Agents.A365.DevTools.Cli + /// - Linux/Mac: $XDG_CONFIG_HOME/a365 (default: ~/.config/a365) + /// + public static string GetGlobalConfigDirectory() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var localAppData = Environment.GetEnvironmentVariable("LocalAppData"); + if (!string.IsNullOrEmpty(localAppData)) + return Path.Combine(localAppData, AuthenticationConstants.ApplicationName); + + // Fallback to SpecialFolder if environment variable not set + var fallbackPath = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(fallbackPath, AuthenticationConstants.ApplicationName); + } + else + { + // On non-Windows, use XDG Base Directory Specification + // https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html + var xdgConfigHome = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME"); + if (!string.IsNullOrEmpty(xdgConfigHome)) + return Path.Combine(xdgConfigHome, "a365"); + + // Default to ~/.config/a365 if XDG_CONFIG_HOME not set + var home = Environment.GetEnvironmentVariable("HOME"); + if (!string.IsNullOrEmpty(home)) + return Path.Combine(home, ".config", "a365"); + + // Final fallback to current directory + return Environment.CurrentDirectory; + } + } + + /// + /// Gets the logs directory path for CLI command execution logs. + /// Follows Microsoft CLI patterns (Azure CLI, .NET CLI). + /// - Windows: %LocalAppData%\Microsoft.Agents.A365.DevTools.Cli\logs\ + /// - Linux/Mac: ~/.config/a365/logs/ + /// + public static string GetLogsDirectory() + { + var configDir = GetGlobalConfigDirectory(); + var logsDir = Path.Combine(configDir, "logs"); + + // Ensure directory exists + try + { + Directory.CreateDirectory(logsDir); + } + catch + { + // If we can't create the logs directory, fall back to temp + logsDir = Path.Combine(Path.GetTempPath(), "a365-logs"); + Directory.CreateDirectory(logsDir); + } + + return logsDir; + } + + /// + /// Gets the log file path for a specific command. + /// Always overwrites - keeps only the latest run for debugging. + /// + /// Name of the command (e.g., "setup", "deploy", "create-instance") + /// Full path to the command log file (e.g., "a365.setup.log") + public static string GetCommandLogPath(string commandName) + { + var logsDir = GetLogsDirectory(); + return Path.Combine(logsDir, $"a365.{commandName}.log"); + } + + /// + /// Gets the full path to a config file in the global directory. + /// + private static string GetGlobalConfigPath(string fileName) + { + return Path.Combine(GetGlobalConfigDirectory(), fileName); + } + + private static string GetGlobalGeneratedConfigPath() + { + return GetGlobalConfigPath("a365.generated.config.json"); + } + + /// + /// Syncs a config file to the global directory for portability. + /// This allows CLI commands to run from any directory. + /// + private async Task SyncConfigToGlobalDirectoryAsync(string fileName, string content, bool throwOnError = false) + { + try + { + var globalDir = GetGlobalConfigDirectory(); + Directory.CreateDirectory(globalDir); + + var globalPath = GetGlobalConfigPath(fileName); + + // Write the config content to the global directory + await File.WriteAllTextAsync(globalPath, content); + + _logger?.LogDebug("Synced configuration to global directory: {Path}", globalPath); + return true; + } + catch (Exception ex) + { + _logger?.LogWarning(ex, "Failed to sync {FileName} to global directory. CLI may not work from other directories.", fileName); + if (throwOnError) throw; + return false; + } + } + + public static void WarnIfLocalGeneratedConfigIsStale(string? localPath, ILogger? logger = null) + { + if (string.IsNullOrEmpty(localPath) || !File.Exists(localPath)) return; + var globalPath = GetGlobalGeneratedConfigPath(); + if (!File.Exists(globalPath)) return; + + try + { + // Compare the lastUpdated timestamps from INSIDE the JSON content, not file system timestamps + // This is because SaveStateAsync writes local first, then global, creating a small time difference + // in file system timestamps even though the content (and lastUpdated field) are identical + var localJson = File.ReadAllText(localPath); + var globalJson = File.ReadAllText(globalPath); + + using var localDoc = JsonDocument.Parse(localJson); + using var globalDoc = JsonDocument.Parse(globalJson); + + var localRoot = localDoc.RootElement; + var globalRoot = globalDoc.RootElement; + + // Get lastUpdated from both files + if (!localRoot.TryGetProperty("lastUpdated", out var localUpdated)) return; + if (!globalRoot.TryGetProperty("lastUpdated", out var globalUpdated)) return; + + // Compare the raw string values instead of DateTime objects to avoid timezone conversion issues + var localTimeStr = localUpdated.GetString(); + var globalTimeStr = globalUpdated.GetString(); + + // If the timestamps are identical as strings, they're from the same save operation + if (localTimeStr == globalTimeStr) + { + return; // Same save operation, no warning needed + } + + // If timestamps differ, parse and compare them + var localTime = localUpdated.GetDateTime(); + var globalTime = globalUpdated.GetDateTime(); + + // Only warn if the content timestamps differ (meaning they're from different save operations) + // TODO: Current design uses local folder data even if it's older than %LocalAppData%. + // This needs to be revisited to determine if we should: + // 1. Always prefer %LocalAppData% as authoritative source + // 2. Prompt user to choose which config to use + // 3. Auto-sync from newer to older location + if (globalTime > localTime) + { + var msg = $"Warning: The local generated config (at {localPath}) is older than the global config (at {globalPath}). You may be using stale configuration. Consider syncing or running setup again."; + if (logger != null) + logger.LogDebug(msg); + else + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine(msg); + Console.ResetColor(); + } + } + } + catch (Exception) + { + // If we can't parse or compare, just skip the warning rather than crashing + // This method is a helpful check, not critical functionality + return; + } + } + + private readonly ILogger? _logger; + + private static readonly JsonSerializerOptions DefaultJsonOptions = new() + { + PropertyNameCaseInsensitive = true, + WriteIndented = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + // Use relaxed encoder so URL-valued fields (e.g. consentUrl) keep literal '&' instead + // of being escaped to '\u0026', which would break copy-paste into a browser. + // This applies globally to all config serialization; only URL-typed string values + // meaningfully benefit from or require the setting — all other scalar values are unaffected. + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + public ConfigService(ILogger? logger = null) + { + _logger = logger; + } + + /// + public async Task LoadAsync( + string configPath = "a365.config.json", + string statePath = "a365.generated.config.json") + { + // SMART PATH RESOLUTION: + // If configPath is absolute or contains directory separators, resolve statePath relative to it + // This ensures generated config is loaded from the same directory as the main config + string resolvedStatePath = statePath; + + if (Path.IsPathRooted(configPath) || configPath.Contains(Path.DirectorySeparatorChar) || configPath.Contains(Path.AltDirectorySeparatorChar)) + { + // Config path is absolute or relative with directory - resolve state path in same directory + var configDir = Path.GetDirectoryName(configPath); + if (!string.IsNullOrEmpty(configDir)) + { + // Extract just the filename from statePath (in case caller passed a full path) + var stateFileName = Path.GetFileName(statePath); + resolvedStatePath = Path.Combine(configDir, stateFileName); + _logger?.LogDebug("Resolved state path to: {StatePath} (same directory as config)", resolvedStatePath); + } + } + + // Resolve config file path + var resolvedConfigPath = FindConfigFile(configPath) ?? configPath; + + // Validate static config file exists + if (!File.Exists(resolvedConfigPath)) + { + throw new ConfigFileNotFoundException(resolvedConfigPath); + } + + // Load static configuration (required) + var staticJson = await File.ReadAllTextAsync(resolvedConfigPath); + var staticConfig = JsonSerializer.Deserialize(staticJson, DefaultJsonOptions) + ?? throw new JsonException($"Failed to deserialize static configuration from {resolvedConfigPath}"); + + _logger?.LogDebug("Loaded static configuration from: {ConfigPath}", resolvedConfigPath); + + // Sync static config to global directory if loaded from current directory + // This ensures portability - user can run CLI commands from any directory + var currentDirConfigPath = Path.Combine(Environment.CurrentDirectory, configPath); + bool loadedFromCurrentDir = Path.GetFullPath(resolvedConfigPath).Equals( + Path.GetFullPath(currentDirConfigPath), + StringComparison.OrdinalIgnoreCase); + + if (loadedFromCurrentDir) + { + await SyncConfigToGlobalDirectoryAsync(Path.GetFileName(configPath), staticJson, throwOnError: false); + } + + // Try to find state file (use resolved path first, then fallback to search) + string? actualStatePath = null; + + // First, try the resolved state path (same directory as config) + if (File.Exists(resolvedStatePath)) + { + actualStatePath = resolvedStatePath; + _logger?.LogDebug("Found state file at resolved path: {StatePath}", actualStatePath); + } + else + { + // Fallback: search for state file + actualStatePath = FindConfigFile(Path.GetFileName(statePath)); + if (actualStatePath != null) + { + _logger?.LogDebug("Found state file via search: {StatePath}", actualStatePath); + } + } + + // Warn if local generated config is stale (only if loading the default state file) + if (Path.GetFileName(resolvedStatePath).Equals("a365.generated.config.json", StringComparison.OrdinalIgnoreCase)) + { + WarnIfLocalGeneratedConfigIsStale(actualStatePath, _logger); + } + + // Load dynamic state if exists (optional) + if (actualStatePath != null && File.Exists(actualStatePath)) + { + var stateJson = await File.ReadAllTextAsync(actualStatePath); + var stateData = JsonSerializer.Deserialize(stateJson, DefaultJsonOptions); + + // Merge dynamic properties into static config + MergeDynamicProperties(staticConfig, stateData); + _logger?.LogDebug("Merged dynamic state from: {StatePath}", actualStatePath); + } + else + { + _logger?.LogDebug("No dynamic state file found at: {StatePath}", resolvedStatePath); + } + + // Validate the merged configuration + var validationResult = await ValidateAsync(staticConfig); + if (!validationResult.IsValid) + { + _logger?.LogError("Configuration validation failed:"); + foreach (var error in validationResult.Errors) + { + _logger?.LogError(" * {Error}", error); + } + + // Convert validation errors to structured exception + var validationErrors = validationResult.Errors + .Select(e => ParseValidationError(e)) + .ToList(); + + throw new Exceptions.ConfigurationValidationException(resolvedConfigPath, validationErrors); + } + + // Log warnings if any + if (validationResult.Warnings.Count > 0) + if (validationResult.Warnings.Count > 0) + { + foreach (var warning in validationResult.Warnings) + { + _logger?.LogWarning(" * {Warning}", warning); + } + } + + return staticConfig; + } + + /// + public async Task SaveStateAsync( + Agent365Config config, + string statePath = "a365.generated.config.json") + { + // Extract only dynamic (get/set) properties + var dynamicData = ExtractDynamicProperties(config); + + // Update metadata + dynamicData["lastUpdated"] = DateTime.UtcNow; + dynamicData["cliVersion"] = GetCliVersion(); + + // Serialize to JSON + var json = JsonSerializer.Serialize(dynamicData, DefaultJsonOptions); + + // If an absolute path is provided, use it directly (for testing and explicit control) + if (Path.IsPathRooted(statePath)) + { + try + { + await File.WriteAllTextAsync(statePath, json); + _logger?.LogDebug("Saved dynamic state to absolute path: {StatePath}", statePath); + return; + } + catch (Exception ex) + { + _logger?.LogError(ex, "Failed to save dynamic state to: {StatePath}", statePath); + throw; + } + } + + // For relative paths, check if we're in a project directory (has local static config) + var staticConfigPath = Path.Combine(Environment.CurrentDirectory, ConfigConstants.DefaultConfigFileName); + bool hasLocalStaticConfig = File.Exists(staticConfigPath); + + if (hasLocalStaticConfig) + { + // We're in a project directory - save state locally only + // This ensures each project maintains its own independent configuration + var currentDirPath = Path.Combine(Environment.CurrentDirectory, statePath); + try + { + await File.WriteAllTextAsync(currentDirPath, json); + _logger?.LogDebug("Saved dynamic state to local project directory: {StatePath}", currentDirPath); + } + catch (Exception ex) + { + _logger?.LogError(ex, "Failed to save dynamic state to: {StatePath}", currentDirPath); + throw; + } + } + else + { + // Not in a project directory - save to global directory for portability + // This allows CLI commands to work when run from any directory + await SyncConfigToGlobalDirectoryAsync(statePath, json, throwOnError: true); + _logger?.LogDebug("Saved dynamic state to global directory (no local static config found)"); + } + } + + /// + public async Task ValidateAsync(Agent365Config config) + { + var errors = new List(); + var warnings = new List(); + + ValidateRequired(config.TenantId, nameof(config.TenantId), errors); + ValidateGuid(config.TenantId, nameof(config.TenantId), errors); + + if (config.NeedDeployment) + { + // Validate required static properties + ValidateRequired(config.SubscriptionId, nameof(config.SubscriptionId), errors); + ValidateRequired(config.ResourceGroup, nameof(config.ResourceGroup), errors); + ValidateRequired(config.Location, nameof(config.Location), errors); + ValidateRequired(config.AppServicePlanName, nameof(config.AppServicePlanName), errors); + ValidateRequired(config.WebAppName, nameof(config.WebAppName), errors); + + // Validate GUID formats + ValidateGuid(config.SubscriptionId, nameof(config.SubscriptionId), errors); + + // Validate Azure naming conventions + ValidateResourceGroupName(config.ResourceGroup, errors); + ValidateAppServicePlanName(config.AppServicePlanName, errors); + ValidateWebAppName(config.WebAppName, errors); + } + else + { + // Only validate bot messaging endpoint + ValidateRequired(config.MessagingEndpoint, nameof(config.MessagingEndpoint), errors); + ValidateUrl(config.MessagingEndpoint, nameof(config.MessagingEndpoint), errors); + } + + // Validate dynamic properties if they exist + if (config.ManagedIdentityPrincipalId != null) + { + ValidateGuid(config.ManagedIdentityPrincipalId, nameof(config.ManagedIdentityPrincipalId), errors); + } + + if (config.AgenticAppId != null) + { + ValidateGuid(config.AgenticAppId, nameof(config.AgenticAppId), errors); + } + + if (config.BotId != null) + { + ValidateGuid(config.BotId, nameof(config.BotId), errors); + } + + if (config.BotMsaAppId != null) + { + ValidateGuid(config.BotMsaAppId, nameof(config.BotMsaAppId), errors); + } + + // Validate URLs if present + if (config.BotMessagingEndpoint != null) + { + ValidateUrl(config.BotMessagingEndpoint, nameof(config.BotMessagingEndpoint), errors); + } + + // Add warnings for best practices + if (string.IsNullOrEmpty(config.AgentDescription)) + { + warnings.Add("AgentDescription is not set. Consider adding a description for better user experience."); + } + + // AgentIdentityScopes and AgentApplicationScopes are now hardcoded defaults - no validation needed + + var result = errors.Count == 0 + ? ValidationResult.Success() + : new ValidationResult { IsValid = false, Errors = errors, Warnings = warnings }; + + if (!result.IsValid) + { + _logger?.LogWarning("Configuration validation failed with {ErrorCount} errors", errors.Count); + } + + return await Task.FromResult(result); + } + + /// + public Task ConfigExistsAsync(string configPath = "a365.config.json") + { + var resolvedPath = FindConfigFile(configPath); + return Task.FromResult(resolvedPath != null); + } + + /// + public Task StateExistsAsync(string statePath = "a365.generated.config.json") + { + var resolvedPath = FindConfigFile(statePath); + return Task.FromResult(resolvedPath != null); + } + + /// + public async Task CreateDefaultConfigAsync( + string configPath = "a365.config.json", + Agent365Config? templateConfig = null) + { + // Only update in current directory if it already exists + var config = templateConfig ?? new Agent365Config + { + TenantId = string.Empty, + SubscriptionId = string.Empty, + ResourceGroup = string.Empty, + Location = string.Empty, + AppServicePlanName = string.Empty, + AppServicePlanSku = "B1", // Default SKU that works for development + WebAppName = string.Empty, + AgentIdentityDisplayName = string.Empty, + // AgentIdentityScopes and AgentApplicationScopes are now hardcoded defaults + DeploymentProjectPath = string.Empty, + AgentDescription = string.Empty + }; + + // Only serialize static (init) properties for the config file + var staticData = ExtractStaticProperties(config); + var json = JsonSerializer.Serialize(staticData, DefaultJsonOptions); + + var currentDirPath = Path.Combine(Environment.CurrentDirectory, configPath); + if (File.Exists(currentDirPath)) + { + await File.WriteAllTextAsync(currentDirPath, json); + _logger?.LogInformation("Updated configuration at: {ConfigPath}", currentDirPath); + } + } + + /// + public async Task InitializeStateAsync(string statePath = "a365.generated.config.json") + { + // Create in current directory if no path components, otherwise use as-is + var targetPath = Path.IsPathRooted(statePath) || statePath.Contains(Path.DirectorySeparatorChar) + ? statePath + : Path.Combine(Environment.CurrentDirectory, statePath); + + var emptyState = new Dictionary + { + ["lastUpdated"] = DateTime.UtcNow, + ["cliVersion"] = GetCliVersion() + }; + + var json = JsonSerializer.Serialize(emptyState, DefaultJsonOptions); + await File.WriteAllTextAsync(targetPath, json); + _logger?.LogInformation("Initialized empty state file at: {StatePath}", targetPath); + } + + #region Config File Resolution + + /// + /// Searches for a config file in multiple standard locations. + /// + /// The config file name to search for + /// The full path to the config file if found, otherwise null + private static string? FindConfigFile(string fileName) + { + // 1. Current directory + var currentDirPath = Path.Combine(Environment.CurrentDirectory, fileName); + if (File.Exists(currentDirPath)) + return currentDirPath; + + // 2. Global config directory (use consistent path resolution) + var globalConfigPath = Path.Combine(GetGlobalConfigDirectory(), fileName); + if (File.Exists(globalConfigPath)) + return globalConfigPath; + + // Not found + return null; + } + + /// + /// Gets the path to the static configuration file (a365.config.json). + /// Searches current directory first, then global config directory. + /// + /// Full path if found, otherwise null + public static string? GetConfigFilePath() + { + return FindConfigFile("a365.config.json"); + } + + /// + /// Gets the path to the generated configuration file (a365.generated.config.json). + /// Searches current directory first, then global config directory. + /// + /// Full path if found, otherwise null + public static string? GetGeneratedConfigFilePath() + { + return FindConfigFile("a365.generated.config.json"); + } + + #endregion + + #region Private Helper Methods + + /// + /// Merges dynamic properties from JSON into the config object. + /// + private void MergeDynamicProperties(Agent365Config config, JsonElement stateData) + { + var type = typeof(Agent365Config); + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var prop in properties) + { + // Only process properties with public setter (not init-only) + if (!HasPublicSetter(prop)) continue; + + var jsonName = GetJsonPropertyName(prop); + if (stateData.TryGetProperty(jsonName, out var value)) + { + try + { + var convertedValue = ConvertJsonElement(value, prop.PropertyType); + prop.SetValue(config, convertedValue); + } + catch (Exception ex) + { + // Log warning but continue - don't fail entire load for one bad property + _logger?.LogWarning(ex, "Failed to set property {PropertyName}", prop.Name); + } + } + } + + // Migrate legacy key: generated configs written by older CLI versions use "botMessagingEndpoint". + // If the new key "messagingEndpoint" was not found (BotMessagingEndpoint is still null), + // fall back to the legacy key so existing setups continue to work without re-running setup. + if (config.BotMessagingEndpoint == null && + stateData.TryGetProperty("botMessagingEndpoint", out var legacyEndpoint) && + legacyEndpoint.ValueKind == JsonValueKind.String) + { + config.BotMessagingEndpoint = legacyEndpoint.GetString(); + } + } + + /// + /// Extracts only dynamic (get/set) properties from the config object. + /// + private Dictionary ExtractDynamicProperties(Agent365Config config) + { + var result = new Dictionary(); + var type = typeof(Agent365Config); + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var prop in properties) + { + // Only include properties with public setter (not init-only) + if (!HasPublicSetter(prop)) continue; + + var jsonName = GetJsonPropertyName(prop); + var value = prop.GetValue(config); + result[jsonName] = value; + } + + return result; + } + + /// + /// Extracts only static (init) properties from the config object. + /// + private Dictionary ExtractStaticProperties(Agent365Config config) + { + var result = new Dictionary(); + var type = typeof(Agent365Config); + var properties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance); + + foreach (var prop in properties) + { + // Only include properties without public setter (init-only) + if (HasPublicSetter(prop)) continue; + + var jsonName = GetJsonPropertyName(prop); + var value = prop.GetValue(config); + + // Skip null values for cleaner JSON + if (value != null) + { + result[jsonName] = value; + } + } + + return result; + } + + /// + /// Checks if a property has a public setter (not init-only). + /// + private bool HasPublicSetter(PropertyInfo prop) + { + var setMethod = prop.GetSetMethod(); + if (setMethod == null) return false; + + // Check if it's an init-only property + var returnParam = setMethod.ReturnParameter; + var modifiers = returnParam.GetRequiredCustomModifiers(); + return !modifiers.Contains(typeof(IsExternalInit)); + } + + /// + /// Gets the JSON property name from JsonPropertyName attribute or property name. + /// + private string GetJsonPropertyName(PropertyInfo prop) + { + var attr = prop.GetCustomAttribute(); + return attr?.Name ?? prop.Name; + } + + /// + /// Converts JsonElement to the target property type. + /// + private object? ConvertJsonElement(JsonElement element, Type targetType) + { + if (element.ValueKind == JsonValueKind.Null) + return null; + + // Handle nullable types + var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType; + + if (underlyingType == typeof(string)) + return element.ValueKind == JsonValueKind.String + ? element.GetString() + : element.GetRawText(); // fallback: convert any other JSON type to string + + if (underlyingType == typeof(int)) + return element.GetInt32(); + + if (underlyingType == typeof(bool)) + { + if (element.ValueKind == JsonValueKind.True) return true; + if (element.ValueKind == JsonValueKind.False) return false; + if (element.ValueKind == JsonValueKind.String && + bool.TryParse(element.GetString(), out var result)) + return result; + + return element.GetBoolean(); + } + + if (underlyingType == typeof(DateTime)) + return element.GetDateTime(); + + if (underlyingType == typeof(Guid)) + return element.GetGuid(); + + if (underlyingType == typeof(List)) + { + var list = new List(); + foreach (var item in element.EnumerateArray()) + { + list.Add(item.GetString() ?? string.Empty); + } + return list; + } + + // For complex types, deserialize + return JsonSerializer.Deserialize(element.GetRawText(), targetType, DefaultJsonOptions); + } + + /// + /// Gets the current CLI version. + /// + private string GetCliVersion() + { + var assembly = Assembly.GetExecutingAssembly(); + var version = assembly.GetName().Version; + return version?.ToString() ?? "1.0.0"; + } + + #endregion + + #region Validation Helpers + + private void ValidateRequired(string? value, string propertyName, List errors) + { + if (string.IsNullOrWhiteSpace(value)) + { + errors.Add($"{propertyName} is required but was not provided."); + } + } + + private void ValidateGuid(string? value, string propertyName, List errors) + { + if (string.IsNullOrWhiteSpace(value)) return; + + if (!Guid.TryParse(value, out _)) + { + errors.Add($"{propertyName} must be a valid GUID format."); + } + } + + private void ValidateUrl(string? value, string propertyName, List errors) + { + if (string.IsNullOrWhiteSpace(value)) return; + + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + errors.Add($"{propertyName} must be a valid HTTP or HTTPS URL."); + } + } + + private void ValidateResourceGroupName(string? value, List errors) + { + if (string.IsNullOrWhiteSpace(value)) return; + + if (value.Length > 90) + { + errors.Add("ResourceGroup name must not exceed 90 characters."); + } + + if (!Regex.IsMatch(value, @"^[a-zA-Z0-9_\-\.()]+$")) + { + errors.Add("ResourceGroup name can only contain alphanumeric characters, underscores, hyphens, periods, and parentheses."); + } + } + + public static void ValidateAppServicePlanName(string? value, List errors) + { + if (string.IsNullOrWhiteSpace(value)) return; + + if (value.Length > 40) + { + errors.Add("AppServicePlanName must not exceed 40 characters."); + } + + if (!System.Text.RegularExpressions.Regex.IsMatch(value, @"^[a-zA-Z0-9\-]+$")) + { + errors.Add("AppServicePlanName can only contain alphanumeric characters and hyphens."); + } + } + + private void ValidateWebAppName(string? value, List errors) + { + if (string.IsNullOrWhiteSpace(value)) return; + + // Azure App Service names: 2-60 characters (not 64 as sometimes documented) + // Must contain only alphanumeric characters and hyphens + // Cannot start or end with a hyphen + // Must be globally unique + + if (value.Length < 2 || value.Length > 60) + { + errors.Add($"WebAppName must be between 2 and 60 characters (currently {value.Length} characters)."); + } + + // Check for invalid characters (only alphanumeric and hyphens allowed) + if (!Regex.IsMatch(value, @"^[a-zA-Z0-9\-]+$")) + { + errors.Add("WebAppName can only contain alphanumeric characters and hyphens (no underscores or other special characters)."); + } + + // Check if starts or ends with hyphen + if (value.StartsWith('-') || value.EndsWith('-')) + { + errors.Add("WebAppName cannot start or end with a hyphen."); + } + } + + /// + /// Parses a validation error message into a ValidationError object. + /// Error format: "PropertyName must ..." or "PropertyName: error message" + /// + private Exceptions.ValidationError ParseValidationError(string errorMessage) + { + // Try to extract field name from error message + // Common patterns: + // - "PropertyName must ..." + // - "PropertyName: error message" + // - "PropertyName is required ..." + + var parts = errorMessage.Split(new[] { ' ', ':' }, 2, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 2) + { + var fieldName = parts[0].Trim(); + var message = parts[1].Trim(); + return new Exceptions.ValidationError(fieldName, message); + } + + // Fallback: treat entire message as the error + return new Exceptions.ValidationError("Configuration", errorMessage); + } + + #endregion } \ No newline at end of file diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs index bc796728..36950c66 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/ConfigurationWizardService.cs @@ -983,7 +983,8 @@ private string GetUsageLocationFromAccount(AzureAccountInfo accountInfo) using var validationLoggerFactory = LoggerFactoryHelper.CreateCleanLoggerFactory(); var executor = new CommandExecutor(validationLoggerFactory.CreateLogger()); - var validator = new ClientAppValidator(validationLoggerFactory.CreateLogger(), executor); + var graphApiService = new GraphApiService(validationLoggerFactory.CreateLogger(), executor); + var validator = new ClientAppValidator(validationLoggerFactory.CreateLogger(), graphApiService); try { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs index e30b1f47..1c7e7be9 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/DelegatedConsentService.cs @@ -49,6 +49,7 @@ public async Task EnsureBlueprintPermissionGrantAsync( try { _logger.LogInformation("==> Ensuring AgentIdentityBlueprint.ReadWrite.All permission for custom client app"); + _logger.LogInformation(""); _logger.LogInformation(" Client App ID: {AppId}", callingAppId); _logger.LogInformation(" Tenant ID: {TenantId}", tenantId); _logger.LogInformation(" Required Scope: {Scope}", TargetScope); @@ -171,7 +172,7 @@ public async Task EnsureBlueprintPermissionGrantAsync( // Create new service principal _logger.LogInformation("Creating service principal for app {AppId}", appId); - var createSpUrl = "https://graph.microsoft.com/v1.0/servicePrincipals"; + var createSpUrl = $"{GraphApiConstants.BaseUrl}/v1.0/servicePrincipals"; var createBody = new { appId = appId @@ -273,24 +274,22 @@ public async Task EnsureBlueprintPermissionGrantAsync( _logger.LogError("Fresh login failed"); return null; } - + + // The new az login session invalidates any previously cached tokens. + AzCliHelper.InvalidateAzCliTokenCache(); + _logger.LogInformation(" Acquiring fresh Graph API token..."); - - // Get fresh token - var tokenResult = await executor.ExecuteAsync( - "az", - $"account get-access-token --resource https://graph.microsoft.com/ --tenant {tenantId} --query accessToken -o tsv", - captureOutput: true, - cancellationToken: cancellationToken); - - if (tokenResult.Success && !string.IsNullOrWhiteSpace(tokenResult.StandardOutput)) + + // Re-populate the process-level cache with the new session's token. + var token = await AzCliHelper.AcquireAzCliTokenAsync(GraphApiConstants.GetResource(GraphApiConstants.BaseUrl), tenantId); + + if (!string.IsNullOrWhiteSpace(token)) { - var token = tokenResult.StandardOutput.Trim(); _logger.LogInformation(" Fresh token acquired successfully"); return token; } - - _logger.LogError("Failed to acquire fresh token: {Error}", tokenResult.StandardError); + + _logger.LogError("Failed to acquire fresh token after re-authentication"); return null; } catch (Exception ex) @@ -335,8 +334,8 @@ private bool IsCaeTokenError(string errorJson) { try { - var url = $"https://graph.microsoft.com/v1.0/servicePrincipals?$filter=appId eq '{appId}'"; - var response = await httpClient.GetAsync(url, cancellationToken); + var url = $"{GraphApiConstants.BaseUrl}/v1.0/servicePrincipals?$filter=appId eq '{appId}'"; + using var response = await httpClient.GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) { @@ -376,9 +375,9 @@ private bool IsCaeTokenError(string errorJson) try { var filter = $"clientId eq '{clientId}' and resourceId eq '{resourceId}' and consentType eq '{AllPrincipalsConsentType}'"; - var url = $"https://graph.microsoft.com/v1.0/oauth2PermissionGrants?$filter={Uri.EscapeDataString(filter)}"; + var url = $"{GraphApiConstants.BaseUrl}/v1.0/oauth2PermissionGrants?$filter={Uri.EscapeDataString(filter)}"; - var response = await httpClient.GetAsync(url, cancellationToken); + using var response = await httpClient.GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) { @@ -443,13 +442,13 @@ private async Task EnsureScopeOnGrantAsync( _logger.LogInformation(" Updating grant {GrantId} to include scope: {Scope}", grantId, scopeToAdd); // Update the grant - var updateUrl = $"https://graph.microsoft.com/v1.0/oauth2PermissionGrants/{grantId}"; + var updateUrl = $"{GraphApiConstants.BaseUrl}/v1.0/oauth2PermissionGrants/{grantId}"; var updateBody = new { scope = newScope }; - var updateResponse = await httpClient.PatchAsync( + using var updateResponse = await httpClient.PatchAsync( updateUrl, new StringContent( JsonSerializer.Serialize(updateBody), @@ -490,7 +489,7 @@ private async Task CreateGrantAsync( { try { - var createUrl = "https://graph.microsoft.com/v1.0/oauth2PermissionGrants"; + var createUrl = $"{GraphApiConstants.BaseUrl}/v1.0/oauth2PermissionGrants"; var createBody = new { clientId = clientId, @@ -499,7 +498,7 @@ private async Task CreateGrantAsync( scope = scope }; - var createResponse = await httpClient.PostAsync( + using var createResponse = await httpClient.PostAsync( createUrl, new StringContent( JsonSerializer.Serialize(createBody), @@ -515,8 +514,8 @@ private async Task CreateGrantAsync( } var responseJson = await createResponse.Content.ReadAsStringAsync(cancellationToken); - var response = JsonDocument.Parse(responseJson); - var grantId = response.RootElement.GetProperty("id").GetString(); + using var responseDoc = JsonDocument.Parse(responseJson); + var grantId = responseDoc.RootElement.GetProperty("id").GetString(); _logger.LogInformation(" Permission grant created successfully (ID: {GrantId})", grantId); return true; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/FederatedCredentialService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/FederatedCredentialService.cs index c6bcadcd..d4c84ed8 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/FederatedCredentialService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/FederatedCredentialService.cs @@ -2,8 +2,9 @@ // Licensed under the MIT License. using System.Text.Json; -using Microsoft.Extensions.Logging; +using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Extensions.Logging; namespace Microsoft.Agents.A365.DevTools.Cli.Services; @@ -48,24 +49,27 @@ public async Task> GetFederatedCredentialsAsync( _logger.LogDebug("Retrieving federated credentials for blueprint: {ObjectId}", blueprintObjectId); // Try standard endpoint first - var doc = await _graphApiService.GraphGetAsync( + var primaryDoc = await _graphApiService.GraphGetAsync( tenantId, $"/beta/applications/{blueprintObjectId}/federatedIdentityCredentials", - cancellationToken); + cancellationToken, + scopes: [AuthenticationConstants.ApplicationReadWriteAllScope]); - // If standard endpoint returns data with credentials, use it - if (doc != null && doc.RootElement.TryGetProperty("value", out var valueCheck) && valueCheck.GetArrayLength() > 0) + JsonDocument? doc; + if (primaryDoc != null && primaryDoc.RootElement.TryGetProperty("value", out var valueCheck) && valueCheck.GetArrayLength() > 0) { _logger.LogDebug("Standard endpoint returned {Count} credential(s)", valueCheck.GetArrayLength()); + doc = primaryDoc; } - // If standard endpoint returns empty or null, try Agent Blueprint-specific endpoint else { + primaryDoc?.Dispose(); _logger.LogDebug("Standard endpoint returned no credentials or failed, trying Agent Blueprint fallback endpoint"); doc = await _graphApiService.GraphGetAsync( tenantId, $"/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintObjectId}/federatedIdentityCredentials", - cancellationToken); + cancellationToken, + scopes: [AuthenticationConstants.ApplicationReadWriteAllScope]); } if (doc == null) @@ -74,80 +78,83 @@ public async Task> GetFederatedCredentialsAsync( return new List(); } - var root = doc.RootElement; - if (!root.TryGetProperty("value", out var valueElement)) + using (doc) { - return new List(); - } + var root = doc.RootElement; + if (!root.TryGetProperty("value", out var valueElement)) + { + return new List(); + } - var credentials = new List(); - foreach (var item in valueElement.EnumerateArray()) - { - try + var credentials = new List(); + foreach (var item in valueElement.EnumerateArray()) { - // Use TryGetProperty to handle missing fields gracefully - if (!item.TryGetProperty("id", out var idElement) || string.IsNullOrWhiteSpace(idElement.GetString())) + try { - _logger.LogWarning("Skipping federated credential with missing or empty 'id' field"); - continue; - } + // Use TryGetProperty to handle missing fields gracefully + if (!item.TryGetProperty("id", out var idElement) || string.IsNullOrWhiteSpace(idElement.GetString())) + { + _logger.LogWarning("Skipping federated credential with missing or empty 'id' field"); + continue; + } - if (!item.TryGetProperty("name", out var nameElement) || string.IsNullOrWhiteSpace(nameElement.GetString())) - { - _logger.LogWarning("Skipping federated credential with missing or empty 'name' field"); - continue; - } + if (!item.TryGetProperty("name", out var nameElement) || string.IsNullOrWhiteSpace(nameElement.GetString())) + { + _logger.LogWarning("Skipping federated credential with missing or empty 'name' field"); + continue; + } - if (!item.TryGetProperty("issuer", out var issuerElement) || string.IsNullOrWhiteSpace(issuerElement.GetString())) - { - _logger.LogWarning("Skipping federated credential with missing or empty 'issuer' field"); - continue; - } + if (!item.TryGetProperty("issuer", out var issuerElement) || string.IsNullOrWhiteSpace(issuerElement.GetString())) + { + _logger.LogWarning("Skipping federated credential with missing or empty 'issuer' field"); + continue; + } - if (!item.TryGetProperty("subject", out var subjectElement) || string.IsNullOrWhiteSpace(subjectElement.GetString())) - { - _logger.LogWarning("Skipping federated credential with missing or empty 'subject' field"); - continue; - } + if (!item.TryGetProperty("subject", out var subjectElement) || string.IsNullOrWhiteSpace(subjectElement.GetString())) + { + _logger.LogWarning("Skipping federated credential with missing or empty 'subject' field"); + continue; + } - var id = idElement.GetString(); - var name = nameElement.GetString(); - var issuer = issuerElement.GetString(); - var subject = subjectElement.GetString(); - - var audiences = new List(); - if (item.TryGetProperty("audiences", out var audiencesElement)) - { - foreach (var audience in audiencesElement.EnumerateArray()) + var id = idElement.GetString(); + var name = nameElement.GetString(); + var issuer = issuerElement.GetString(); + var subject = subjectElement.GetString(); + + var audiences = new List(); + if (item.TryGetProperty("audiences", out var audiencesElement)) { - var audienceValue = audience.GetString(); - if (!string.IsNullOrWhiteSpace(audienceValue)) + foreach (var audience in audiencesElement.EnumerateArray()) { - audiences.Add(audienceValue); + var audienceValue = audience.GetString(); + if (!string.IsNullOrWhiteSpace(audienceValue)) + { + audiences.Add(audienceValue); + } } } - } - credentials.Add(new FederatedCredentialInfo + credentials.Add(new FederatedCredentialInfo + { + Id = id, + Name = name, + Issuer = issuer, + Subject = subject, + Audiences = audiences + }); + } + catch (Exception itemEx) { - Id = id, - Name = name, - Issuer = issuer, - Subject = subject, - Audiences = audiences - }); - } - catch (Exception itemEx) - { - // Log individual credential parsing errors but continue processing remaining credentials - _logger.LogWarning(itemEx, "Failed to parse federated credential entry, skipping"); + // Log individual credential parsing errors but continue processing remaining credentials + _logger.LogWarning(itemEx, "Failed to parse federated credential entry, skipping"); + } } - } - _logger.LogDebug("Found {Count} federated credential(s) for blueprint: {ObjectId}", - credentials.Count, blueprintObjectId); + _logger.LogDebug("Found {Count} federated credential(s) for blueprint: {ObjectId}", + credentials.Count, blueprintObjectId); - return credentials; + return credentials; + } // end using (doc) } catch (Exception ex) { @@ -259,7 +266,8 @@ public async Task CreateFederatedCredentialAsyn tenantId, endpoint, payload, - cancellationToken); + cancellationToken, + scopes: [AuthenticationConstants.ApplicationReadWriteAllScope]); if (response.IsSuccess) { @@ -309,15 +317,40 @@ public async Task CreateFederatedCredentialAsyn }; } - // For other errors on first endpoint, try second endpoint - if (endpoint == endpoints[0]) + // For non-403 errors on first endpoint, try second endpoint + if (response.StatusCode != 403 && endpoint == endpoints[0]) { _logger.LogDebug("First endpoint failed with HTTP {StatusCode}, trying second endpoint...", response.StatusCode); continue; } - // Both endpoints failed — log one clean error + // For 403 on first endpoint, try second endpoint (different identity path may succeed) + if (response.StatusCode == 403 && endpoint == endpoints[0]) + { + _logger.LogDebug("First endpoint returned HTTP 403, trying alternative endpoint..."); + continue; + } + + // Both endpoints failed or single endpoint returned a non-retriable error var graphError = TryExtractGraphErrorMessage(response.Body); + + // 403 on second endpoint is a deterministic auth failure — do not retry + if (response.StatusCode == 403) + { + var errorDetail = graphError ?? "Insufficient privileges to complete the operation"; + _logger.LogError("Failed to create federated credential '{Name}': {ErrorMessage}", name, errorDetail); + _logger.LogError("The authenticated account does not have sufficient privileges for this operation."); + _logger.LogError("Ensure the account has Application Administrator or Cloud App Administrator role,"); + _logger.LogError("or that the user is an owner of the blueprint application in Entra ID."); + _logger.LogDebug("Federated credential error response body: {Body}", response.Body); + return new FederatedCredentialCreateResult + { + Success = false, + ErrorMessage = errorDetail, + ShouldRetry = false + }; + } + if (graphError != null) _logger.LogError("Failed to create federated credential '{Name}': {ErrorMessage}", name, graphError); else @@ -326,7 +359,8 @@ public async Task CreateFederatedCredentialAsyn return new FederatedCredentialCreateResult { Success = false, - ErrorMessage = $"HTTP {response.StatusCode}: {response.ReasonPhrase}" + ErrorMessage = $"HTTP {response.StatusCode}: {response.ReasonPhrase}", + ShouldRetry = false }; } @@ -367,14 +401,20 @@ public async Task DeleteFederatedCredentialAsync( _logger.LogDebug("Deleting federated credential: {CredentialId} from blueprint: {ObjectId}", credentialId, blueprintObjectId); + // Application.ReadWrite.All is the currently functional scope for FIC deletion. + // AddRemoveCreds.All is specified in the permissions reference but is not yet validated; + // restoring Application.ReadWrite.All to match the previously working state. + var ficScope = AuthenticationConstants.ApplicationReadWriteAllScope; + // Try the standard endpoint first var endpoint = $"/beta/applications/{blueprintObjectId}/federatedIdentityCredentials/{credentialId}"; - + var success = await _graphApiService.GraphDeleteAsync( tenantId, endpoint, cancellationToken, - treatNotFoundAsSuccess: true); + treatNotFoundAsSuccess: true, + scopes: [ficScope]); if (success) { @@ -385,12 +425,13 @@ public async Task DeleteFederatedCredentialAsync( // Try fallback endpoint for agent blueprint _logger.LogDebug("Standard endpoint failed, trying fallback endpoint for agent blueprint"); endpoint = $"/beta/applications/microsoft.graph.agentIdentityBlueprint/{blueprintObjectId}/federatedIdentityCredentials/{credentialId}"; - + success = await _graphApiService.GraphDeleteAsync( tenantId, endpoint, cancellationToken, - treatNotFoundAsSuccess: true); + treatNotFoundAsSuccess: true, + scopes: [ficScope]); if (success) { @@ -399,6 +440,8 @@ public async Task DeleteFederatedCredentialAsync( } _logger.LogWarning("Failed to delete federated credential using both endpoints: {CredentialId}", credentialId); + _logger.LogWarning("Federated credential deletion failed. This typically means the signed-in user is not the owner of the blueprint application."); + _logger.LogWarning("If you own the blueprint, re-run 'a365 cleanup'. Otherwise, remove it manually via Entra portal > App registrations > (blueprint app) > Certificates and secrets > Federated credentials."); return false; } catch (Exception ex) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs index 82d52547..c4dd688b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs @@ -3,6 +3,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; using Microsoft.Agents.A365.DevTools.Cli.Services.Internal; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -23,30 +24,39 @@ public class GraphApiService private readonly CommandExecutor _executor; private readonly HttpClient _httpClient; private readonly IMicrosoftGraphTokenProvider? _tokenProvider; + private string _graphBaseUrl; - // Azure CLI token cache to avoid spawning az subprocess for every Graph API call. - // Tokens acquired via 'az account get-access-token' are typically valid for 60+ minutes; - // we cache them for a shorter window so the CLI still picks up token refreshes promptly. - private string? _cachedAzCliToken; - private string? _cachedAzCliTenantId; - private DateTimeOffset _cachedAzCliTokenExpiry = DateTimeOffset.MinValue; - internal static readonly TimeSpan AzCliTokenCacheDuration = TimeSpan.FromMinutes(5); + // Token caching is handled at the process level by AzCliHelper.AcquireAzCliTokenAsync. + // All GraphApiService instances (and other services) share a single token per + // (resource, tenantId) pair — no per-instance cache needed. + + // Login hint resolved once per GraphApiService instance from 'az account show'. + // Used to direct MSAL/WAM to the correct Azure CLI identity, preventing the Windows + // account (WAM default) or a stale cached MSAL account from being used instead. + private string? _loginHint; + private bool _loginHintResolved; + + // Resolver delegate for the login hint. Defaults to AzCliHelper.ResolveLoginHintAsync; + // injectable via constructor so unit tests can bypass the real 'az account show' process. + private readonly Func> _loginHintResolver; - /// - /// Expiry time for the cached Azure CLI token. Internal for testing purposes. - /// - internal DateTimeOffset CachedAzCliTokenExpiry - { - get => _cachedAzCliTokenExpiry; - set => _cachedAzCliTokenExpiry = value; - } - /// /// Optional custom client app ID to use for authentication with Microsoft Graph PowerShell. /// When set, this will be passed to Connect-MgGraph -ClientId parameter. /// public string? CustomClientAppId { get; set; } + /// + /// Override the Microsoft Graph base URL for sovereign / government cloud tenants. + /// Defaults to (commercial cloud). + /// Set this after construction when the config is available (e.g. from Agent365Config.GraphBaseUrl). + /// + public string GraphBaseUrl + { + get => _graphBaseUrl; + set => _graphBaseUrl = string.IsNullOrWhiteSpace(value) ? GraphApiConstants.BaseUrl : value; + } + // Lightweight wrapper to surface HTTP status, reason and body to callers public record GraphResponse { @@ -57,47 +67,58 @@ public record GraphResponse public JsonDocument? Json { get; init; } } - // Allow injecting a custom HttpMessageHandler for unit testing - public GraphApiService(ILogger logger, CommandExecutor executor, HttpMessageHandler? handler = null, IMicrosoftGraphTokenProvider? tokenProvider = null) + // Allow injecting a custom HttpMessageHandler for unit testing. + // loginHintResolver: optional override for 'az account show' login-hint resolution. + // Pass () => Task.FromResult(null) in unit tests to skip the real az process. + public GraphApiService(ILogger logger, CommandExecutor executor, HttpMessageHandler? handler = null, IMicrosoftGraphTokenProvider? tokenProvider = null, Func>? loginHintResolver = null, string? graphBaseUrl = null) { _logger = logger; _executor = executor; _httpClient = handler != null ? new HttpClient(handler) : HttpClientFactory.CreateAuthenticatedClient(); _tokenProvider = tokenProvider; + _loginHintResolver = loginHintResolver ?? AzCliHelper.ResolveLoginHintAsync; + _graphBaseUrl = string.IsNullOrWhiteSpace(graphBaseUrl) ? GraphApiConstants.BaseUrl : graphBaseUrl; } // Parameterless constructor to ease test mocking/substitution frameworks which may // require creating proxy instances without providing constructor arguments. public GraphApiService() - : this(NullLogger.Instance, new CommandExecutor(NullLogger.Instance), null) + : this(NullLogger.Instance, new CommandExecutor(NullLogger.Instance), null, null, null) { } // Two-argument convenience constructor used by tests and callers that supply // a logger and an existing CommandExecutor (no custom handler). public GraphApiService(ILogger logger, CommandExecutor executor) - : this(logger ?? NullLogger.Instance, executor ?? throw new ArgumentNullException(nameof(executor)), null, null) + : this(logger ?? NullLogger.Instance, executor ?? throw new ArgumentNullException(nameof(executor)), null, null, null) { } /// /// Get access token for Microsoft Graph API using Azure CLI /// - public async Task GetGraphAccessTokenAsync(string tenantId, CancellationToken ct = default) + public virtual async Task GetGraphAccessTokenAsync(string tenantId, CancellationToken ct = default) { _logger.LogDebug("Acquiring Graph API access token for tenant {TenantId}", tenantId); - + try { - // Check if Azure CLI is authenticated - var accountCheck = await _executor.ExecuteAsync( - "az", - "account show", - captureOutput: true, - suppressErrorLogging: true, - cancellationToken: ct); + var resource = GraphApiConstants.GetResource(_graphBaseUrl); + + // Check the process-level cache first. AzCliHelper caches tokens per (resource, tenantId) + // for the process lifetime — avoids spawning duplicate 'az account get-access-token' + // subprocesses when multiple services request the same token. + var cachedToken = await AzCliHelper.AcquireAzCliTokenAsync(resource, tenantId); + if (!string.IsNullOrWhiteSpace(cachedToken)) + { + _logger.LogDebug("Graph API access token acquired from cache"); + return cachedToken; + } - if (!accountCheck.Success) + // Cache miss or az CLI not authenticated — check login state via the injectable resolver. + // Using _loginHintResolver (not the static helper directly) keeps the test seam consistent. + var loginHint = await _loginHintResolver(); + if (loginHint == null) { _logger.LogInformation("Azure CLI not authenticated. Initiating login..."); _logger.LogInformation("A browser window will open for authentication. Please check your taskbar or browser if you don't see it."); @@ -111,18 +132,26 @@ public GraphApiService(ILogger logger, CommandExecutor executor _logger.LogError("Azure CLI login failed"); return null; } + + // Bust the caches so the fresh login identity and token are picked up. + AzCliHelper.InvalidateLoginHintCache(); + AzCliHelper.InvalidateAzCliTokenCache(); } - // Get access token for Microsoft Graph + // Acquire the token — this goes through AzCliHelper so it is cached for all + // subsequent callers (including those that call AzCliHelper directly). var tokenResult = await _executor.ExecuteAsync( "az", - $"account get-access-token --resource https://graph.microsoft.com/ --tenant {tenantId} --query accessToken -o tsv", + $"account get-access-token --resource {resource} --tenant {tenantId} --query accessToken -o tsv", captureOutput: true, cancellationToken: ct); if (tokenResult.Success && !string.IsNullOrWhiteSpace(tokenResult.StandardOutput)) { var token = tokenResult.StandardOutput.Trim(); + // Warm the shared cache so other services that call AzCliHelper.AcquireAzCliTokenAsync + // directly receive this token without spawning another subprocess. + AzCliHelper.WarmAzCliTokenCache(resource, tenantId, token); _logger.LogDebug("Graph API access token acquired successfully"); return token; } @@ -134,38 +163,43 @@ public GraphApiService(ILogger logger, CommandExecutor executor errorOutput.Contains("expired", StringComparison.OrdinalIgnoreCase)) { _logger.LogWarning("Authentication session may have expired. Attempting fresh login..."); - + // Force logout and re-login _logger.LogInformation("Logging out of Azure CLI..."); await _executor.ExecuteAsync("az", "logout", suppressErrorLogging: true, cancellationToken: ct); - + _logger.LogInformation("Initiating fresh login..."); var freshLoginResult = await _executor.ExecuteAsync( "az", $"login --tenant {tenantId}", cancellationToken: ct); - + if (!freshLoginResult.Success) { _logger.LogError("Fresh login failed. Please manually run: az login --tenant {TenantId}", tenantId); return null; } - + + // Bust caches after re-authentication so stale tokens are not returned. + AzCliHelper.InvalidateLoginHintCache(); + AzCliHelper.InvalidateAzCliTokenCache(); + // Retry token acquisition _logger.LogInformation("Retrying token acquisition..."); var retryTokenResult = await _executor.ExecuteAsync( "az", - $"account get-access-token --resource https://graph.microsoft.com/ --tenant {tenantId} --query accessToken -o tsv", + $"account get-access-token --resource {resource} --tenant {tenantId} --query accessToken -o tsv", captureOutput: true, cancellationToken: ct); - + if (retryTokenResult.Success && !string.IsNullOrWhiteSpace(retryTokenResult.StandardOutput)) { var token = retryTokenResult.StandardOutput.Trim(); + AzCliHelper.WarmAzCliTokenCache(resource, tenantId, token); _logger.LogInformation("Graph API access token acquired successfully after re-authentication"); return token; } - + _logger.LogError("Failed to acquire token after re-authentication: {Error}", retryTokenResult.StandardError); return null; } @@ -210,7 +244,8 @@ private async Task EnsureGraphHeadersAsync(string tenantId, CancellationTo { // Use token provider with delegated scopes (interactive browser auth with caching) _logger.LogDebug("Acquiring Graph token with specific scopes via token provider: {Scopes}", string.Join(", ", scopes)); - token = await _tokenProvider.GetMgGraphAccessTokenAsync(tenantId, scopes, false, CustomClientAppId, ct); + var loginHint = await ResolveLoginHintAsync(); + token = await _tokenProvider.GetMgGraphAccessTokenAsync(tenantId, scopes, false, CustomClientAppId, ct, loginHint); if (string.IsNullOrWhiteSpace(token)) { @@ -228,37 +263,25 @@ private async Task EnsureGraphHeadersAsync(string tenantId, CancellationTo } else { - // Use Azure CLI token (default fallback for operations that don't need special scopes) - // Check if we have a cached token for this tenant that hasn't expired - if (_cachedAzCliToken != null - && string.Equals(_cachedAzCliTenantId, tenantId, StringComparison.OrdinalIgnoreCase) - && DateTimeOffset.UtcNow < _cachedAzCliTokenExpiry) - { - _logger.LogDebug("Using cached Azure CLI Graph token (expires in {Minutes:F1} minutes)", - (_cachedAzCliTokenExpiry - DateTimeOffset.UtcNow).TotalMinutes); - token = _cachedAzCliToken; - } - else + // Use the process-level token cache in AzCliHelper — shared across all service + // instances so a token acquired in any phase is reused by subsequent phases. + token = await AzCliHelper.AcquireAzCliTokenAsync(GraphApiConstants.GetResource(_graphBaseUrl), tenantId); + + if (string.IsNullOrWhiteSpace(token)) { - _logger.LogDebug("Acquiring Graph token via Azure CLI (no specific scopes required)"); + // Cache miss or az CLI not authenticated — run full auth + recovery flow. + _logger.LogDebug("Process-level token cache miss; running full auth flow for tenant {TenantId}", tenantId); token = await GetGraphAccessTokenAsync(tenantId, ct); if (string.IsNullOrWhiteSpace(token)) { - // Clear cache on failure to ensure clean state - _cachedAzCliToken = null; - _cachedAzCliTenantId = null; - _cachedAzCliTokenExpiry = DateTimeOffset.MinValue; - _logger.LogError("Failed to acquire Graph token via Azure CLI. Ensure 'az login' is completed."); return false; } - // Cache the token for subsequent calls within the same command execution - _cachedAzCliToken = token; - _cachedAzCliTenantId = tenantId; - _cachedAzCliTokenExpiry = DateTimeOffset.UtcNow.Add(AzCliTokenCacheDuration); - _logger.LogDebug("Cached Azure CLI Graph token for {Duration} minutes", AzCliTokenCacheDuration.TotalMinutes); + // Warm the process-level cache so subsequent callers (including other + // GraphApiService instances and services) skip the auth flow entirely. + AzCliHelper.WarmAzCliTokenCache(GraphApiConstants.GetResource(_graphBaseUrl), tenantId, token); } } @@ -274,6 +297,29 @@ private async Task EnsureGraphHeadersAsync(string tenantId, CancellationTo return true; } + /// + /// Returns the object ID of the currently signed-in user via GET /v1.0/me. + /// Replaces 'az ad signed-in-user show --query id -o tsv' (~30s) with a Graph HTTP call (~200ms). + /// Returns null if the call fails (caller should fall back to az CLI). + /// + public virtual async Task GetCurrentUserObjectIdAsync(string tenantId, CancellationToken ct = default) + { + using var doc = await GraphGetAsync(tenantId, "/v1.0/me?$select=id", ct); + if (doc == null) return null; + return doc.RootElement.TryGetProperty("id", out var idEl) ? idEl.GetString() : null; + } + + /// + /// Checks whether a service principal with the given object ID exists in the tenant. + /// Replaces 'az ad sp show --id {principalId}' (~30s) with a Graph HTTP call (~200ms). + /// Used for MSI propagation polling — returns true when the SP is visible in the tenant. + /// + public virtual async Task ServicePrincipalExistsAsync(string tenantId, string principalId, CancellationToken ct = default) + { + using var doc = await GraphGetAsync(tenantId, $"/v1.0/servicePrincipals/{principalId}?$select=id", ct); + return doc != null; + } + /// /// Executes a GET request to Microsoft Graph API. /// Virtual to allow mocking in unit tests using Moq. @@ -281,9 +327,7 @@ private async Task EnsureGraphHeadersAsync(string tenantId, CancellationTo public virtual async Task GraphGetAsync(string tenantId, string relativePath, CancellationToken ct = default, IEnumerable? scopes = null) { if (!await EnsureGraphHeadersAsync(tenantId, ct, scopes)) return null; - var url = relativePath.StartsWith("http", StringComparison.OrdinalIgnoreCase) - ? relativePath - : $"https://graph.microsoft.com{relativePath}"; + var url = GraphApiConstants.BuildUrl(_graphBaseUrl, relativePath); using var resp = await _httpClient.GetAsync(url, ct); if (!resp.IsSuccessStatusCode) { @@ -296,12 +340,52 @@ private async Task EnsureGraphHeadersAsync(string tenantId, CancellationTo return JsonDocument.Parse(json); } + /// + /// GET from Graph and always return HTTP response details (status, body, parsed JSON). + /// Use this instead of GraphGetAsync when the caller needs to distinguish auth failures + /// (401) from transient server errors (503, 429, network exceptions). + /// + public virtual async Task GraphGetWithResponseAsync(string tenantId, string relativePath, CancellationToken ct = default, IEnumerable? scopes = null) + { + if (!await EnsureGraphHeadersAsync(tenantId, ct, scopes)) + return new GraphResponse { IsSuccess = false, StatusCode = 0, ReasonPhrase = "NoAuth", Body = "Failed to acquire token" }; + + var url = GraphApiConstants.BuildUrl(_graphBaseUrl, relativePath); + + try + { + using var resp = await _httpClient.GetAsync(url, ct); + var body = await resp.Content.ReadAsStringAsync(ct); + + JsonDocument? json = null; + if (resp.IsSuccessStatusCode && !string.IsNullOrWhiteSpace(body)) + { + try { json = JsonDocument.Parse(body); } catch { /* ignore parse errors */ } + } + + if (!resp.IsSuccessStatusCode) + _logger.LogDebug("Graph GET {Url} failed {Code} {Reason}: {Body}", url, (int)resp.StatusCode, resp.ReasonPhrase, body); + + return new GraphResponse + { + IsSuccess = resp.IsSuccessStatusCode, + StatusCode = (int)resp.StatusCode, + ReasonPhrase = resp.ReasonPhrase ?? string.Empty, + Body = body ?? string.Empty, + Json = json + }; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Graph GET {Url} threw an exception", url); + return new GraphResponse { IsSuccess = false, StatusCode = 0, ReasonPhrase = ex.Message, Body = string.Empty }; + } + } + public virtual async Task GraphPostAsync(string tenantId, string relativePath, object payload, CancellationToken ct = default, IEnumerable? scopes = null) { if (!await EnsureGraphHeadersAsync(tenantId, ct, scopes)) return null; - var url = relativePath.StartsWith("http", StringComparison.OrdinalIgnoreCase) - ? relativePath - : $"https://graph.microsoft.com{relativePath}"; + var url = GraphApiConstants.BuildUrl(_graphBaseUrl, relativePath); var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); using var resp = await _httpClient.PostAsync(url, content, ct); var body = await resp.Content.ReadAsStringAsync(ct); @@ -309,9 +393,9 @@ private async Task EnsureGraphHeadersAsync(string tenantId, CancellationTo { var errorMessage = TryExtractGraphErrorMessage(body); if (errorMessage != null) - _logger.LogError("Graph POST {Url} failed: {ErrorMessage}", url, errorMessage); + _logger.LogWarning("Graph POST {Url} failed: {ErrorMessage}", url, errorMessage); else - _logger.LogError("Graph POST {Url} failed {Code} {Reason}", url, (int)resp.StatusCode, resp.ReasonPhrase); + _logger.LogWarning("Graph POST {Url} failed {Code} {Reason}", url, (int)resp.StatusCode, resp.ReasonPhrase); _logger.LogDebug("Graph POST response body: {Body}", body); return null; } @@ -329,9 +413,7 @@ public virtual async Task GraphPostWithResponseAsync(string tenan return new GraphResponse { IsSuccess = false, StatusCode = 0, ReasonPhrase = "NoAuth", Body = "Failed to acquire token" }; } - var url = relativePath.StartsWith("http", StringComparison.OrdinalIgnoreCase) - ? relativePath - : $"https://graph.microsoft.com{relativePath}"; + var url = GraphApiConstants.BuildUrl(_graphBaseUrl, relativePath); var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); using var resp = await _httpClient.PostAsync(url, content, ct); @@ -360,9 +442,7 @@ public virtual async Task GraphPostWithResponseAsync(string tenan public virtual async Task GraphPatchAsync(string tenantId, string relativePath, object payload, CancellationToken ct = default, IEnumerable? scopes = null) { if (!await EnsureGraphHeadersAsync(tenantId, ct, scopes)) return false; - var url = relativePath.StartsWith("http", StringComparison.OrdinalIgnoreCase) - ? relativePath - : $"https://graph.microsoft.com{relativePath}"; + var url = GraphApiConstants.BuildUrl(_graphBaseUrl, relativePath); var content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); using var request = new HttpRequestMessage(new HttpMethod("PATCH"), url) { Content = content }; using var resp = await _httpClient.SendAsync(request, ct); @@ -391,9 +471,7 @@ public async Task GraphDeleteAsync( { if (!await EnsureGraphHeadersAsync(tenantId, ct, scopes)) return false; - var url = relativePath.StartsWith("http", StringComparison.OrdinalIgnoreCase) - ? relativePath - : $"https://graph.microsoft.com{relativePath}"; + var url = GraphApiConstants.BuildUrl(_graphBaseUrl, relativePath); using var req = new HttpRequestMessage(HttpMethod.Delete, url); using var resp = await _httpClient.SendAsync(req, ct); @@ -488,20 +566,28 @@ public async Task CreateOrUpdateOauth2PermissionGrantAsync( { var desiredScopeString = string.Join(' ', scopes); - // Read existing - var listDoc = await GraphGetAsync( + // Read existing — extract string values immediately so JsonDocument can be disposed + string? existingId = null; + string existingScopes = ""; + + using (var listDoc = await GraphGetAsync( tenantId, $"/v1.0/oauth2PermissionGrants?$filter=clientId eq '{clientSpObjectId}' and resourceId eq '{resourceSpObjectId}'", ct, - permissionGrantScopes); - - var existing = listDoc?.RootElement.TryGetProperty("value", out var arr) == true && arr.GetArrayLength() > 0 - ? arr[0] - : (JsonElement?)null; + permissionGrantScopes)) + { + if (listDoc?.RootElement.TryGetProperty("value", out var arr) == true && arr.GetArrayLength() > 0) + { + var grant = arr[0]; + existingId = grant.TryGetProperty("id", out var idProp) ? idProp.GetString() : null; + existingScopes = grant.TryGetProperty("scope", out var scopeProp) ? scopeProp.GetString() ?? "" : ""; + } + } - if (existing is null) + if (string.IsNullOrWhiteSpace(existingId)) { - // Create + // AllPrincipals (tenant-wide) grants require Global Administrator. + // Only called from admin paths (setup admin or setup all run by GA). var payload = new { clientId = clientSpObjectId, @@ -509,13 +595,45 @@ public async Task CreateOrUpdateOauth2PermissionGrantAsync( resourceId = resourceSpObjectId, scope = desiredScopeString }; - var created = await GraphPostAsync(tenantId, "/v1.0/oauth2PermissionGrants", payload, ct, permissionGrantScopes); - return created != null; // success if response parsed + + _logger.LogDebug("Graph POST /v1.0/oauth2PermissionGrants body: {Body}", JsonSerializer.Serialize(payload)); + + // A freshly-created service principal may not yet be visible to the + // oauth2PermissionGrants replica (Directory_ObjectNotFound). Retry with + // exponential back-off so the command is self-healing without user intervention. + const int maxRetries = 8; + const int baseDelaySeconds = 5; + for (int attempt = 0; attempt < maxRetries; attempt++) + { + var grantResponse = await GraphPostWithResponseAsync(tenantId, "/v1.0/oauth2PermissionGrants", payload, ct, permissionGrantScopes); + // Dispose the error JSON immediately — only IsSuccess and Body are needed below. + grantResponse.Json?.Dispose(); + + if (grantResponse.IsSuccess) + return true; + + if (!grantResponse.Body.Contains("Directory_ObjectNotFound", StringComparison.OrdinalIgnoreCase)) + return false; // non-transient error, do not retry + + if (attempt < maxRetries - 1) + { + var delaySecs = (int)Math.Min(baseDelaySeconds * Math.Pow(2, attempt), 60); + _logger.LogWarning( + "Service principal not yet replicated to grants endpoint — retrying in {Delay}s (attempt {Attempt}/{Max})...", + delaySecs, attempt + 1, maxRetries - 1); + await Task.Delay(TimeSpan.FromSeconds(delaySecs), ct); + } + } + + _logger.LogWarning( + "OAuth2 permission grant failed after {MaxRetries} retries — service principal may still be propagating. " + + "Re-run 'a365 setup admin' to retry.", + maxRetries); + return false; } // Merge scopes if needed - var current = existing.Value.TryGetProperty("scope", out var s) ? s.GetString() ?? "" : ""; - var currentSet = new HashSet(current.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase); + var currentSet = new HashSet(existingScopes.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase); var desiredSet = new HashSet(desiredScopeString.Split(' ', StringSplitOptions.RemoveEmptyEntries), StringComparer.OrdinalIgnoreCase); if (desiredSet.IsSubsetOf(currentSet)) return true; // already satisfied @@ -523,10 +641,7 @@ public async Task CreateOrUpdateOauth2PermissionGrantAsync( currentSet.UnionWith(desiredSet); var merged = string.Join(' ', currentSet); - var id = existing.Value.GetProperty("id").GetString(); - if (string.IsNullOrWhiteSpace(id)) return false; - - return await GraphPatchAsync(tenantId, $"/v1.0/oauth2PermissionGrants/{id}", new { scope = merged }, ct, permissionGrantScopes); + return await GraphPatchAsync(tenantId, $"/v1.0/oauth2PermissionGrants/{existingId}", new { scope = merged }, ct, permissionGrantScopes); } /// @@ -555,10 +670,10 @@ public async Task CreateOrUpdateOauth2PermissionGrantAsync( token = token.Trim(); using var request = new HttpRequestMessage(HttpMethod.Get, - "https://graph.microsoft.com/v1.0/me/memberOf/microsoft.graph.directoryRole"); + $"{_graphBaseUrl}/v1.0/me/memberOf/microsoft.graph.directoryRole"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - var response = await _httpClient.SendAsync(request, ct); + using var response = await _httpClient.SendAsync(request, ct); if (!response.IsSuccessStatusCode) { _logger.LogWarning("Could not retrieve user's directory roles: {Status}", response.StatusCode); @@ -638,7 +753,7 @@ public virtual async Task IsApplicationOwnerAsync( } using var meRequest = new HttpRequestMessage(HttpMethod.Get, - "https://graph.microsoft.com/v1.0/me?$select=id"); + $"{_graphBaseUrl}/v1.0/me?$select=id"); meRequest.Headers.Authorization = _httpClient.DefaultRequestHeaders.Authorization; using var meResponse = await _httpClient.SendAsync(meRequest, ct); @@ -688,6 +803,103 @@ public virtual async Task IsApplicationOwnerAsync( } } + /// + /// Checks whether the currently signed-in user holds the Global Administrator role, + /// which is required to grant tenant-wide admin consent interactively. + /// Uses only — works for both admin and non-admin users. + /// Returns (non-blocking) if the check cannot be completed. + /// + public virtual async Task IsCurrentUserAdminAsync( + string tenantId, + CancellationToken ct = default) + { + return await CheckDirectoryRoleAsync(tenantId, AuthenticationConstants.GlobalAdminRoleTemplateId, ct); + } + + /// + /// Checks whether the currently signed-in user holds the Agent ID Administrator role, + /// which is required to create or update inheritable permissions on agent blueprints. + /// Uses only — works for both admin and non-admin users. + /// Returns (non-blocking) if the check cannot be completed. + /// + public virtual async Task IsCurrentUserAgentIdAdminAsync( + string tenantId, + CancellationToken ct = default) + { + return await CheckDirectoryRoleAsync(tenantId, AuthenticationConstants.AgentIdAdminRoleTemplateId, ct); + } + + /// + /// Returns if the role is confirmed active, + /// if confirmed absent, or + /// if the check itself failed (e.g. network error, + /// throttling, auth failure) — in which case the caller should attempt the operation + /// anyway and let the API surface the real error. + /// Queries /me/transitiveMemberOf/microsoft.graph.directoryRole, which requires only + /// User.Read and succeeds for both admin and non-admin users. + /// Note: PIM-eligible-but-not-activated assignments are not considered active. + /// + private async Task CheckDirectoryRoleAsync(string tenantId, string roleTemplateId, CancellationToken ct) + { + try + { + // /me/transitiveMemberOf is a directory query — Directory.Read.All is required. + // User.Read is insufficient and would return Unknown for most users. + IEnumerable? scopes = _tokenProvider != null + ? [AuthenticationConstants.DirectoryReadAllScope] + : null; + + string? nextUrl = "/v1.0/me/transitiveMemberOf/microsoft.graph.directoryRole?$select=roleTemplateId"; + + while (nextUrl != null) + { + using var doc = await GraphGetAsync(tenantId, nextUrl, ct, scopes); + + if (doc == null) + return Models.RoleCheckResult.Unknown; + + if (!doc.RootElement.TryGetProperty("value", out var roles)) + { + _logger.LogWarning("Unexpected Graph response shape — 'value' property missing from transitiveMemberOf response."); + return Models.RoleCheckResult.Unknown; + } + + if (roles.EnumerateArray().Any(r => + r.TryGetProperty("roleTemplateId", out var id) && + string.Equals(id.GetString(), roleTemplateId, StringComparison.OrdinalIgnoreCase))) + return Models.RoleCheckResult.HasRole; + + nextUrl = doc.RootElement.TryGetProperty("@odata.nextLink", out var nextLink) + ? nextLink.GetString() + : null; + } + + return Models.RoleCheckResult.DoesNotHaveRole; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Role check for {TemplateId} failed — will attempt operation anyway: {Message}", + roleTemplateId, ex.Message); + return Models.RoleCheckResult.Unknown; + } + } + + /// + /// Resolves the Azure CLI login hint once per instance from 'az account show'. + /// The hint is passed to MSAL so that WAM and silent auth target the correct + /// Azure CLI identity instead of the Windows default account. + /// Returns null if az account show fails or the user field is absent. + /// + private async Task ResolveLoginHintAsync() + { + if (_loginHintResolved) + return _loginHint; + + _loginHintResolved = true; + _loginHint = await _loginHintResolver(); + return _loginHint; + } + /// /// Attempts to extract a human-readable error message from a Graph API JSON error response body. /// Returns null if the body cannot be parsed or does not contain an error message. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/AzCliHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/AzCliHelper.cs new file mode 100644 index 00000000..f1f93439 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/AzCliHelper.cs @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.Json; + +namespace Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; + +/// +/// Shared helper for invoking the Azure CLI and parsing its output. +/// Consolidates az CLI interactions to ensure consistent behavior across services. +/// +internal static class AzCliHelper +{ + // Process-level cache: 'az account show' returns the same user for the lifetime of a CLI + // invocation. Caching eliminates repeated 20-40s subprocess calls that occur when multiple + // services and commands each call ResolveLoginHintAsync independently. + private static volatile Task? _cachedLoginHintTask; + + // Test seam: replace the underlying resolver without touching the cache layer. + // Null in production. Tests set this to avoid spawning a real 'az' subprocess. + internal static Func>? LoginHintResolverOverride { get; set; } + + /// + /// Resolves the currently signed-in Azure CLI user from 'az account show'. + /// The result is cached for the process lifetime — the active account cannot change + /// mid-execution of a single CLI command. Returns null if unavailable (non-fatal). + /// + internal static Task ResolveLoginHintAsync() + => _cachedLoginHintTask ??= (LoginHintResolverOverride ?? ResolveLoginHintCoreAsync)(); + + /// + /// Clears the login-hint process-level cache after a fresh 'az login'. + /// Forces the next call to ResolveLoginHintAsync to re-run 'az account show'. + /// + internal static void InvalidateLoginHintCache() => _cachedLoginHintTask = null; + + /// Clears the login-hint process-level cache. For use in tests only. + internal static void ResetLoginHintCacheForTesting() => _cachedLoginHintTask = null; + + // ------------------------------------------------------------------------- + // az account get-access-token — process-level token cache + // ------------------------------------------------------------------------- + // Tokens acquired via 'az account get-access-token' are valid for 60+ minutes. + // Caching at the process level means a single CLI invocation only spawns one + // subprocess per (resource, tenantId) pair, regardless of how many services or + // commands request the same token. Expected savings: 40–60s per command run. + + private static readonly ConcurrentDictionary> _azCliTokenCache = new(); + + // Test seam: replace the underlying acquirer without touching the cache layer. + // The override is invoked inside GetOrAdd, so the result is still cached after + // the first call — only one invocation per cache key, even in tests. + internal static Func>? AzCliTokenAcquirerOverride { get; set; } + + /// + /// Acquires an Azure CLI access token for the given resource and tenant. + /// The result is cached for the process lifetime — a single CLI command cannot + /// invalidate a token except through explicit re-authentication (az login). + /// Call after 'az login' to bust the cache. + /// + internal static Task AcquireAzCliTokenAsync(string resource, string tenantId = "", CancellationToken ct = default) + { + var key = $"{resource}::{tenantId}"; + return _azCliTokenCache.GetOrAdd(key, _ => + AzCliTokenAcquirerOverride != null + ? AzCliTokenAcquirerOverride(resource, tenantId) + : AcquireAzCliTokenCoreAsync(resource, tenantId, ct)); + } + + /// + /// Injects a token acquired via an alternative auth flow (e.g., after 'az login' recovery) + /// so that subsequent callers across all services receive the fresh token from cache. + /// + internal static void WarmAzCliTokenCache(string resource, string tenantId, string token) + { + var key = $"{resource}::{tenantId}"; + _azCliTokenCache[key] = Task.FromResult(token); + } + + /// + /// Clears the token cache. Call after 'az login' or 'az logout' to ensure + /// subsequent callers acquire a fresh token rather than a now-invalid cached one. + /// + internal static void InvalidateAzCliTokenCache() => _azCliTokenCache.Clear(); + + /// Clears the token cache. For use in tests only. + internal static void ResetAzCliTokenCacheForTesting() => _azCliTokenCache.Clear(); + + private static async Task AcquireAzCliTokenCoreAsync(string resource, string tenantId, CancellationToken ct = default) + { + Process? process = null; + try + { + // On Windows az is az.cmd which requires cmd.exe to launch. On all platforms + // arguments are passed via ArgumentList (not string interpolation) so + // resource/tenantId values cannot alter the command line. + var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + var startInfo = new ProcessStartInfo + { + FileName = isWindows ? "cmd.exe" : "az", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + // On Windows: cmd.exe /c az . On other platforms: az . + if (isWindows) + { + startInfo.ArgumentList.Add("/c"); + startInfo.ArgumentList.Add("az"); + } + startInfo.ArgumentList.Add("account"); + startInfo.ArgumentList.Add("get-access-token"); + startInfo.ArgumentList.Add("--resource"); + startInfo.ArgumentList.Add(resource); + if (!string.IsNullOrEmpty(tenantId)) + { + startInfo.ArgumentList.Add("--tenant"); + startInfo.ArgumentList.Add(tenantId); + } + startInfo.ArgumentList.Add("--query"); + startInfo.ArgumentList.Add("accessToken"); + startInfo.ArgumentList.Add("-o"); + startInfo.ArgumentList.Add("tsv"); + + process = Process.Start(startInfo); + if (process == null) return null; + // Start reads concurrently so the pipe buffers never fill up and block the process. + // WaitForExitAsync(ct) is awaited first so cancellation is observed immediately; + // the reads complete naturally once the process exits and the pipes close. + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(ct); + await Task.WhenAll(outputTask, errorTask); + var output = outputTask.Result.Trim(); + return process.ExitCode == 0 && !string.IsNullOrWhiteSpace(output) ? output : null; + } + catch (OperationCanceledException) + { + try { process?.Kill(entireProcessTree: true); } catch { } + throw; + } + catch + { + return null; + } + finally + { + process?.Dispose(); + } + } + + private static async Task ResolveLoginHintCoreAsync() + { + try + { + // On Windows az is az.cmd and requires cmd.exe. Arguments are passed via + // ArgumentList rather than string interpolation to avoid shell-injection risk. + var isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + var startInfo = new ProcessStartInfo + { + FileName = isWindows ? "cmd.exe" : "az", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + if (isWindows) + { + startInfo.ArgumentList.Add("/c"); + startInfo.ArgumentList.Add("az"); + } + startInfo.ArgumentList.Add("account"); + startInfo.ArgumentList.Add("show"); + + using var process = Process.Start(startInfo); + if (process == null) return null; + // Read stdout and stderr concurrently to prevent the process from blocking + // when either pipe's buffer fills up before WaitForExitAsync is called. + var outputTask = process.StandardOutput.ReadToEndAsync(); + var errorTask = process.StandardError.ReadToEndAsync(); + await Task.WhenAll(outputTask, errorTask); + await process.WaitForExitAsync(); + var output = outputTask.Result; + if (process.ExitCode == 0 && !string.IsNullOrWhiteSpace(output)) + { + var cleaned = JsonDeserializationHelper.CleanAzureCliJsonOutput(output); + var json = JsonSerializer.Deserialize(cleaned); + if (json.TryGetProperty("user", out var user) && + user.TryGetProperty("name", out var name)) + return name.GetString(); + } + } + catch (OperationCanceledException) + { + throw; + } + catch { } + return null; + } +} 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 0b96915f..dc27b8c7 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/CleanConsoleFormatter.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/CleanConsoleFormatter.cs @@ -33,7 +33,7 @@ public override void Write( TextWriter textWriter) { var message = logEntry.Formatter?.Invoke(logEntry.State, logEntry.Exception); - if (string.IsNullOrEmpty(message)) + if (message == null) { return; } @@ -41,6 +41,19 @@ public override void Write( // Check if we're writing to actual console (supports colors) bool isConsole = !Console.IsOutputRedirected; + // Allow empty strings as intentional blank lines for visual spacing. + // Must use Console.WriteLine (not textWriter) when on a real console so the blank line + // is written to the same stream as non-empty messages — mixing the two causes buffering + // ordering issues where the blank line appears after the next message instead of before it. + if (message.Length == 0) + { + if (isConsole) + Console.WriteLine(); + else + textWriter.WriteLine(); + return; + } + // Azure CLI pattern: red for errors, yellow for warnings, dark gray for debug/trace, no color for info switch (logEntry.LogLevel) { @@ -104,29 +117,21 @@ public override void Write( } break; default: // Information - textWriter.WriteLine(message); + if (isConsole) + { + Console.ResetColor(); + Console.WriteLine(message); + } + else + { + textWriter.WriteLine(message); + } break; } - // If there's an exception, include it (for debugging) - if (logEntry.Exception != null) - { - if (isConsole) - { - Console.ForegroundColor = logEntry.LogLevel switch - { - LogLevel.Error or LogLevel.Critical => ConsoleColor.Red, - LogLevel.Warning => ConsoleColor.Yellow, - LogLevel.Debug or LogLevel.Trace => ConsoleColor.DarkGray, - _ => Console.ForegroundColor - }; - Console.WriteLine(logEntry.Exception); - Console.ResetColor(); - } - else - { - textWriter.WriteLine(logEntry.Exception); - } - } + // Exception details (stack traces) are intentionally suppressed from console output. + // The file logger captures the full exception for diagnostics. Showing stack traces + // on the console is noise for end users and was the root cause of call stacks appearing + // in CLI output whenever any logger call included an exception parameter. } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/DotNetProjectHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/DotNetProjectHelper.cs index a314d23c..0587b524 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/DotNetProjectHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/DotNetProjectHelper.cs @@ -71,7 +71,7 @@ public static class DotNetProjectHelper } var version = $"{verMatch.Groups[1].Value}.{verMatch.Groups[2].Value}"; - logger.LogInformation( + logger.LogDebug( "Detected TargetFramework: {Tfm} → .NET {Version}", tfm, version); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/RetryHelper.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/RetryHelper.cs index abbfabc8..9979790b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/RetryHelper.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/RetryHelper.cs @@ -66,6 +66,9 @@ public async Task ExecuteWithRetryAsync( } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) { + if (ex is TaskCanceledException && cancellationToken.IsCancellationRequested) + throw; + lastException = ex; _logger.LogWarning("Exception: {Message}", ex.Message); @@ -124,6 +127,90 @@ public async Task ExecuteWithRetryAsync( cancellationToken); } + /// + /// Execute an async operation with retry logic and exponential backoff. + /// Use this overload when the retry decision requires an async operation (e.g. reading an + /// HTTP response body) that cannot be performed inside a synchronous predicate. + /// + /// Return type of the operation + /// The async operation to execute. Receives a cancellation token and returns a result. + /// Async predicate that determines if retry is needed. Returns TRUE when the operation should be retried, FALSE when done. + /// Maximum number of retry attempts before giving up (default: 5) + /// Base delay in seconds for exponential backoff calculation (default: 2). + /// Cancellation token to cancel the operation + /// Result of the operation when shouldRetryAsync returns false (success), or the last result after all retries are exhausted. + public async Task ExecuteWithRetryAsync( + Func> operation, + Func> shouldRetryAsync, + int maxRetries = 5, + int baseDelaySeconds = 2, + CancellationToken cancellationToken = default) + { + int attempt = 0; + Exception? lastException = null; + T? lastResult = default; + + while (attempt < maxRetries) + { + try + { + lastResult = await operation(cancellationToken); + + if (!await shouldRetryAsync(lastResult, cancellationToken)) + { + return lastResult; + } + + if (attempt < maxRetries - 1) + { + var delay = CalculateDelay(attempt, baseDelaySeconds); + _logger.LogInformation( + "Retry attempt {AttemptNumber} of {MaxRetries}. Waiting {DelaySeconds} seconds...", + attempt + 1, maxRetries, (int)delay.TotalSeconds); + + await Task.Delay(delay, cancellationToken); + } + + attempt++; + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + if (ex is TaskCanceledException && cancellationToken.IsCancellationRequested) + throw; + + lastException = ex; + _logger.LogWarning("Exception: {Message}", ex.Message); + + if (attempt < maxRetries - 1) + { + var delay = CalculateDelay(attempt, baseDelaySeconds); + _logger.LogInformation( + "Retry attempt {AttemptNumber} of {MaxRetries}. Waiting {DelaySeconds} seconds...", + attempt + 1, maxRetries, (int)delay.TotalSeconds); + + await Task.Delay(delay, cancellationToken); + } + + attempt++; + } + } + + if (lastException != null) + { + throw lastException; + } + + if (lastResult is null) + { + throw new RetryExhaustedException( + "Async operation with retry", + maxRetries, + "Operation did not return a value and no exception was thrown"); + } + + return lastResult; + } + private static TimeSpan CalculateDelay(int attemptNumber, int baseDelaySeconds) { var exponentialDelay = baseDelaySeconds * Math.Pow(2, attemptNumber); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IClientAppValidator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IClientAppValidator.cs index 937ee887..199f0996 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IClientAppValidator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IClientAppValidator.cs @@ -24,7 +24,7 @@ public interface IClientAppValidator /// Automatically adds missing redirect URIs if needed. /// /// The client app ID - /// Microsoft Graph access token + /// The tenant ID /// Cancellation token - Task EnsureRedirectUrisAsync(string clientAppId, string graphToken, CancellationToken ct = default); + Task EnsureRedirectUrisAsync(string clientAppId, string tenantId, CancellationToken ct = default); } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs index b283f175..a7dff190 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/InteractiveGraphAuthService.cs @@ -4,6 +4,7 @@ using Azure.Core; using Microsoft.Agents.A365.DevTools.Cli.Constants; using Microsoft.Agents.A365.DevTools.Cli.Exceptions; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; using Microsoft.Extensions.Logging; using Microsoft.Graph; using Microsoft.Identity.Client; @@ -26,6 +27,7 @@ public sealed class InteractiveGraphAuthService private readonly ILogger _logger; private readonly string _clientAppId; private readonly Func? _credentialFactory; + private readonly Func> _loginHintResolver; private GraphServiceClient? _cachedClient; private string? _cachedTenantId; @@ -41,7 +43,8 @@ public sealed class InteractiveGraphAuthService public InteractiveGraphAuthService( ILogger logger, string clientAppId, - Func? credentialFactory = null) + Func? credentialFactory = null, + Func>? loginHintResolver = null) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); @@ -61,6 +64,7 @@ public InteractiveGraphAuthService( _clientAppId = clientAppId; _credentialFactory = credentialFactory; + _loginHintResolver = loginHintResolver ?? ResolveAzLoginHintAsync; } /// @@ -82,18 +86,7 @@ public async Task GetAuthenticatedGraphClientAsync( return _cachedClient; } - _logger.LogInformation("Attempting to authenticate to Microsoft Graph interactively..."); - _logger.LogInformation("This requires permissions defined in AuthenticationConstants.RequiredClientAppPermissions for Agent Blueprint operations."); - _logger.LogInformation(""); - _logger.LogInformation("IMPORTANT: Interactive authentication is required."); - _logger.LogInformation("Please sign in with an account that has Global Administrator or similar privileges."); - _logger.LogInformation(""); - _logger.LogInformation("Authenticating to Microsoft Graph..."); - _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(""); // Eagerly acquire a token so authentication failures are detected here rather than // surfacing later from inside GraphServiceClient's lazy token acquisition. @@ -102,9 +95,12 @@ public async Task GetAuthenticatedGraphClientAsync( TokenCredential? credential = null; try { + // Resolve the current az CLI user so MSAL/WAM targets the correct identity. + var loginHint = await _loginHintResolver(); + // Resolve credential: use injected factory (for tests) or default MsalBrowserCredential credential = _credentialFactory?.Invoke(_clientAppId, tenantId) - ?? new MsalBrowserCredential(_clientAppId, tenantId, redirectUri: null, _logger); + ?? new MsalBrowserCredential(_clientAppId, tenantId, redirectUri: null, _logger, loginHint: loginHint); await credential.GetTokenAsync(tokenContext, cancellationToken); } @@ -162,4 +158,13 @@ private void ThrowInsufficientPermissionsException(Exception innerException) "Insufficient permissions - you must be a Global Administrator or have all required permissions defined in AuthenticationConstants.RequiredClientAppPermissions", isPermissionIssue: true); } + + /// + /// Resolves the current Azure CLI user UPN from 'az account show'. + /// Used as a login hint for MSAL/WAM so the correct identity is selected + /// instead of the default OS-level Windows account. + /// Returns null if az CLI is unavailable or the user field is absent (non-fatal). + /// + internal static Task ResolveAzLoginHintAsync() + => AzCliHelper.ResolveLoginHintAsync(); } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/IMicrosoftGraphTokenProvider.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/IMicrosoftGraphTokenProvider.cs index 45a89d67..e64c711e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/IMicrosoftGraphTokenProvider.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/IMicrosoftGraphTokenProvider.cs @@ -13,11 +13,15 @@ public interface IMicrosoftGraphTokenProvider /// If true, uses device code flow (CLI-friendly). If false, uses interactive browser flow. /// Optional client app ID to use for authentication. If not provided, uses default Microsoft Graph PowerShell app. /// Cancellation token. + /// Optional UPN/email of the expected user. When provided, MSAL uses this identity for + /// both silent cache lookup and interactive auth (WAM/browser), preventing stale cached tokens from a + /// different user contaminating this session. /// The access token, or null if acquisition fails. Task GetMgGraphAccessTokenAsync( string tenantId, IEnumerable scopes, - bool useDeviceCode = true, + bool useDeviceCode = false, string? clientAppId = null, - CancellationToken ct = default); + CancellationToken ct = default, + string? loginHint = null); } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs index 210ebd00..89456b4f 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Internal/MicrosoftGraphTokenProvider.cs @@ -18,17 +18,22 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services; /// -/// Implements Microsoft Graph token acquisition via PowerShell Microsoft.Graph module. +/// Implements Microsoft Graph token acquisition via MSAL.NET (primary) with PowerShell fallback. /// /// AUTHENTICATION METHOD: -/// - Uses Connect-MgGraph (PowerShell) for Graph API authentication -/// - Default: Interactive browser authentication (useDeviceCode=false) -/// - Device Code Flow: Available but NOT used by default (DCF discouraged in production) +/// - Primary: MSAL.NET with WAM on Windows (native broker, no browser, CAP-compliant), +/// system browser on macOS, device code on Linux +/// - Fallback: PowerShell Connect-MgGraph (used when MSAL is unavailable, e.g. no clientAppId) +/// +/// WHY MSAL PRIMARY: +/// - WAM authenticates via the OS broker — no browser popup, works on corporate tenants +/// with Conditional Access Policies that block browser-based auth +/// - Token cache is keyed by user identity (HomeAccountId) — prevents cross-user token +/// contamination on shared machines /// /// TOKEN CACHING: /// - In-memory cache per CLI process: Tokens cached by (tenant + clientId + scopes) -/// - Persistent cache: PowerShell module manages its own session cache -/// - Reduces repeated Connect-MgGraph prompts during multi-step operations +/// - MSAL persistent cache: DPAPI on Windows, Keychain on macOS, in-memory on Linux /// /// USAGE: /// - Called by GraphApiService when specific scopes are required @@ -40,9 +45,13 @@ public sealed class MicrosoftGraphTokenProvider : IMicrosoftGraphTokenProvider, private readonly ILogger _logger; // Cache tokens per (tenant + clientId + scopes) for the lifetime of this CLI process. - // This reduces repeated Connect-MgGraph prompts in setup flows. + // This reduces repeated auth prompts during multi-step setup flows. private readonly ConcurrentDictionary _tokenCache = new(); private readonly ConcurrentDictionary _locks = new(); + + // Test seam: override MSAL token acquisition in unit tests without requiring WAM/browser. + // Null in production; set by tests to return controlled token values. + internal Func>? MsalTokenAcquirerOverride { get; set; } private sealed record CachedToken(string AccessToken, DateTimeOffset ExpiresOnUtc); @@ -81,7 +90,8 @@ public MicrosoftGraphTokenProvider( IEnumerable scopes, bool useDeviceCode = false, string? clientAppId = null, - CancellationToken ct = default) + CancellationToken ct = default, + string? loginHint = null) { var validatedScopes = ValidateAndPrepareScopes(scopes); ValidateTenantId(tenantId); @@ -91,7 +101,7 @@ public MicrosoftGraphTokenProvider( ValidateClientAppId(clientAppId); } - var cacheKey = MakeCacheKey(tenantId, validatedScopes, clientAppId); + var cacheKey = MakeCacheKey(tenantId, validatedScopes, clientAppId, loginHint); var tokenExpirationMinutes = AuthenticationConstants.TokenExpirationBufferMinutes; // Fast path: cached + not expiring soon @@ -119,27 +129,30 @@ public MicrosoftGraphTokenProvider( return cached.AccessToken; } - _logger.LogInformation("Acquiring Microsoft Graph delegated access token via PowerShell..."); + _logger.LogDebug("Acquiring Microsoft Graph delegated access token..."); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - _logger.LogInformation("A browser window will open for authentication. Complete sign-in, then return here — the CLI will continue automatically."); + _logger.LogDebug("A Windows authentication dialog may appear. Complete sign-in, then return here — the CLI will continue automatically."); } else { - _logger.LogInformation("A device code prompt will appear below. Open the URL in any browser, enter the code, complete sign-in, then return here — the CLI will continue automatically."); + _logger.LogDebug("A browser window or device code prompt may appear. Complete sign-in, then return here — the CLI will continue automatically."); } - var script = BuildPowerShellScript(tenantId, validatedScopes, useDeviceCode, clientAppId); - var result = await ExecuteWithFallbackAsync(script, ct); - var token = ProcessResult(result); + // MSAL/WAM is primary: user-identity-aware cache prevents cross-user token contamination, + // and WAM on Windows authenticates via the OS broker (no browser, CAP-compliant). + var token = MsalTokenAcquirerOverride != null + ? await MsalTokenAcquirerOverride(tenantId, validatedScopes, clientAppId, ct) + : await AcquireGraphTokenViaMsalAsync(tenantId, validatedScopes, clientAppId, ct, loginHint); - // If PS Connect-MgGraph fails for any reason (no TTY on Linux, NullRef in DeviceCodeCredential, - // module issues, etc.), fall back to MSAL. On Windows this uses WAM; on Linux/macOS it uses - // device code. The acquired token is stored in _tokenCache below so subsequent calls - // (inheritable permissions, custom permissions) hit the cache without re-prompting. + // Fall back to PowerShell Connect-MgGraph if MSAL is unavailable (e.g. no clientAppId) + // or fails for any reason. if (string.IsNullOrWhiteSpace(token)) { - token = await AcquireGraphTokenViaMsalAsync(tenantId, validatedScopes, clientAppId, ct); + _logger.LogDebug("MSAL token acquisition failed, falling back to PowerShell Connect-MgGraph..."); + var script = BuildPowerShellScript(tenantId, validatedScopes, useDeviceCode, clientAppId); + var result = await ExecuteWithFallbackAsync(script, ct); + token = ProcessResult(result); } if (string.IsNullOrWhiteSpace(token)) @@ -275,20 +288,22 @@ private async Task ExecuteWithFallbackAsync( } /// - /// Acquires a Microsoft Graph access token via MSAL as a fallback when PowerShell - /// Connect-MgGraph fails for any reason. On Windows uses WAM; on Linux/macOS uses device code. - /// Uses MsalBrowserCredential which shares the static in-process token cache, so a token - /// acquired here is reused silently on subsequent calls within the same CLI invocation. + /// Acquires a Microsoft Graph access token via MSAL.NET (primary authentication path). + /// On Windows uses WAM (no browser, CAP-compliant); on Linux/macOS uses device code. + /// Uses MsalBrowserCredential whose token cache is keyed by user identity, preventing + /// cross-user token contamination on shared machines. + /// Returns null if clientAppId is unavailable; caller falls back to PowerShell Connect-MgGraph. /// private async Task AcquireGraphTokenViaMsalAsync( string tenantId, string[] scopes, string? clientAppId, - CancellationToken ct) + CancellationToken ct, + string? loginHint = null) { if (string.IsNullOrWhiteSpace(clientAppId)) { - _logger.LogWarning("MSAL Graph fallback skipped: no client app ID available. Ensure ClientAppId is set in a365.config.json."); + _logger.LogDebug("MSAL token acquisition skipped: no client app ID configured. Falling back to PowerShell Connect-MgGraph."); return null; } @@ -301,18 +316,19 @@ private async Task ExecuteWithFallbackAsync( _logger.LogDebug("Acquiring Graph token via MSAL for scopes: {Scopes}", string.Join(", ", fullScopes)); - var msalCredential = new MsalBrowserCredential(clientAppId, tenantId, logger: _logger); + var msalCredential = new MsalBrowserCredential(clientAppId, tenantId, logger: _logger, loginHint: loginHint); var tokenResult = await msalCredential.GetTokenAsync(new TokenRequestContext(fullScopes), ct); if (string.IsNullOrWhiteSpace(tokenResult.Token)) return null; - _logger.LogInformation("Microsoft Graph access token acquired via MSAL fallback."); + _logger.LogDebug("Microsoft Graph access token acquired successfully."); return tokenResult.Token; } catch (Exception ex) { - _logger.LogWarning(ex, "MSAL Graph token fallback failed: {Message}", ex.Message); + _logger.LogDebug(ex, "MSAL Graph token acquisition failed"); + _logger.LogWarning("MSAL Graph token acquisition failed: {Message}", ex.Message); return null; } } @@ -456,7 +472,7 @@ private static string BuildPowerShellArguments(string shell, string script) _logger.LogWarning("Returned token does not appear to be a valid JWT"); } - _logger.LogInformation("Microsoft Graph access token acquired successfully"); + _logger.LogDebug("Microsoft Graph access token acquired successfully"); return token; } @@ -491,7 +507,7 @@ private static bool IsValidJwtFormat(string token) token.Count(c => c == '.') == 2; } - private static string MakeCacheKey(string tenantId, IEnumerable scopes, string? clientAppId) + private static string MakeCacheKey(string tenantId, IEnumerable scopes, string? clientAppId, string? loginHint = null) { var scopeKey = string.Join(" ", scopes .Where(s => !string.IsNullOrWhiteSpace(s)) @@ -499,7 +515,7 @@ private static string MakeCacheKey(string tenantId, IEnumerable scopes, .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(s => s, StringComparer.OrdinalIgnoreCase)); - return $"{tenantId}::{clientAppId ?? ""}::{scopeKey}"; + return $"{tenantId}::{clientAppId ?? ""}::{scopeKey}::{loginHint ?? ""}"; } private bool TryGetJwtExpiryUtc(string jwt, out DateTimeOffset expiresOnUtc) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs index 1beedb23..6fe06eff 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/MsalBrowserCredential.cs @@ -40,6 +40,7 @@ public sealed class MsalBrowserCredential : TokenCredential private readonly string _tenantId; private readonly bool _useWam; private readonly IntPtr _windowHandle; + private readonly string? _loginHint; // Shared persistent cache helper - initialized once and reused across all instances. // This is the key to reducing multiple WAM prompts during setup operations. @@ -82,13 +83,16 @@ public sealed class MsalBrowserCredential : TokenCredential /// Whether to use WAM on Windows. Default is true. /// Optional authority URL. When provided, overrides the default AzurePublic authority. /// Use this for government clouds (e.g., "https://login.microsoftonline.us/{tenantId}"). + /// Optional UPN/email to pre-select the account for silent acquisition and interactive auth. + /// When provided, WAM and silent auth will target this identity instead of the first cached account. public MsalBrowserCredential( string clientId, string tenantId, string? redirectUri = null, ILogger? logger = null, bool useWam = true, - string? authority = null) + string? authority = null, + string? loginHint = null) { if (string.IsNullOrWhiteSpace(clientId)) { @@ -102,7 +106,8 @@ public MsalBrowserCredential( _tenantId = tenantId; _logger = logger; - + _loginHint = loginHint; + // Get window handle for WAM on Windows // Try multiple sources: console window, foreground window, or desktop window _windowHandle = IntPtr.Zero; @@ -117,7 +122,8 @@ public MsalBrowserCredential( } catch (Exception ex) { - _logger?.LogWarning(ex, "Failed to get window handle, falling back to system browser"); + _logger?.LogDebug(ex, "Failed to get window handle"); + _logger?.LogWarning("Failed to get window handle, falling back to system browser"); _useWam = false; } } @@ -240,7 +246,8 @@ private static void RegisterPersistentCache(IPublicClientApplication app, ILogge { // Cache registration failure is non-fatal - authentication will still work, // but users may see more prompts during multi-step operations - logger?.LogWarning(ex, "Failed to register persistent token cache. Authentication prompts may be repeated."); + logger?.LogDebug(ex, "Failed to register persistent token cache"); + logger?.LogWarning("Failed to register persistent token cache. Authentication prompts may be repeated."); } } @@ -315,9 +322,22 @@ public override async ValueTask GetTokenAsync( try { - // First, try to acquire token silently from cache - var accounts = await _publicClientApp.GetAccountsAsync(); - var account = accounts.FirstOrDefault(); + // First, try to acquire token silently from cache. + // When a login hint is provided, only attempt silent acquisition for the matching account. + // Do NOT fall back to any other cached account — that would silently return a token for + // the wrong user (e.g. sellak's cached token when sellakdev is the CLI identity). + var accounts = (await _publicClientApp.GetAccountsAsync()).ToList(); + IAccount? account; + if (!string.IsNullOrWhiteSpace(_loginHint)) + { + account = accounts.FirstOrDefault(a => + string.Equals(a.Username, _loginHint, StringComparison.OrdinalIgnoreCase)); + // If the hint account is not cached, skip silent path — go to interactive with hint. + } + else + { + account = accounts.FirstOrDefault(); + } if (account != null) { @@ -337,25 +357,52 @@ public override async ValueTask GetTokenAsync( } } - // Acquire token interactively + // Acquire token interactively. + // When a login hint is provided, WAM and browser auth will pre-select that identity + // instead of defaulting to the Windows account or cached account picker. AuthenticationResult interactiveResult; - + if (_useWam) { // WAM on Windows - native authentication dialog, no browser needed _logger?.LogInformation("Authenticating via Windows Account Manager..."); - interactiveResult = await _publicClientApp - .AcquireTokenInteractive(scopes) - .ExecuteAsync(cancellationToken); + var builder = _publicClientApp.AcquireTokenInteractive(scopes); + if (account != null && !string.IsNullOrWhiteSpace(_loginHint)) + { + // Caller explicitly identified this account via loginHint and MSAL found it in + // cache — WithAccount is more reliable than WithLoginHint for WAM because it + // passes the internal WAM account reference, not just a UPN. + builder = builder.WithAccount(account); + } + else if (!string.IsNullOrWhiteSpace(_loginHint)) + { + // Hint provided (e.g. resolved from az account show) but this account is not + // yet in the MSAL cache (e.g. first sign-in or cache cleared). + // WithLoginHint asks WAM to pre-select this identity in the dialog; WAM honors + // it for registered Work/School accounts so the user only needs to confirm, + // rather than searching for the right account in a blank picker. + builder = builder.WithLoginHint(_loginHint); + } + else + { + // No hint at all — show the account picker so the user can select or add the + // correct account. Using WithAccount for a first-cached "best guess" would lock + // WAM to a stale identity (e.g. an old account from a previous session) with no + // way to switch. + builder = builder.WithPrompt(Prompt.SelectAccount); + } + interactiveResult = await builder.ExecuteAsync(cancellationToken); } else { // System browser on Mac/Linux _logger?.LogInformation("Opening browser for authentication..."); - interactiveResult = await _publicClientApp + var builder = _publicClientApp .AcquireTokenInteractive(scopes) - .WithUseEmbeddedWebView(false) - .ExecuteAsync(cancellationToken); + .WithUseEmbeddedWebView(false); + if (!string.IsNullOrWhiteSpace(_loginHint)) + builder = builder.WithLoginHint(_loginHint); + interactiveResult = await builder.ExecuteAsync(cancellationToken); } _logger?.LogDebug("Successfully acquired token via interactive authentication."); @@ -375,7 +422,8 @@ public override async ValueTask GetTokenAsync( } catch (MsalException ex) { - _logger?.LogError(ex, "MSAL authentication failed: {Message}", ex.Message); + _logger?.LogDebug(ex, "MSAL authentication failed"); + _logger?.LogError("MSAL authentication failed: {Message}", ex.Message); throw new MsalAuthenticationFailedException($"Failed to acquire token: {ex.Message}", ex); } } @@ -444,7 +492,8 @@ private async Task AcquireTokenWithDeviceCodeFallbackAsync( } catch (MsalException msalEx) { - _logger?.LogError(msalEx, "Device code authentication failed: {Message}", msalEx.Message); + _logger?.LogDebug(msalEx, "Device code authentication failed"); + _logger?.LogError("Device code authentication failed: {Message}", msalEx.Message); throw new MsalAuthenticationFailedException($"Device code authentication failed: {msalEx.Message}", msalEx); } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementCheck.cs index 7938ee6b..c75e98ac 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementCheck.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementCheck.cs @@ -86,7 +86,7 @@ protected async Task ExecuteCheckWithLoggingAsync( return result; } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException) { var errorMessage = $"Exception during check: {ex.Message}"; var resolutionGuidance = "Please check the logs for more details and ensure all prerequisites are met"; diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/AzureAuthRequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/AzureAuthRequirementCheck.cs index ef0e7c96..2644471c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/AzureAuthRequirementCheck.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/AzureAuthRequirementCheck.cs @@ -42,7 +42,7 @@ private async Task CheckImplementationAsync( ILogger logger, CancellationToken cancellationToken) { - var authenticated = await _authValidator.ValidateAuthenticationAsync(config.SubscriptionId); + var authenticated = await _authValidator.ValidateAuthenticationAsync(config.SubscriptionId, cancellationToken); if (!authenticated) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/FrontierPreviewRequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/FrontierPreviewRequirementCheck.cs index df1a2258..fd929240 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/FrontierPreviewRequirementCheck.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/FrontierPreviewRequirementCheck.cs @@ -26,8 +26,8 @@ public override Task CheckAsync(Agent365Config config, I { return ExecuteCheckWithLoggingAsync(config, logger, (_, __, ___) => Task.FromResult( RequirementCheckResult.Warning( - message: "Cannot automatically verify Frontier Preview Program enrollment", - details: "enrollment cannot be auto-verified. See: https://adoption.microsoft.com/copilot/frontier-program/" + message: "Tenant enrollment cannot be verified automatically", + details: "Ensure your tenant is enrolled before proceeding. See: https://adoption.microsoft.com/copilot/frontier-program/" )), cancellationToken); } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/PowerShellModulesRequirementCheck.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/PowerShellModulesRequirementCheck.cs index 3720d340..6d016f26 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/PowerShellModulesRequirementCheck.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Requirements/RequirementChecks/PowerShellModulesRequirementCheck.cs @@ -14,6 +14,13 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Services.Requirements.RequirementCh /// public class PowerShellModulesRequirementCheck : RequirementCheck { + private readonly Func>? _commandRunner; + public PowerShellModulesRequirementCheck( + Func>? commandRunner = null) + { + _commandRunner = commandRunner; + } + /// public override string Name => "PowerShell Modules"; @@ -177,7 +184,7 @@ private async Task CheckPowerShellAvailabilityAsync(ILogger logger, Cancel try { // Check for PowerShell 7+ (pwsh) - var result = await ExecutePowerShellCommandAsync("pwsh", "$PSVersionTable.PSVersion.Major", logger, cancellationToken); + var result = await RunCommandAsync("pwsh", "$PSVersionTable.PSVersion.Major", logger, cancellationToken); if (result.success && int.TryParse(result.output?.Trim(), out var major) && major >= 7) { logger.LogDebug("PowerShell availability check succeeded."); @@ -202,7 +209,7 @@ private async Task CheckModuleInstalledAsync(string moduleName, ILogger lo { var command = $"(Get-Module -ListAvailable -Name '{moduleName}' | Select-Object -First 1).Name"; - var result = await ExecutePowerShellCommandAsync("pwsh", command, logger, cancellationToken); + var result = await RunCommandAsync("pwsh", command, logger, cancellationToken); if (!result.success || string.IsNullOrWhiteSpace(result.output)) { return false; @@ -230,7 +237,7 @@ private async Task InstallModuleAsync(string moduleName, ILogger logger, C try { var command = $"Install-Module -Name '{moduleName}' -Repository 'PSGallery' -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop"; - var result = await ExecutePowerShellCommandAsync("pwsh", command, logger, cancellationToken); + var result = await RunCommandAsync("pwsh", command, logger, cancellationToken); if (!result.success) { logger.LogDebug("Auto-install failed for {ModuleName}: {Output}", moduleName, result.output); @@ -244,6 +251,17 @@ private async Task InstallModuleAsync(string moduleName, ILogger logger, C } } + private Task<(bool success, string? output)> RunCommandAsync( + string executable, + string command, + ILogger logger, + CancellationToken cancellationToken) + { + if (_commandRunner != null) + return _commandRunner(executable, command, cancellationToken); + return ExecutePowerShellCommandAsync(executable, command, logger, cancellationToken); + } + /// /// Execute a PowerShell command and return the result /// @@ -285,6 +303,10 @@ private async Task InstallModuleAsync(string moduleName, ILogger logger, C logger.LogDebug("PowerShell command failed: {Error}", error); return (false, error); } + catch (OperationCanceledException) + { + throw; + } catch (Exception ex) { logger.LogDebug("PowerShell execution failed: {Error}", ex.Message); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/design.md b/src/Microsoft.Agents.A365.DevTools.Cli/design.md index 0fc0a121..bde68d4d 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/design.md +++ b/src/Microsoft.Agents.A365.DevTools.Cli/design.md @@ -159,6 +159,20 @@ For security and flexibility, the CLI supports environment variable overrides: **Design Decision:** All test/preprod App IDs and URLs have been removed from the codebase. The production App ID is the only hardcoded value. Internal Microsoft developers use environment variables for non-production testing. +### Sovereign / Government Cloud Configuration + +By default the CLI targets the commercial Microsoft Graph endpoint. For sovereign or government cloud tenants, set `graphBaseUrl` in `a365.config.json`: + +| Cloud | `graphBaseUrl` value | +|-------|----------------------| +| Commercial (default) | *(omit the field)* | +| GCC High / DoD | `https://graph.microsoft.us` | +| China (21Vianet) | `https://microsoftgraph.chinacloudapi.cn` | + +This field is optional. When omitted, `https://graph.microsoft.com` is used. + +The value is read from `Agent365Config.GraphBaseUrl` and forwarded to `GraphApiService` via its `GraphBaseUrl` property after config is loaded. This controls both the HTTP endpoint used for all Graph API calls and the token resource identifier passed to `az account get-access-token`. + --- ## Command Pattern Implementation @@ -342,27 +356,43 @@ a365 deploy --restart # Quick mode: steps 6-7 only (packaging + deploy) ## Permissions Architecture -The CLI configures three layers of permissions for agent blueprints: +The CLI configures two independent layers of permissions for agent blueprints: + +1. **Inheritable Permissions** — Blueprint-level permissions that agent instances inherit automatically. Set via the Agent Blueprint API (`/beta/applications/microsoft.graph.agentIdentityBlueprint/{id}/inheritablePermissions`). Requires Agent ID Administrator or Global Administrator role. Read back after writing to verify presence. +2. **OAuth2 Grants** — Tenant-wide delegated consent via Graph API `/oauth2PermissionGrants` with `consentType=AllPrincipals`. Requires Global Administrator only. -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 +> **Technical limitation:** `oauth2PermissionGrant` creation via the API requires `DelegatedPermissionGrant.ReadWrite.All`, which is an admin-only scope. Additionally, Global Administrator bypasses entitlement validation and can grant any scope; non-admin users receive HTTP 403 (insufficient privileges) or HTTP 400 (entitlement not found) for all resource SPs. There is no self-service path for non-admin users. + +> **Note:** `requiredResourceAccess` (portal "API permissions") is **not** configured for Agent Blueprints — it is not supported by the Agent ID API. ```mermaid flowchart TD Blueprint["Agent Blueprint
(Application Registration)"] - OAuth2["OAuth2 Permission Grants
(Admin Consent)"] - Required["Required Resource Access
(Portal Permissions)"] - Inheritable["Inheritable Permissions
(Blueprint Config)"] + OAuth2["OAuth2 Permission Grants
(AllPrincipals — Global Admin only)"] + Inheritable["Inheritable Permissions
(Agent ID Admin or Global Admin)"] Instance["Agent Instance
(Inherits from Blueprint)"] Blueprint --> OAuth2 - Blueprint --> Required Blueprint --> Inheritable Inheritable --> Instance ``` -**Unified Configuration:** `SetupHelpers.EnsureResourcePermissionsAsync` handles all three layers plus verification with retry logic (exponential backoff: 2s, 4s, 8s, 16s, 32s, max 5 retries). +### Role-based setup workflow + +Because the two permission layers require different roles, the CLI supports a two-person handoff: + +| Step | Command | Who runs it | What it does | +|------|---------|-------------|--------------| +| 1 | `a365 setup all` | Agent ID Admin or Developer | All infra + blueprint + inheritable permissions. OAuth2 grants skipped (requires GA). Ends with instructions to hand off config folder to GA. | +| 2 | `a365 setup admin --config-dir ""` | Global Administrator | Reads both config files, resolves SPs, creates AllPrincipals OAuth2 grants for all resources. | + +**Batch flow (`BatchPermissionsOrchestrator`):** +- **Phase 1:** Token prewarm + SP resolution (blueprint + all resource SPs). +- **Phase 2a:** Inheritable permissions — set via Blueprint API, read back to verify. Agent ID Admin and GA. +- **Phase 2b:** OAuth2 grants — `AllPrincipals` via Graph API. GA only; skipped for non-admin with instruction to run `setup admin`. +- **Phase 3:** For GA: skipped (Phase 2b satisfies consent). For non-admin: shows `setup admin` command and a Graph Explorer query to verify inheritable permissions. + +**Standalone callers:** `SetupHelpers.EnsureResourcePermissionsAsync` handles a single resource with retry logic and is used by `CopilotStudioSubcommand` and direct callers. **Per-Resource Tracking:** `ResourceConsent` model tracks inheritance state per resource (Agent 365 Tools, Messaging Bot API, Observability API). diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/AzCliTokenCacheCollection.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/AzCliTokenCacheCollection.cs new file mode 100644 index 00000000..0f07bf39 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/AzCliTokenCacheCollection.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests; + +/// +/// Serializes test classes that share the process-level AzCliHelper token cache. +/// Without serialization, a constructor calling ResetAzCliTokenCacheForTesting() in one +/// class can clear tokens that another class just warmed, causing real az CLI subprocesses +/// to be spawned and tests to fail or run slowly. +/// +[CollectionDefinition("AzCliTokenCache", DisableParallelization = true)] +public class AzCliTokenCacheCollection { } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BatchPermissionsOrchestratorTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BatchPermissionsOrchestratorTests.cs new file mode 100644 index 00000000..909b9e5d --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BatchPermissionsOrchestratorTests.cs @@ -0,0 +1,142 @@ +// 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.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +/// +/// Unit tests for BatchPermissionsOrchestrator.ConfigureAllPermissionsAsync. +/// Focused on the non-fatal phase-independence contract: each phase failure +/// must not prevent subsequent phases from running. +/// +public class BatchPermissionsOrchestratorTests +{ + private readonly GraphApiService _graph; + private readonly AgentBlueprintService _blueprintService; + private readonly ILogger _logger; + + public BatchPermissionsOrchestratorTests() + { + _logger = NullLogger.Instance; + _graph = Substitute.ForPartsOf(); + _blueprintService = Substitute.ForPartsOf( + Substitute.For>(), _graph); + } + + /// + /// When no specs are supplied the orchestrator returns success immediately + /// without making any service calls. This guards against empty-state panics + /// and ensures callers with no resources to configure do not trigger + /// unnecessary Graph authentication. + /// + [Fact] + public async Task ConfigureAllPermissions_EmptySpecs_ReturnsTrueWithoutCallingServices() + { + // Arrange + var config = new Agent365Config + { + TenantId = "tenant-id", + AgentBlueprintId = "app-id" + }; + + // Act + var (blueprintUpdated, inheritedConfigured, consentGranted, consentUrl) = + await BatchPermissionsOrchestrator.ConfigureAllPermissionsAsync( + _graph, _blueprintService, config, + blueprintAppId: "app-id", + tenantId: "tenant-id", + specs: Array.Empty(), + _logger, + setupResults: null, + ct: default); + + // Assert + blueprintUpdated.Should().BeTrue(); + inheritedConfigured.Should().BeTrue(); + consentGranted.Should().BeTrue(); + consentUrl.Should().BeNull(); + + // No Graph calls should be made for an empty spec list + await _graph.DidNotReceive().GraphGetAsync( + Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any?>()); + } + + /// + /// When Phase 1 fails (Graph authentication unavailable), Phase 2 is skipped + /// but Phase 3 still runs and returns a non-null consent URL for non-admins. + /// + /// This is the key non-admin contract: even with no Graph access the caller + /// always receives a URL to present to the tenant administrator, rather than + /// getting an exception or an empty result with no recovery path. + /// + [Fact] + public async Task ConfigureAllPermissions_WhenPhase1AuthFails_Phase2SkippedAndPhase3ReturnsConsentUrl() + { + // Arrange — GraphGetAsync returns null, simulating delegated auth failure in Phase 1 + _graph.GraphGetAsync( + Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any?>()) + .Returns((JsonDocument?)null); + + // Phase 3 checks whether the current user is an admin; return DoesNotHaveRole (non-admin path) + _graph.IsCurrentUserAdminAsync(Arg.Any(), Arg.Any()) + .Returns(RoleCheckResult.DoesNotHaveRole); + + var config = new Agent365Config + { + TenantId = "tenant-123", + AgentBlueprintId = "blueprint-app-id", + ClientAppId = "client-app-id" + }; + + // Include a Microsoft Graph spec so Phase 3 builds a consent URL. + // GrantAdminConsentAsync only generates a URL for Graph scopes (non-Graph resources + // use inheritable permissions, not the /v2.0/adminconsent URL). + var specs = new[] + { + new ResourcePermissionSpec( + AuthenticationConstants.MicrosoftGraphResourceAppId, + "Microsoft Graph", + new[] { "Mail.ReadWrite" }, + SetInheritable: true) + }; + + // Act + var (blueprintUpdated, inheritedConfigured, consentGranted, consentUrl) = + await BatchPermissionsOrchestrator.ConfigureAllPermissionsAsync( + _graph, _blueprintService, config, + blueprintAppId: "blueprint-app-id", + tenantId: "tenant-123", + specs: specs, + _logger, + setupResults: null, + ct: default); + + // Assert — Phase 1 failed, Phase 2 was skipped + blueprintUpdated.Should().BeFalse("Phase 1 auth failure should mark blueprint permissions as not updated"); + inheritedConfigured.Should().BeFalse("Phase 2 must be skipped when Phase 1 fails"); + + // Phase 3 ran and returned a consent URL for the non-admin user + consentGranted.Should().BeFalse("non-admin cannot grant consent interactively"); + consentUrl.Should().NotBeNullOrWhiteSpace("non-admin must always receive a consent URL for the tenant admin"); + consentUrl.Should().Contain("tenant-123", "consent URL must be scoped to the correct tenant"); + consentUrl.Should().Contain("blueprint-app-id", "consent URL must reference the blueprint application"); + + // state parameter must be a random GUID (not the old hardcoded "xyz123") + var stateMatch = System.Text.RegularExpressions.Regex.Match(consentUrl!, @"[?&]state=([^&]+)"); + stateMatch.Success.Should().BeTrue(because: "consent URL must include a state parameter for CSRF protection"); + Guid.TryParse(stateMatch.Groups[1].Value, out _).Should().BeTrue( + because: "state parameter must be a random GUID, not a hardcoded value like 'xyz123'"); + } +} 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 79c84af2..c0d9c8da 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 @@ -5,6 +5,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; +using System.Net.Http; using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -41,12 +42,21 @@ public BlueprintSubcommandTests() _mockLogger = Substitute.For(); _mockConfigService = Substitute.For(); var mockExecutorLogger = Substitute.For>(); - _mockExecutor = Substitute.ForPartsOf(mockExecutorLogger); - _mockAuthValidator = Substitute.ForPartsOf(NullLogger.Instance, _mockExecutor); + // Full mock — ForPartsOf would fall through to real CommandExecutor.ExecuteAsync and spawn real processes + _mockExecutor = Substitute.For(mockExecutorLogger); + _mockExecutor.ExecuteAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new Microsoft.Agents.A365.DevTools.Cli.Services.CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty })); + // Full mock — both virtual methods are always stubbed by callers + _mockAuthValidator = Substitute.For(NullLogger.Instance, _mockExecutor); var mockPlatformDetectorLogger = Substitute.For>(); _mockPlatformDetector = Substitute.ForPartsOf(mockPlatformDetectorLogger); _mockBotConfigurator = Substitute.For(); - _mockGraphApiService = Substitute.ForPartsOf(Substitute.For>(), _mockExecutor); + // Pass a no-op loginHintResolver to prevent AzCliHelper.ResolveLoginHintAsync from spawning + // a real "az account show" process on every test that touches GraphApiService. + Func> noOpLoginHint = () => Task.FromResult(null); + _mockGraphApiService = Substitute.ForPartsOf( + Substitute.For>(), _mockExecutor, + (HttpMessageHandler?)null, (IMicrosoftGraphTokenProvider?)null, noOpLoginHint, (string?)null); _mockBlueprintService = Substitute.ForPartsOf(Substitute.For>(), _mockGraphApiService); _mockClientAppValidator = Substitute.For(); _mockBlueprintLookupService = Substitute.ForPartsOf(Substitute.For>(), _mockGraphApiService); @@ -1709,20 +1719,14 @@ await BlueprintSubcommand.CreateBlueprintClientSecretAsync( graphService: _mockGraphApiService, setupConfig: setupConfig, configService: _mockConfigService, - logger: _mockLogger); + logger: _mockLogger, + loginHintResolver: () => Task.FromResult(null)); - // Assert — all required permission guidance must be logged + // Assert — documentation link must be logged (covers required permissions) _mockLogger.Received().Log( LogLevel.Warning, Arg.Any(), - Arg.Is(o => o.ToString()!.Contains("Application Administrator")), - Arg.Any(), - Arg.Any>()); - - _mockLogger.Received().Log( - LogLevel.Warning, - Arg.Any(), - Arg.Is(o => o.ToString()!.Contains("Cloud Application Administrator")), + Arg.Is(o => o.ToString()!.Contains("how-to-add-credentials")), Arg.Any(), Arg.Any>()); } @@ -1747,13 +1751,14 @@ await BlueprintSubcommand.CreateBlueprintClientSecretAsync( graphService: _mockGraphApiService, setupConfig: setupConfig, configService: _mockConfigService, - logger: _mockLogger); + logger: _mockLogger, + loginHintResolver: () => Task.FromResult(null)); - // Assert — agentBlueprintClientSecretProtected: false must be mentioned + // Assert — config file name must be mentioned so user knows where to add the secret _mockLogger.Received().Log( - LogLevel.Information, + LogLevel.Warning, Arg.Any(), - Arg.Is(o => o.ToString()!.Contains("agentBlueprintClientSecretProtected")), + Arg.Is(o => o.ToString()!.Contains("a365.generated.config.json")), Arg.Any(), Arg.Any>()); } @@ -1778,11 +1783,12 @@ await BlueprintSubcommand.CreateBlueprintClientSecretAsync( graphService: _mockGraphApiService, setupConfig: setupConfig, configService: _mockConfigService, - logger: _mockLogger); + logger: _mockLogger, + loginHintResolver: () => Task.FromResult(null)); // Assert — re-run instruction must be logged _mockLogger.Received().Log( - LogLevel.Information, + LogLevel.Warning, Arg.Any(), Arg.Is(o => o.ToString()!.Contains("a365 setup all")), Arg.Any(), @@ -1804,14 +1810,16 @@ public async Task CreateBlueprintClientSecretAsync_ShouldNotCallAzureCliGraphTok _mockConfigService.SaveStateAsync(Arg.Any(), Arg.Any()) .Returns(Task.CompletedTask); - // Act + // Act — AcquireMsalGraphTokenAsync returns null immediately for empty credentials + // (guard added to avoid MSAL/WAM blocking for ~30s before failing). await BlueprintSubcommand.CreateBlueprintClientSecretAsync( blueprintObjectId: "00000000-0000-0000-0000-000000000001", blueprintAppId: "00000000-0000-0000-0000-000000000002", graphService: _mockGraphApiService, setupConfig: setupConfig, configService: _mockConfigService, - logger: _mockLogger); + logger: _mockLogger, + loginHintResolver: () => Task.FromResult(null)); // Assert — Azure CLI token path must NOT be taken await _mockGraphApiService.DidNotReceiveWithAnyArgs().GetGraphAccessTokenAsync(default!, default); @@ -1900,7 +1908,7 @@ public async Task BlueprintIntermediateSave_ShouldPreserveExistingGeneratedConfi ["agentBlueprintClientSecretProtected"] = true, ["botId"] = "bot-id-456", ["botMsaAppId"] = "bot-msa-app-id-789", - ["botMessagingEndpoint"] = "https://myapp.azurewebsites.net/api/messages", + ["messagingEndpoint"] = "https://myapp.azurewebsites.net/api/messages", ["completed"] = true, ["completedAt"] = "2026-01-01T00:00:00Z", ["resourceConsents"] = new JsonArray @@ -1945,7 +1953,7 @@ await File.WriteAllTextAsync(generatedConfigPath, generatedConfig.ToJsonString( savedConfig["agentBlueprintClientSecretProtected"]!.GetValue().Should().BeTrue(); savedConfig["botId"]!.GetValue().Should().Be("bot-id-456"); savedConfig["botMsaAppId"]!.GetValue().Should().Be("bot-msa-app-id-789"); - savedConfig["botMessagingEndpoint"]!.GetValue().Should().Be("https://myapp.azurewebsites.net/api/messages"); + savedConfig["messagingEndpoint"]!.GetValue().Should().Be("https://myapp.azurewebsites.net/api/messages"); savedConfig["managedIdentityPrincipalId"]!.GetValue().Should().Be("msi-principal-id-123"); savedConfig["completed"]!.GetValue().Should().BeTrue(); savedConfig["completedAt"]!.GetValue().Should().Be("2026-01-01T00:00:00Z"); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandBotEndpointTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandBotEndpointTests.cs index 89c188c6..d03e8782 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandBotEndpointTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandBotEndpointTests.cs @@ -59,11 +59,12 @@ public CleanupCommandBotEndpointTests() _mockTokenProvider = Substitute.For(); _mockTokenProvider.GetMgGraphAccessTokenAsync( - Arg.Any(), - Arg.Any>(), - Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), Arg.Any(), - Arg.Any()) + Arg.Any(), + Arg.Any()) .Returns("test-token"); var mockGraphLogger = Substitute.For>(); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs index 5436711b..fee5cb22 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/CleanupCommandTests.cs @@ -11,6 +11,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Services.Requirements; using NSubstitute; using Xunit; +using Microsoft.Agents.A365.DevTools.Cli.Tests.Services; namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; @@ -35,7 +36,8 @@ public CleanupCommandTests() _mockConfigService = Substitute.For(); var mockExecutorLogger = Substitute.For>(); - _mockExecutor = Substitute.ForPartsOf(mockExecutorLogger); + // Full mock — ForPartsOf would fall through to real CommandExecutor.ExecuteAsync and spawn real processes + _mockExecutor = Substitute.For(mockExecutorLogger); // Default executor behavior for tests: return success for any external command to avoid launching real CLI tools _mockExecutor.ExecuteAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) @@ -47,16 +49,22 @@ public CleanupCommandTests() // Configure token provider to return a test token _mockTokenProvider.GetMgGraphAccessTokenAsync( - Arg.Any(), - Arg.Any>(), - Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any(), Arg.Any(), - Arg.Any()) + Arg.Any(), + Arg.Any()) .Returns("test-token"); - // Create a real GraphApiService instance with mocked dependencies + // Create a real GraphApiService instance with mocked dependencies. + // Pass a no-op loginHintResolver to prevent AzCliHelper.ResolveLoginHintAsync from spawning + // a real "az account show" process during test setup. + // Pass a TestHttpMessageHandler (returns 404 when queue empty) instead of null to avoid + // real HTTPS calls to graph.microsoft.com — the handler returns immediately, no network needed. var mockGraphLogger = Substitute.For>(); - _graphApiService = new GraphApiService(mockGraphLogger, _mockExecutor, null, _mockTokenProvider); + _graphApiService = new GraphApiService(mockGraphLogger, _mockExecutor, new TestHttpMessageHandler(), _mockTokenProvider, + loginHintResolver: () => Task.FromResult(null)); // Create AgentBlueprintService wrapping GraphApiService var mockBlueprintLogger = Substitute.For>(); @@ -77,7 +85,9 @@ public CleanupCommandTests() Arg.Any(), Arg.Any()) .Returns(true); - _mockAuthValidator = Substitute.ForPartsOf(NullLogger.Instance, _mockExecutor); + // Full mock — both virtual methods (ValidateAuthenticationAsync, GetAppServiceTokenAsync) are + // always stubbed by callers, so ForPartsOf would only add risk of real auth code running. + _mockAuthValidator = Substitute.For(NullLogger.Instance, _mockExecutor); } [Fact(Skip = "Test requires interactive confirmation - cleanup commands now enforce user confirmation instead of --force")] @@ -617,16 +627,26 @@ public async Task Cleanup_ShouldCallConfirmationProviderWithCorrectPrompts() // Arrange var config = CreateValidConfig(); _mockConfigService.LoadAsync(Arg.Any(), Arg.Any()).Returns(config); - + + // First confirmation passes, typed confirmation fails — command aborts after both prompts + // without running the Azure deletion loop. Explicit stubs make intent clear regardless + // of constructor defaults. + _mockConfirmationProvider.ConfirmAsync(Arg.Any()).Returns(true); + _mockConfirmationProvider.ConfirmWithTypedResponseAsync(Arg.Any(), Arg.Any()).Returns(false); + var command = CleanupCommand.CreateCommand(_mockLogger, _mockConfigService, _mockBotConfigurator, _mockExecutor, _agentBlueprintService, _mockConfirmationProvider, _federatedCredentialService, _mockAuthValidator); var args = new[] { "cleanup", "--config", "test.json" }; // Act await command.InvokeAsync(args); - // Assert + // Assert — both prompts were shown with the correct text await _mockConfirmationProvider.Received(1).ConfirmAsync(Arg.Is(s => s.Contains("DELETE ALL resources"))); await _mockConfirmationProvider.Received(1).ConfirmWithTypedResponseAsync(Arg.Is(s => s.Contains("Type 'DELETE'")), "DELETE"); + + // Assert — abort path taken: no deletion should have started after the typed confirmation failed + await _mockBotConfigurator.DidNotReceive().DeleteEndpointWithAgentBlueprintAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } /// 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 445dd570..2bb2b90d 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 @@ -129,8 +129,8 @@ public async Task ConfigInit_WithWizard_OnlySavesStaticPropertiesToConfigFile() "REGRESSION: dynamic property botId should NOT be in a365.config.json"); rootElement.TryGetProperty("botMsaAppId", out _).Should().BeFalse( "REGRESSION: dynamic property botMsaAppId should NOT be in a365.config.json"); - rootElement.TryGetProperty("botMessagingEndpoint", out _).Should().BeFalse( - "REGRESSION: dynamic property botMessagingEndpoint should NOT be in a365.config.json"); + rootElement.TryGetProperty("messagingEndpoint", out _).Should().BeFalse( + "REGRESSION: dynamic property messagingEndpoint should NOT be in a365.config.json"); rootElement.TryGetProperty("resourceConsents", out _).Should().BeFalse( "REGRESSION: dynamic property resourceConsents should NOT be in a365.config.json"); rootElement.TryGetProperty("inheritanceConfigured", out _).Should().BeFalse( @@ -440,6 +440,124 @@ public async Task ConfigInit_WithWizard_MessagingEndpoint() } } + /// + /// TryGetConfigField returns the string value when the field exists in the generated config. + /// + [Fact] + public void TryGetConfigField_FieldInGeneratedConfig_ReturnsValue() + { + var logger = NullLogger.Instance; + var config = new Agent365Config + { + TenantId = "tenant-123", + SubscriptionId = "sub-456" + }; + config.BotMessagingEndpoint = "https://myapp.azurewebsites.net/api/messages"; + + var result = ConfigCommand.TryGetConfigField(config, "messagingEndpoint", checkGenerated: true, checkStatic: false, logger); + + result.Should().Be("https://myapp.azurewebsites.net/api/messages", + because: "TryGetConfigField must return BotMessagingEndpoint when searching generated config"); + } + + /// + /// TryGetConfigField returns the string value when the field exists only in the static config. + /// + [Fact] + public void TryGetConfigField_FieldInStaticConfig_ReturnsValue() + { + var logger = NullLogger.Instance; + var config = new Agent365Config + { + TenantId = "tenant-abc", + SubscriptionId = "sub-def" + }; + + var result = ConfigCommand.TryGetConfigField(config, "tenantId", checkGenerated: false, checkStatic: true, logger); + + result.Should().Be("tenant-abc", + because: "TryGetConfigField must return static config value when checkStatic is true"); + } + + /// + /// TryGetConfigField returns null when the field is not found in either config. + /// + [Fact] + public void TryGetConfigField_FieldNotFound_ReturnsNull() + { + var logger = NullLogger.Instance; + var config = new Agent365Config + { + TenantId = "tenant-123" + }; + + var result = ConfigCommand.TryGetConfigField(config, "nonExistentField", checkGenerated: true, checkStatic: true, logger); + + result.Should().BeNull( + because: "TryGetConfigField must return null when the field does not exist in any config"); + } + + /// + /// TryGetConfigField returns raw JSON text for non-string fields (e.g. booleans). + /// + [Fact] + public void TryGetConfigField_NonStringField_ReturnsRawJson() + { + var logger = NullLogger.Instance; + var config = new Agent365Config + { + TenantId = "tenant-123" + }; + config.AgentBlueprintClientSecretProtected = true; + + var result = ConfigCommand.TryGetConfigField(config, "agentBlueprintClientSecretProtected", checkGenerated: true, checkStatic: false, logger); + + result.Should().Be("true", + because: "TryGetConfigField must return the raw JSON representation for boolean fields"); + } + + /// + /// TryGetConfigField searches generated config before static config (generated wins when field exists in both). + /// + [Fact] + public void TryGetConfigField_FieldInBothConfigs_ReturnsGeneratedValue() + { + var logger = NullLogger.Instance; + // MessagingEndpoint (init-only, static) and BotMessagingEndpoint (settable, generated) + // both serialize as "messagingEndpoint" in their respective config dictionaries. + var config = new Agent365Config + { + TenantId = "tenant-123", + MessagingEndpoint = "https://static-endpoint.contoso.com/api/messages" + }; + config.BotMessagingEndpoint = "https://derived-endpoint.azurewebsites.net/api/messages"; + + var result = ConfigCommand.TryGetConfigField(config, "messagingEndpoint", checkGenerated: true, checkStatic: true, logger); + + result.Should().Be("https://derived-endpoint.azurewebsites.net/api/messages", + because: "generated config must take precedence over static config when both contain the same field"); + } + + /// + /// TryGetConfigField falls back to static config when the field is absent from generated config. + /// + [Fact] + public void TryGetConfigField_FieldAbsentFromGenerated_FallsBackToStatic() + { + var logger = NullLogger.Instance; + var config = new Agent365Config + { + TenantId = "tenant-fallback", + MessagingEndpoint = "https://static-endpoint.contoso.com/api/messages" + }; + // BotMessagingEndpoint is null — not present in generated config + + var result = ConfigCommand.TryGetConfigField(config, "messagingEndpoint", checkGenerated: true, checkStatic: true, logger); + + result.Should().Be("https://static-endpoint.contoso.com/api/messages", + because: "TryGetConfigField must fall back to static config when the field is missing from generated config"); + } + /// /// Helper method to clean up test directories with retry logic /// diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/InfrastructureSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/InfrastructureSubcommandTests.cs index 39db29b1..cbff4385 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/InfrastructureSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/InfrastructureSubcommandTests.cs @@ -54,7 +54,7 @@ public async Task EnsureAppServicePlanExists_WhenQuotaLimitExceeded_ThrowsInvali var exception = await Assert.ThrowsAsync( async () => await InfrastructureSubcommand.EnsureAppServicePlanExistsAsync( _commandExecutor, _logger, resourceGroup, planName, planSku, "eastus", subscriptionId, - maxRetries: 2, baseDelaySeconds: 1)); + maxRetries: 2, baseDelaySeconds: 0)); exception.ErrorType.Should().Be(AppServicePlanErrorType.QuotaExceeded); exception.PlanName.Should().Be(planName); @@ -83,7 +83,7 @@ public async Task EnsureAppServicePlanExists_WhenPlanAlreadyExists_SkipsCreation // Act await InfrastructureSubcommand.EnsureAppServicePlanExistsAsync( _commandExecutor, _logger, resourceGroup, planName, planSku, "eastus", subscriptionId, - maxRetries: 2, baseDelaySeconds: 1); + maxRetries: 2, baseDelaySeconds: 0); // Assert - Verify creation command was never called await _commandExecutor.DidNotReceive().ExecuteAsync("az", @@ -126,7 +126,7 @@ public async Task EnsureAppServicePlanExists_WhenCreationSucceeds_VerifiesExiste // Act await InfrastructureSubcommand.EnsureAppServicePlanExistsAsync( _commandExecutor, _logger, resourceGroup, planName, planSku, "eastus", subscriptionId, - maxRetries: 2, baseDelaySeconds: 1); + maxRetries: 2, baseDelaySeconds: 0); // Assert - Verify the plan creation was called await _commandExecutor.Received(1).ExecuteAsync("az", @@ -168,7 +168,7 @@ public async Task EnsureAppServicePlanExists_WhenCreationFailsSilently_ThrowsInv var exception = await Assert.ThrowsAsync( async () => await InfrastructureSubcommand.EnsureAppServicePlanExistsAsync( _commandExecutor, _logger, resourceGroup, planName, planSku, "eastus", subscriptionId, - maxRetries: 2, baseDelaySeconds: 1)); + maxRetries: 2, baseDelaySeconds: 0)); exception.ErrorType.Should().Be(AppServicePlanErrorType.VerificationTimeout); exception.PlanName.Should().Be(planName); @@ -205,7 +205,7 @@ public async Task EnsureAppServicePlanExists_WhenPermissionDenied_ThrowsInvalidO var exception = await Assert.ThrowsAsync( async () => await InfrastructureSubcommand.EnsureAppServicePlanExistsAsync( _commandExecutor, _logger, resourceGroup, planName, planSku, "eastus", subscriptionId, - maxRetries: 2, baseDelaySeconds: 1)); + maxRetries: 2, baseDelaySeconds: 0)); exception.ErrorType.Should().Be(AppServicePlanErrorType.AuthorizationFailed); exception.PlanName.Should().Be(planName); @@ -240,7 +240,7 @@ public async Task EnsureAppServicePlanExists_WithRetry_WhenPlanPropagatesSlowly_ // Act await InfrastructureSubcommand.EnsureAppServicePlanExistsAsync( _commandExecutor, _logger, resourceGroup, planName, planSku, "eastus", subscriptionId, - maxRetries: 2, baseDelaySeconds: 1); + maxRetries: 2, baseDelaySeconds: 0); // Assert - Verify show was called multiple times (initial check + retries) await _commandExecutor.Received(3).ExecuteAsync("az", @@ -283,7 +283,7 @@ public async Task EnsureAppServicePlanExists_WithRetry_WhenPlanNeverAppears_Thro "eastus", subscriptionId, maxRetries: 2, - baseDelaySeconds: 1)); + baseDelaySeconds: 0)); exception.ErrorType.Should().Be(AppServicePlanErrorType.VerificationTimeout); exception.PlanName.Should().Be(planName); @@ -342,14 +342,14 @@ public async Task CreateInfrastructureAsync_WhenUserIdAvailable_AssignsWebsiteCo if (args.Contains("ad signed-in-user show")) return new CommandResult { ExitCode = 0, StandardOutput = "12345678-1234-1234-1234-123456789abc" }; + // Role pre-check: no existing role found (empty output triggers assignment) + if (args.Contains("role assignment list")) + return new CommandResult { ExitCode = 0, StandardOutput = "" }; + // Role assignment create if (args.Contains("role assignment create")) return new CommandResult { ExitCode = 0, StandardOutput = "{\"id\": \"test-role-assignment-id\"}" }; - // Role assignment verification - if (args.Contains("role assignment list")) - return new CommandResult { ExitCode = 0, StandardOutput = "Website Contributor" }; - return new CommandResult { ExitCode = 0 }; }); @@ -372,21 +372,15 @@ public async Task CreateInfrastructureAsync_WhenUserIdAvailable_AssignsWebsiteCo externalHosting: false, CancellationToken.None); - // Assert - Verify role assignment command was called + // Assert - Verify pre-check was called (role assignment list with include-inherited) await _commandExecutor.Received().ExecuteAsync("az", - Arg.Is(s => - s.Contains("role assignment create") && - s.Contains("Website Contributor") && - s.Contains("12345678-1234-1234-1234-123456789abc")), + Arg.Is(s => s.Contains("role assignment list") && s.Contains("include-inherited")), captureOutput: true, suppressErrorLogging: true); - // Assert - Verify role assignment verification was called + // Assert - Verify role assignment create was called (since pre-check returned empty) await _commandExecutor.Received().ExecuteAsync("az", - Arg.Is(s => - s.Contains("role assignment list") && - s.Contains("Website Contributor") && - s.Contains("12345678-1234-1234-1234-123456789abc")), + Arg.Is(s => s.Contains("role assignment create") && s.Contains("Website Contributor")), captureOutput: true, suppressErrorLogging: true); } @@ -506,8 +500,8 @@ public async Task CreateInfrastructureAsync_WhenRoleAssignmentFails_ContinuesWit var webAppName = "test-webapp"; var generatedConfigPath = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.json"); var deploymentProjectPath = Path.Combine(Path.GetTempPath(), $"test-project-{Guid.NewGuid()}"); - var logger = Substitute.For(); - + var logger = new TestLogger(); + try { // Create temporary project directory @@ -582,22 +576,8 @@ public async Task CreateInfrastructureAsync_WhenRoleAssignmentFails_ContinuesWit // Assert - Principal ID should still be set, warning logged principalId.Should().Be("test-principal-id"); - - // Verify warning was logged for assignment failure - logger.Received().Log( - LogLevel.Warning, - Arg.Any(), - Arg.Is(o => o.ToString()!.Contains("Could not assign Website Contributor role")), - Arg.Any(), - Arg.Any>()); - - // Verify warning was logged for verification failure - logger.Received().Log( - LogLevel.Warning, - Arg.Any(), - Arg.Is(o => o.ToString()!.Contains("Could not verify Website Contributor role")), - Arg.Any(), - Arg.Any>()); + logger.HasWarning("Could not assign Website Contributor role to user. Diagnostic logs may not be accessible.") + .Should().BeTrue("the code must warn when role assignment fails"); } finally { @@ -621,7 +601,7 @@ public async Task CreateInfrastructureAsync_WhenRoleAlreadyExists_VerifiesSucces var webAppName = "test-webapp"; var generatedConfigPath = Path.Combine(Path.GetTempPath(), $"test-{Guid.NewGuid()}.json"); var deploymentProjectPath = Path.Combine(Path.GetTempPath(), $"test-project-{Guid.NewGuid()}"); - var logger = Substitute.For(); + var logger = new TestLogger(); try { @@ -695,22 +675,14 @@ public async Task CreateInfrastructureAsync_WhenRoleAlreadyExists_VerifiesSucces // Assert - Principal ID should be set principalId.Should().Be("test-principal-id"); - // Verify role assignment verification was called + // Verify pre-check (role assignment list --include-inherited) was called await _commandExecutor.Received().ExecuteAsync("az", - Arg.Is(s => - s.Contains("role assignment list") && - s.Contains("Website Contributor") && - s.Contains("12345678-1234-1234-1234-123456789abc")), + Arg.Is(s => s.Contains("role assignment list") && s.Contains("include-inherited")), captureOutput: true, suppressErrorLogging: true); - // Verify success confirmation was logged - logger.Received().Log( - LogLevel.Information, - Arg.Any(), - Arg.Is(o => o.ToString()!.Contains("Current user is confirmed as Website Contributor")), - Arg.Any(), - Arg.Any>()); + logger.HasInformation("log access confirmed, skipping") + .Should().BeTrue("the code must log when an existing role is detected and assignment is skipped"); } finally { @@ -721,4 +693,22 @@ await _commandExecutor.Received().ExecuteAsync("az", Directory.Delete(deploymentProjectPath, true); } } + + private sealed class TestLogger : ILogger + { + private readonly List<(LogLevel Level, string Message)> _entries = []; + + public bool HasWarning(string fragment) => + _entries.Any(e => e.Level == LogLevel.Warning && e.Message.Contains(fragment)); + + public bool HasInformation(string fragment) => + _entries.Any(e => e.Level == LogLevel.Information && e.Message.Contains(fragment)); + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + => _entries.Add((logLevel, formatter(state, exception))); + + public bool IsEnabled(LogLevel logLevel) => true; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + } } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs index ab58dd7a..3e20a642 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs @@ -460,8 +460,8 @@ public async Task ConfigureMcpPermissionsAsync_WithMissingManifest_ShouldHandleG config, false); - // Assert - Should handle missing manifest gracefully - result.Should().BeFalse(); + result.Should().BeTrue( + because: "McpServersMetadata.Read.All is always included even when the ToolingManifest is missing, so the method proceeds to configure permissions and returns true (pending admin consent)"); } #endregion diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs index 7759d63c..d5b4e369 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupCommandTests.cs @@ -32,13 +32,17 @@ public class SetupCommandTests private readonly IClientAppValidator _mockClientAppValidator; private readonly BlueprintLookupService _mockBlueprintLookupService; private readonly FederatedCredentialService _mockFederatedCredentialService; + private readonly IConfirmationProvider _mockConfirmationProvider; public SetupCommandTests() { _mockLogger = Substitute.For>(); _mockConfigService = Substitute.For(); var mockExecutorLogger = Substitute.For>(); - _mockExecutor = Substitute.ForPartsOf(mockExecutorLogger); + // Full mock — ForPartsOf would fall through to real CommandExecutor.ExecuteAsync and spawn real processes + _mockExecutor = Substitute.For(mockExecutorLogger); + _mockExecutor.ExecuteAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new Microsoft.Agents.A365.DevTools.Cli.Services.CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty })); var mockDeployLogger = Substitute.For>(); var mockPlatformDetectorLogger = Substitute.For>(); _mockPlatformDetector = Substitute.ForPartsOf(mockPlatformDetectorLogger); @@ -46,19 +50,24 @@ public SetupCommandTests() var mockNodeLogger = Substitute.For>(); var mockPythonLogger = Substitute.For>(); _mockDeploymentService = Substitute.ForPartsOf( - mockDeployLogger, - _mockExecutor, + mockDeployLogger, + _mockExecutor, _mockPlatformDetector, mockDotNetLogger, mockNodeLogger, mockPythonLogger); _mockBotConfigurator = Substitute.For(); - _mockAuthValidator = Substitute.ForPartsOf(NullLogger.Instance, _mockExecutor); + // Full mock — both virtual methods are always stubbed so the real az CLI is never spawned + _mockAuthValidator = Substitute.For(NullLogger.Instance, _mockExecutor); + _mockAuthValidator.ValidateAuthenticationAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(true)); + _mockAuthValidator.GetAppServiceTokenAsync(Arg.Any()).Returns(Task.FromResult(true)); _mockGraphApiService = Substitute.For(); _mockBlueprintService = Substitute.ForPartsOf(Substitute.For>(), _mockGraphApiService); _mockClientAppValidator = Substitute.For(); _mockBlueprintLookupService = Substitute.ForPartsOf(Substitute.For>(), _mockGraphApiService); _mockFederatedCredentialService = Substitute.ForPartsOf(Substitute.For>(), _mockGraphApiService); + _mockConfirmationProvider = Substitute.For(); + _mockConfirmationProvider.ConfirmAsync(Arg.Any()).Returns(true); } [Fact] @@ -87,7 +96,7 @@ public async Task SetupAllCommand_DryRun_ValidConfig_OnlyValidatesConfig() _mockBotConfigurator, _mockAuthValidator, _mockPlatformDetector, - _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator); + _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator, _mockConfirmationProvider); var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole(); @@ -132,7 +141,7 @@ public async Task SetupAllCommand_SkipInfrastructure_SkipsInfrastructureStep() _mockBotConfigurator, _mockAuthValidator, _mockPlatformDetector, - _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator); + _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator, _mockConfirmationProvider); var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole(); @@ -159,7 +168,7 @@ public void SetupCommand_HasRequiredSubcommands() _mockBotConfigurator, _mockAuthValidator, _mockPlatformDetector, - _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator); + _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator, _mockConfirmationProvider); // Assert - Verify all required subcommands exist var subcommandNames = command.Subcommands.Select(c => c.Name).ToList(); @@ -183,7 +192,7 @@ public void SetupCommand_PermissionsSubcommand_HasMcpAndBotSubcommands() _mockBotConfigurator, _mockAuthValidator, _mockPlatformDetector, - _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator); + _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator, _mockConfirmationProvider); var permissionsCmd = command.Subcommands.FirstOrDefault(c => c.Name == "permissions"); @@ -210,7 +219,7 @@ public void SetupCommand_ErrorMessages_ShouldBeInformativeAndActionable() _mockBotConfigurator, _mockAuthValidator, _mockPlatformDetector, - _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator); + _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator, _mockConfirmationProvider); // Assert - Command structure should support clear error messaging command.Should().NotBeNull(); @@ -255,7 +264,7 @@ public async Task InfrastructureSubcommand_DryRun_CompletesSuccessfully() _mockBotConfigurator, _mockAuthValidator, _mockPlatformDetector, - _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator); + _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator, _mockConfirmationProvider); var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole(); @@ -297,7 +306,7 @@ public async Task BlueprintSubcommand_DryRun_CompletesSuccessfully() _mockBotConfigurator, _mockAuthValidator, _mockPlatformDetector, - _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator); + _mockGraphApiService, _mockBlueprintService, _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator, _mockConfirmationProvider); var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole(); @@ -340,7 +349,7 @@ public async Task RequirementsSubcommand_ValidConfig_CompletesSuccessfully() _mockPlatformDetector, _mockGraphApiService, _mockBlueprintService, - _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator); + _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator, _mockConfirmationProvider); var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole(); @@ -383,7 +392,7 @@ public async Task RequirementsSubcommand_WithCategoryFilter_RunsFilteredChecks() _mockPlatformDetector, _mockGraphApiService, _mockBlueprintService, - _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator); + _mockBlueprintLookupService, _mockFederatedCredentialService, _mockClientAppValidator, _mockConfirmationProvider); var parser = new CommandLineBuilder(command).Build(); var testConsole = new TestConsole(); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs new file mode 100644 index 00000000..dd21eb6b --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs @@ -0,0 +1,243 @@ +// 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.Constants; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Helpers; + +/// +/// Unit tests for SetupHelpers.BuildAdminConsentUrls, PopulateAdminConsentUrls, +/// and BuildCombinedConsentUrl. +/// +public class SetupHelpersConsentUrlTests +{ + private const string TenantId = "tenant-id-123"; + private const string BlueprintClientId = "blueprint-app-id-456"; + + [Fact] + public void BuildAdminConsentUrls_WithGraphAndMcpScopes_ReturnsUrlForEachResource() + { + var graphScopes = new[] { "Mail.Send", "Chat.ReadWrite" }; + var mcpScopes = new[] { "McpServers.Mail.All" }; + + var urls = SetupHelpers.BuildAdminConsentUrls(TenantId, BlueprintClientId, graphScopes, mcpScopes); + + urls.Should().HaveCount(5); + urls.Select(u => u.ResourceName).Should().Contain(new[] + { + "Microsoft Graph", + "Agent 365 Tools", + "Messaging Bot API", + "Observability API", + "Power Platform API" + }); + } + + [Fact] + public void BuildAdminConsentUrls_UrlsContainTenantAndClientId() + { + var urls = SetupHelpers.BuildAdminConsentUrls( + TenantId, BlueprintClientId, + new[] { "Mail.Send" }, + new[] { "McpServers.Mail.All" }); + + foreach (var (_, url) in urls) + { + url.Should().Contain(TenantId); + url.Should().Contain(BlueprintClientId); + } + } + + [Fact] + public void BuildAdminConsentUrls_MessagingBotApi_UsesCorrectScopeConstant() + { + var urls = SetupHelpers.BuildAdminConsentUrls(TenantId, BlueprintClientId, new[] { "Mail.Send" }, new[] { "scope" }); + var botUrl = urls.First(u => u.ResourceName == "Messaging Bot API").ConsentUrl; + + botUrl.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.MessagingBotApiIdentifierUri}/{ConfigConstants.MessagingBotApiAdminConsentScope}"), + because: "scope URIs are Uri.EscapeDataString-encoded in the query string — required by AAD for adminconsent"); + } + + [Fact] + public void BuildAdminConsentUrls_ObservabilityApi_UsesCorrectScopeConstant() + { + var urls = SetupHelpers.BuildAdminConsentUrls(TenantId, BlueprintClientId, new[] { "Mail.Send" }, new[] { "scope" }); + var obsUrl = urls.First(u => u.ResourceName == "Observability API").ConsentUrl; + + obsUrl.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiAdminConsentScope}"), + because: "scope URIs are Uri.EscapeDataString-encoded in the query string — required by AAD for adminconsent"); + } + + [Fact] + public void BuildAdminConsentUrls_PowerPlatformApi_UsesCorrectScopeConstant() + { + var urls = SetupHelpers.BuildAdminConsentUrls(TenantId, BlueprintClientId, new[] { "Mail.Send" }, new[] { "scope" }); + var ppUrl = urls.First(u => u.ResourceName == "Power Platform API").ConsentUrl; + + ppUrl.Should().Contain(Uri.EscapeDataString($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}"), + because: "scope URIs are Uri.EscapeDataString-encoded in the query string — required by AAD for adminconsent"); + } + + [Fact] + public void BuildAdminConsentUrls_UrlsDoNotContainRawAmpersand_InScopeParam() + { + // Ensure '&' in the URL is only used as a query-string separator, not inside + // the scope parameter (which would break browser-based consent flow). + var urls = SetupHelpers.BuildAdminConsentUrls( + TenantId, BlueprintClientId, + new[] { "Mail.Send", "Chat.ReadWrite" }, + new[] { "McpServers.Mail.All" }); + + foreach (var (_, url) in urls) + { + // Extract just the scope= value + var scopeValue = url.Split("&scope=", 2)[1].Split('&')[0]; + scopeValue.Should().NotContain("&", + because: "scopes must be joined with %20, not raw ampersands"); + } + } + + [Fact] + public void BuildAdminConsentUrls_EmptyGraphScopes_OmitsGraphEntry() + { + var urls = SetupHelpers.BuildAdminConsentUrls(TenantId, BlueprintClientId, Array.Empty(), new[] { "scope" }); + + urls.Should().NotContain(u => u.ResourceName == "Microsoft Graph"); + } + + [Fact] + public void BuildAdminConsentUrls_EmptyMcpScopes_OmitsMcpEntry() + { + var urls = SetupHelpers.BuildAdminConsentUrls(TenantId, BlueprintClientId, new[] { "Mail.Send" }, Array.Empty()); + + urls.Should().NotContain(u => u.ResourceName == "Agent 365 Tools"); + } + + [Fact] + public void PopulateAdminConsentUrls_UpsertsConsentUrlIntoResourceConsents() + { + var config = new Agent365Config + { + TenantId = TenantId, + AgentBlueprintId = BlueprintClientId, + }; + var mcpScopes = new[] { "McpServers.Mail.All" }; + + var names = SetupHelpers.PopulateAdminConsentUrls(config, McpConstants.Agent365ToolsProdAppId, mcpScopes); + + names.Should().NotBeEmpty(); + config.ResourceConsents.Should().NotBeEmpty(); + config.ResourceConsents.Should().OnlyContain(rc => !string.IsNullOrWhiteSpace(rc.ConsentUrl)); + } + + [Fact] + public void PopulateAdminConsentUrls_ReturnsResourceNamesForAllPopulatedUrls() + { + var config = new Agent365Config + { + TenantId = TenantId, + AgentBlueprintId = BlueprintClientId, + }; + + var names = SetupHelpers.PopulateAdminConsentUrls(config, McpConstants.Agent365ToolsProdAppId, new[] { "scope" }); + + names.Should().BeEquivalentTo(config.ResourceConsents.Select(rc => rc.ResourceName)); + } + + [Fact] + public void PopulateAdminConsentUrls_WhenConsentAlreadyExists_UpdatesUrl() + { + var config = new Agent365Config + { + TenantId = TenantId, + AgentBlueprintId = BlueprintClientId, + }; + config.ResourceConsents.Add(new ResourceConsent + { + ResourceName = "Messaging Bot API", + ResourceAppId = ConfigConstants.MessagingBotApiAppId, + ConsentUrl = "https://old-url" + }); + + SetupHelpers.PopulateAdminConsentUrls(config, McpConstants.Agent365ToolsProdAppId, new[] { "scope" }); + + var botConsent = config.ResourceConsents.First(rc => rc.ResourceName == "Messaging Bot API"); + botConsent.ConsentUrl.Should().NotBe("https://old-url", + because: "existing entry should be updated with the freshly built URL"); + } + + // ── BuildCombinedConsentUrl ──────────────────────────────────────────────── + + [Fact] + public void BuildCombinedConsentUrl_ReturnsCorrectBaseUrlStructure() + { + var url = SetupHelpers.BuildCombinedConsentUrl( + TenantId, BlueprintClientId, + new[] { "Mail.Send" }, new[] { "McpServers.Mail.All" }); + + url.Should().StartWith($"https://login.microsoftonline.com/{TenantId}/v2.0/adminconsent"); + url.Should().Contain($"client_id={BlueprintClientId}"); + url.Should().Contain($"redirect_uri={Uri.EscapeDataString(AuthenticationConstants.BlueprintConsentRedirectUri)}", + because: "redirect_uri must be registered on the blueprint app — AADSTS500113 is returned if absent or unregistered"); + } + + [Fact] + public void BuildCombinedConsentUrl_IncludesAllGraphScopes() + { + var url = SetupHelpers.BuildCombinedConsentUrl( + TenantId, BlueprintClientId, + new[] { "Mail.ReadWrite", "Mail.Send", "Chat.ReadWrite" }, Array.Empty()); + + url.Should().Contain(Uri.EscapeDataString($"{AuthenticationConstants.MicrosoftGraphResourceUri}/Mail.ReadWrite"), + because: "scope URIs are Uri.EscapeDataString-encoded in the query string — required by AAD for adminconsent"); + url.Should().Contain(Uri.EscapeDataString($"{AuthenticationConstants.MicrosoftGraphResourceUri}/Mail.Send")); + url.Should().Contain(Uri.EscapeDataString($"{AuthenticationConstants.MicrosoftGraphResourceUri}/Chat.ReadWrite")); + } + + [Fact] + public void BuildCombinedConsentUrl_IncludesAllMcpScopes() + { + var url = SetupHelpers.BuildCombinedConsentUrl( + TenantId, BlueprintClientId, + Array.Empty(), new[] { "McpServers.Mail.All", "McpServersMetadata.Read.All" }); + + url.Should().Contain(Uri.EscapeDataString($"{McpConstants.Agent365ToolsIdentifierUri}/McpServers.Mail.All"), + because: "scope URIs are Uri.EscapeDataString-encoded in the query string — required by AAD for adminconsent"); + url.Should().Contain(Uri.EscapeDataString($"{McpConstants.Agent365ToolsIdentifierUri}/McpServersMetadata.Read.All")); + } + + [Fact] + public void BuildCombinedConsentUrl_AlwaysIncludesAllThreeFixedResources() + { + // Even with empty graph and MCP scopes, the three fixed resources must be present + var url = SetupHelpers.BuildCombinedConsentUrl( + TenantId, BlueprintClientId, + Array.Empty(), Array.Empty()); + + url.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.MessagingBotApiIdentifierUri}/{ConfigConstants.MessagingBotApiAdminConsentScope}"), + because: "scope URIs are Uri.EscapeDataString-encoded in the query string — required by AAD for adminconsent"); + url.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiAdminConsentScope}")); + url.Should().Contain(Uri.EscapeDataString($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}")); + } + + [Fact] + public void BuildCombinedConsentUrl_ScopesJoinedWithEncodedSpaceNotAmpersand() + { + var url = SetupHelpers.BuildCombinedConsentUrl( + TenantId, BlueprintClientId, + new[] { "Mail.Send", "Chat.ReadWrite" }, new[] { "McpServers.Mail.All" }); + + // Extract the scope parameter value. BuildCombinedConsentUrl places scope before + // redirect_uri, so splitting on "&scope=" then stopping at the next "&" is stable. + var scopeParam = url.Split("&scope=", 2)[1].Split('&')[0]; + + scopeParam.Should().NotContain("&", + because: "scopes must be separated by %20, not raw ampersands"); + scopeParam.Should().Contain("%20", + because: "multiple scopes must be space-separated using %20"); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersVerificationTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersVerificationTests.cs new file mode 100644 index 00000000..b107df70 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersVerificationTests.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; +using Microsoft.Extensions.Logging; +using NSubstitute; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Helpers; + +/// +/// Unit tests for SetupHelpers.DisplayVerificationInfoAsync. +/// Specifically guards against regressions in JSON property casing +/// and the "no URLs found → no header" behaviour. +/// +public class SetupHelpersVerificationTests : IDisposable +{ + private readonly ILogger _mockLogger; + private readonly List _logMessages; + private readonly string _tempDir; + + public SetupHelpersVerificationTests() + { + _mockLogger = Substitute.For(); + _logMessages = new List(); + _tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(_tempDir); + + _mockLogger.When(x => x.Log( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any>())) + .Do(callInfo => + { + var state = callInfo.ArgAt(2); + if (state != null) + _logMessages.Add(state.ToString() ?? string.Empty); + }); + } + + public void Dispose() + { + try { Directory.Delete(_tempDir, recursive: true); } catch { /* best-effort cleanup */ } + } + + /// + /// Verifies that camelCase JSON property names are read correctly. + /// This is a regression test: the original code used PascalCase lookups + /// (e.g. "AppServiceName") which silently produced no output against the + /// actual camelCase JSON written by the CLI (e.g. "appServiceName"). + /// + [Fact] + public async Task DisplayVerificationInfoAsync_WithCamelCaseJson_EmitsAllThreeUrls() + { + // Arrange + var generatedConfig = new + { + appServiceName = "my-web-app", + resourceGroup = "my-rg", + subscriptionId = "sub-123", + agentBlueprintId = "blueprint-abc" + }; + + await WriteGeneratedConfigAsync(generatedConfig); + var configFile = new FileInfo(Path.Combine(_tempDir, "a365.config.json")); + + // Act + await SetupHelpers.DisplayVerificationInfoAsync(configFile, _mockLogger); + + // Assert — all three URL strings must appear in logged output + _logMessages.Should().Contain(m => m.Contains("my-web-app.azurewebsites.net"), + because: "appServiceName should produce an azurewebsites.net URL"); + _logMessages.Should().Contain(m => m.Contains("my-rg"), + because: "resourceGroup should appear in the Azure portal resource group URL"); + _logMessages.Should().Contain(m => m.Contains("sub-123"), + because: "subscriptionId should appear in the Azure portal resource group URL"); + _logMessages.Should().Contain(m => m.Contains("blueprint-abc"), + because: "agentBlueprintId should appear in the Entra app registration URL"); + _logMessages.Should().Contain(m => m.Contains("Verification URLs:"), + because: "header must be emitted when at least one URL is available"); + } + + /// + /// Verifies that the "Verification URLs:" header is NOT emitted when the + /// generated config contains none of the expected properties. + /// Previously the header was always logged before the property checks, + /// resulting in an empty section in the output. + /// + [Fact] + public async Task DisplayVerificationInfoAsync_WithNoRelevantProperties_DoesNotEmitHeader() + { + // Arrange — valid JSON but none of the three expected properties + await WriteGeneratedConfigAsync(new { tenantId = "tenant-only" }); + var configFile = new FileInfo(Path.Combine(_tempDir, "a365.config.json")); + + // Act + await SetupHelpers.DisplayVerificationInfoAsync(configFile, _mockLogger); + + // Assert + _logMessages.Should().NotContain(m => m.Contains("Verification URLs:"), + because: "header must be suppressed when no URLs can be built"); + } + + private async Task WriteGeneratedConfigAsync(object content) + { + var path = Path.Combine(_tempDir, "a365.generated.config.json"); + var json = JsonSerializer.Serialize(content, new JsonSerializerOptions { WriteIndented = false }); + await File.WriteAllTextAsync(path, json); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/TenantDetectionHelperTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/TenantDetectionHelperTests.cs index 1e2cdab0..3557e396 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/TenantDetectionHelperTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/TenantDetectionHelperTests.cs @@ -4,6 +4,7 @@ using FluentAssertions; 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 NSubstitute; using static Microsoft.Agents.A365.DevTools.Cli.Tests.TestConstants; @@ -92,8 +93,13 @@ public async Task DetectTenantIdAsync_WithConfigHavingWhitespaceTenantId_Returns [Fact] public async Task DetectTenantIdAsync_WithNullConfig_LogsAttemptToDetectFromAzureCli() { + // Arrange — inject a mock executor so no real az process is spawned + var mockExecutor = Substitute.For(Substitute.For>()); + mockExecutor.ExecuteAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new CommandResult { ExitCode = 1, StandardOutput = string.Empty, StandardError = string.Empty })); + // Act - await TenantDetectionHelper.DetectTenantIdAsync(null, _mockLogger); + await TenantDetectionHelper.DetectTenantIdAsync(null, _mockLogger, mockExecutor); // Assert _mockLogger.Received(1).Log( @@ -197,8 +203,9 @@ public async Task DetectTenantIdAsync_PrioritizesConfigOverAzureCli() } [Fact] - public async Task DetectTenantIdAsync_WithValidTenantId_TrimsWhitespace() + public async Task DetectTenantIdAsync_ReturnsConfigTenantId_Verbatim() { + // DetectTenantIdAsync returns the TenantId from config as-is (no trimming). // Arrange var config = new Agent365Config { @@ -211,36 +218,8 @@ public async Task DetectTenantIdAsync_WithValidTenantId_TrimsWhitespace() var result = await TenantDetectionHelper.DetectTenantIdAsync(config, _mockLogger); // Assert - // Note: The config TenantId itself should be trimmed, but we test the behavior result.Should().Be(" tenant-with-spaces "); } #endregion - - #region Null-Coalescing Pattern Tests - - [Fact] - public void DetectTenantIdAsync_NullResult_CanBeCoalescedToEmptyString() - { - // Arrange & Act - string? nullableResult = null; - string nonNullableResult = nullableResult ?? string.Empty; - - // Assert - nonNullableResult.Should().Be(string.Empty); - nonNullableResult.Should().NotBeNull(); - } - - [Fact] - public void DetectTenantIdAsync_NonNullResult_PreservesValue() - { - // Arrange & Act - string? nullableResult = "tenant-123"; - string nonNullableResult = nullableResult ?? string.Empty; - - // Assert - nonNullableResult.Should().Be("tenant-123"); - } - - #endregion } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AdminConsentHelperTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AdminConsentHelperTests.cs index f5685b5d..46560903 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AdminConsentHelperTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AdminConsentHelperTests.cs @@ -45,8 +45,9 @@ public async Task PollAdminConsentAsync_ReturnsFalse_WhenNoGrant() executor.ExecuteAsync("az", Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(new Microsoft.Agents.A365.DevTools.Cli.Services.CommandResult { ExitCode = 0, StandardOutput = "{\"value\":[]}" })); - var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2)); - var result = await AdminConsentHelper.PollAdminConsentAsync(executor, logger, "appId-1", "Test", 3, 1, cts.Token); + // Use intervalSeconds=0 and a short CTS to avoid real waits — this is a mock-only test. + var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(200)); + var result = await AdminConsentHelper.PollAdminConsentAsync(executor, logger, "appId-1", "Test", 1, 0, cts.Token); result.Should().BeFalse(); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs index 8f29a294..c053c8a3 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AgentBlueprintServiceTests.cs @@ -7,6 +7,7 @@ using FluentAssertions; using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -25,8 +26,11 @@ public AgentBlueprintServiceTests() _mockLogger = Substitute.For>(); _mockGraphLogger = Substitute.For>(); var mockExecutorLogger = Substitute.For>(); - _mockExecutor = Substitute.ForPartsOf(mockExecutorLogger); + // Use Substitute.For<> (full mock) so unmatched ExecuteAsync calls return a safe default + // instead of falling through to the real implementation and spawning actual az processes. + _mockExecutor = Substitute.For(mockExecutorLogger); _mockTokenProvider = Substitute.For(); + AzCliHelper.WarmAzCliTokenCache("https://graph.microsoft.com/", "tid", "fake-graph-token"); } [Fact] @@ -59,7 +63,7 @@ public async Task SetInheritablePermissionsAsync_Creates_WhenMissing() return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty }); }); - var graphService = new GraphApiService(_mockGraphLogger, executor, handler); + var graphService = new GraphApiService(_mockGraphLogger, executor, handler, loginHintResolver: () => Task.FromResult(null)); var service = new AgentBlueprintService(_mockLogger, graphService); // ResolveBlueprintObjectIdAsync: First GET to check if blueprintAppId is objectId (returns 404 NotFound) @@ -119,7 +123,7 @@ public async Task SetInheritablePermissionsAsync_Patches_WhenPresent() return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty }); }); - var graphService = new GraphApiService(_mockGraphLogger, executor, handler); + var graphService = new GraphApiService(_mockGraphLogger, executor, handler, loginHintResolver: () => Task.FromResult(null)); var service = new AgentBlueprintService(_mockLogger, graphService); // Existing entry with one scope @@ -175,10 +179,11 @@ public async Task DeleteAgentIdentityAsync_WithValidIdentity_ReturnsTrue() // Override with specific scope assertion _mockTokenProvider.GetMgGraphAccessTokenAsync( tenantId, - Arg.Is>(scopes => scopes.Contains("AgentIdentityBlueprint.ReadWrite.All")), + Arg.Is>(scopes => scopes.Contains("AgentIdentityBlueprint.DeleteRestore.All")), false, Arg.Any(), - Arg.Any()) + Arg.Any(), + Arg.Any()) .Returns("fake-delegated-token"); handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.NoContent)); @@ -191,10 +196,11 @@ public async Task DeleteAgentIdentityAsync_WithValidIdentity_ReturnsTrue() await _mockTokenProvider.Received(1).GetMgGraphAccessTokenAsync( tenantId, - Arg.Is>(scopes => scopes.Contains("AgentIdentityBlueprint.ReadWrite.All")), + Arg.Is>(scopes => scopes.Contains("AgentIdentityBlueprint.DeleteRestore.All")), false, Arg.Any(), - Arg.Any()); + Arg.Any(), + Arg.Any()); } } @@ -292,7 +298,8 @@ public async Task DeleteAgentIdentityAsync_WhenExceptionThrown_ReturnsFalse() Arg.Any>(), Arg.Any(), Arg.Any(), - Arg.Any()) + Arg.Any(), + Arg.Any()) .Returns(Task.FromException(new HttpRequestException("Connection timeout"))); // Act @@ -388,7 +395,7 @@ public async Task GetAgentInstancesForBlueprintAsync_Throws_WhenGraphQueryFails( // Override token provider to throw so the Graph call fails _mockTokenProvider.GetMgGraphAccessTokenAsync( - Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) + Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromException(new HttpRequestException("Connection timeout"))); // Act & Assert - exception must propagate so callers can abort rather than proceeding with 0 instances @@ -423,7 +430,7 @@ public async Task DeleteAgentUserAsync_ReturnsFalse_OnGraphError() { // Override token provider to throw _mockTokenProvider.GetMgGraphAccessTokenAsync( - Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any()) + Arg.Any(), Arg.Any>(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromException(new HttpRequestException("Connection timeout"))); // Act @@ -444,9 +451,13 @@ public async Task DeleteAgentUserAsync_ReturnsFalse_OnGraphError() { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty })); _mockTokenProvider.GetMgGraphAccessTokenAsync( Arg.Any(), Arg.Any>(), Arg.Any(), - Arg.Any(), Arg.Any()) + Arg.Any(), Arg.Any(), Arg.Any()) .Returns("test-token"); - var graphService = new GraphApiService(_mockGraphLogger, executor, handler, _mockTokenProvider); + // Pass a no-op login hint resolver to skip the real 'az account show' process spawned by + // AzCliHelper.ResolveLoginHintAsync — that static call bypasses the mocked CommandExecutor + // and causes each test to wait several seconds for the real az CLI. + var graphService = new GraphApiService(_mockGraphLogger, executor, handler, _mockTokenProvider, + loginHintResolver: () => Task.FromResult(null)); return (new AgentBlueprintService(_mockLogger, graphService), handler); } } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ArmApiServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ArmApiServiceTests.cs new file mode 100644 index 00000000..5ec9f0f3 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ArmApiServiceTests.cs @@ -0,0 +1,312 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text.Json; +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; + +/// +/// Unit tests for ArmApiService. +/// Uses TestHttpMessageHandler (defined in GraphApiServiceTests.cs, same assembly) +/// to inject fake HTTP responses. The AzCliHelper process-level token cache is +/// pre-warmed in the constructor so no real az subprocess is spawned. +/// +[Collection("AzCliTokenCache")] +public class ArmApiServiceTests +{ + private const string TenantId = "tid"; + private const string SubscriptionId = "sub-123"; + private const string ResourceGroup = "rg-test"; + private const string PlanName = "plan-test"; + private const string WebAppName = "webapp-test"; + private const string UserObjectId = "user-obj-id"; + + public ArmApiServiceTests() + { + AzCliHelper.ResetAzCliTokenCacheForTesting(); + AzCliHelper.WarmAzCliTokenCache(ArmApiService.ArmResource, TenantId, "fake-arm-token"); + } + + private static ArmApiService CreateService(HttpMessageHandler handler) => + new ArmApiService(NullLogger.Instance, handler); + + // ──────────────────────────── ResourceGroupExistsAsync ──────────────────────────── + + [Fact] + public async Task ResourceGroupExistsAsync_When200_ReturnsTrue() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK)); + var svc = CreateService(handler); + + var result = await svc.ResourceGroupExistsAsync(SubscriptionId, ResourceGroup, TenantId); + + result.Should().BeTrue(because: "HTTP 200 means the resource group exists"); + } + + [Fact] + public async Task ResourceGroupExistsAsync_When404_ReturnsFalse() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.NotFound)); + var svc = CreateService(handler); + + var result = await svc.ResourceGroupExistsAsync(SubscriptionId, ResourceGroup, TenantId); + + result.Should().BeFalse(because: "HTTP 404 means the resource group does not exist"); + } + + [Fact] + public async Task ResourceGroupExistsAsync_WhenHttpThrows_ReturnsNull() + { + using var handler = new ThrowingHttpMessageHandler(); + var svc = CreateService(handler); + + var result = await svc.ResourceGroupExistsAsync(SubscriptionId, ResourceGroup, TenantId); + + result.Should().BeNull(because: "a network exception should cause the caller to fall back to az CLI"); + } + + [Fact] + public async Task ResourceGroupExistsAsync_When401_ReturnsNull() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.Unauthorized) { Content = new StringContent(string.Empty) }); + var svc = CreateService(handler); + + var result = await svc.ResourceGroupExistsAsync(SubscriptionId, ResourceGroup, TenantId); + + result.Should().BeNull(because: "a 401 means the ARM token lacks permission — caller must fall back to az CLI, not treat the resource as absent"); + } + + // ──────────────────────────── AppServicePlanExistsAsync ─────────────────────────── + + [Fact] + public async Task AppServicePlanExistsAsync_When200_ReturnsTrue() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK)); + var svc = CreateService(handler); + + var result = await svc.AppServicePlanExistsAsync(SubscriptionId, ResourceGroup, PlanName, TenantId); + + result.Should().BeTrue(because: "HTTP 200 means the App Service plan exists"); + } + + [Fact] + public async Task AppServicePlanExistsAsync_When404_ReturnsFalse() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.NotFound)); + var svc = CreateService(handler); + + var result = await svc.AppServicePlanExistsAsync(SubscriptionId, ResourceGroup, PlanName, TenantId); + + result.Should().BeFalse(because: "HTTP 404 means the App Service plan does not exist"); + } + + [Fact] + public async Task AppServicePlanExistsAsync_WhenHttpThrows_ReturnsNull() + { + using var handler = new ThrowingHttpMessageHandler(); + var svc = CreateService(handler); + + var result = await svc.AppServicePlanExistsAsync(SubscriptionId, ResourceGroup, PlanName, TenantId); + + result.Should().BeNull(because: "a network exception should cause the caller to fall back to az CLI"); + } + + [Fact] + public async Task AppServicePlanExistsAsync_When401_ReturnsNull() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.Unauthorized) { Content = new StringContent(string.Empty) }); + var svc = CreateService(handler); + + var result = await svc.AppServicePlanExistsAsync(SubscriptionId, ResourceGroup, PlanName, TenantId); + + result.Should().BeNull(because: "a 401 means the ARM token lacks permission — caller must fall back to az CLI, not treat the plan as absent"); + } + + // ──────────────────────────── WebAppExistsAsync ─────────────────────────────────── + + [Fact] + public async Task WebAppExistsAsync_When200_ReturnsTrue() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK)); + var svc = CreateService(handler); + + var result = await svc.WebAppExistsAsync(SubscriptionId, ResourceGroup, WebAppName, TenantId); + + result.Should().BeTrue(because: "HTTP 200 means the web app exists"); + } + + [Fact] + public async Task WebAppExistsAsync_When404_ReturnsFalse() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.NotFound)); + var svc = CreateService(handler); + + var result = await svc.WebAppExistsAsync(SubscriptionId, ResourceGroup, WebAppName, TenantId); + + result.Should().BeFalse(because: "HTTP 404 means the web app does not exist"); + } + + [Fact] + public async Task WebAppExistsAsync_When401_ReturnsNull() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.Unauthorized) { Content = new StringContent(string.Empty) }); + var svc = CreateService(handler); + + var result = await svc.WebAppExistsAsync(SubscriptionId, ResourceGroup, WebAppName, TenantId); + + result.Should().BeNull(because: "a 401 means the ARM token lacks permission — caller must fall back to az CLI, not treat the web app as absent"); + } + + [Fact] + public async Task WebAppExistsAsync_WhenHttpThrows_ReturnsNull() + { + using var handler = new ThrowingHttpMessageHandler(); + var svc = CreateService(handler); + + var result = await svc.WebAppExistsAsync(SubscriptionId, ResourceGroup, WebAppName, TenantId); + + result.Should().BeNull(because: "a network exception should cause the caller to fall back to az CLI"); + } + + // ──────────────────────────── GetSufficientWebAppRoleAsync ──────────────────────── + + [Fact] + public async Task GetSufficientWebAppRoleAsync_WhenOwnerAtSubscriptionScope_ReturnsOwner() + { + // Owner role at subscription scope — scope chain includes the web app (inherited). + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(BuildRoleAssignmentsResponse( + scope: $"/subscriptions/{SubscriptionId}", + roleGuid: "8e3af657-a8ff-443c-a75c-2fe8c4bcb635")); // Owner + var svc = CreateService(handler); + + var result = await svc.GetSufficientWebAppRoleAsync(SubscriptionId, ResourceGroup, WebAppName, UserObjectId, TenantId); + + result.Should().Be("Owner", + because: "Owner at subscription scope is inherited by all resources in that subscription"); + } + + [Fact] + public async Task GetSufficientWebAppRoleAsync_WhenContributorAtResourceGroupScope_ReturnsContributor() + { + // Contributor role at the resource group — inherited by the web app within it. + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(BuildRoleAssignmentsResponse( + scope: $"/subscriptions/{SubscriptionId}/resourceGroups/{ResourceGroup}", + roleGuid: "b24988ac-6180-42a0-ab88-20f7382dd24c")); // Contributor + var svc = CreateService(handler); + + var result = await svc.GetSufficientWebAppRoleAsync(SubscriptionId, ResourceGroup, WebAppName, UserObjectId, TenantId); + + result.Should().Be("Contributor", + because: "Contributor at resource group scope is inherited by all resources in that group"); + } + + [Fact] + public async Task GetSufficientWebAppRoleAsync_WhenNoSufficientRole_ReturnsEmpty() + { + // Role assignments exist but none are Owner/Contributor/Website Contributor. + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(BuildRoleAssignmentsResponse( + scope: $"/subscriptions/{SubscriptionId}", + roleGuid: "acdd72a7-3385-48ef-bd42-f606fba81ae7")); // Reader — not sufficient + var svc = CreateService(handler); + + var result = await svc.GetSufficientWebAppRoleAsync(SubscriptionId, ResourceGroup, WebAppName, UserObjectId, TenantId); + + result.Should().BeEmpty( + because: "Reader does not grant the access required to deploy or configure the web app"); + } + + [Fact] + public async Task GetSufficientWebAppRoleAsync_WhenRoleIsAtUnrelatedScope_ReturnsEmpty() + { + // Owner on a different resource group — scope chain does NOT include our web app. + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(BuildRoleAssignmentsResponse( + scope: $"/subscriptions/{SubscriptionId}/resourceGroups/other-rg", + roleGuid: "8e3af657-a8ff-443c-a75c-2fe8c4bcb635")); // Owner, wrong scope + var svc = CreateService(handler); + + var result = await svc.GetSufficientWebAppRoleAsync(SubscriptionId, ResourceGroup, WebAppName, UserObjectId, TenantId); + + result.Should().BeEmpty( + because: "a role on an unrelated resource group does not grant access to our web app"); + } + + [Fact] + public async Task GetSufficientWebAppRoleAsync_WhenHttpFails_ReturnsNull() + { + using var handler = new TestHttpMessageHandler(); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent(string.Empty) + }); + var svc = CreateService(handler); + + var result = await svc.GetSufficientWebAppRoleAsync(SubscriptionId, ResourceGroup, WebAppName, UserObjectId, TenantId); + + result.Should().BeNull(because: "a non-success HTTP response should cause the caller to fall back to az CLI"); + } + + [Fact] + public async Task GetSufficientWebAppRoleAsync_WhenHttpThrows_ReturnsNull() + { + using var handler = new ThrowingHttpMessageHandler(); + var svc = CreateService(handler); + + var result = await svc.GetSufficientWebAppRoleAsync(SubscriptionId, ResourceGroup, WebAppName, UserObjectId, TenantId); + + result.Should().BeNull(because: "a network exception should cause the caller to fall back to az CLI"); + } + + // ──────────────────────────── Helpers ───────────────────────────────────────────── + + private static HttpResponseMessage BuildRoleAssignmentsResponse(string scope, string roleGuid) + { + var body = JsonSerializer.Serialize(new + { + value = new[] + { + new + { + properties = new + { + scope, + roleDefinitionId = $"/subscriptions/{SubscriptionId}/providers/Microsoft.Authorization/roleDefinitions/{roleGuid}" + } + } + } + }); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body) + }; + } +} + +/// +/// HttpMessageHandler that always throws an HttpRequestException to simulate network failure. +/// +internal class ThrowingHttpMessageHandler : HttpMessageHandler +{ + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => throw new HttpRequestException("Simulated network failure"); + + protected override void Dispose(bool disposing) => base.Dispose(disposing); +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AuthenticationServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AuthenticationServiceTests.cs index 58f1c393..a66a405d 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AuthenticationServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/AuthenticationServiceTests.cs @@ -800,7 +800,7 @@ public TestableAuthenticationService( _deviceCodeCredential = deviceCodeCredential; } - protected override TokenCredential CreateBrowserCredential(string clientId, string tenantId) + protected override TokenCredential CreateBrowserCredential(string clientId, string tenantId, string? loginHint = null) => _browserCredential; protected override TokenCredential CreateDeviceCodeCredential(string clientId, string tenantId) diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs index c54bd6e6..e92d1a55 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/ClientAppValidatorTests.cs @@ -7,6 +7,7 @@ using Microsoft.Agents.A365.DevTools.Cli.Services; using Microsoft.Extensions.Logging; using NSubstitute; +using System.Text.Json; using Xunit; namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; @@ -14,26 +15,43 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; /// /// Unit tests for ClientAppValidator service. /// Tests validation logic for client app existence, permissions, and admin consent. +/// Uses GraphApiService mocks (via NSubstitute virtual method substitution) for direct HTTP calls +/// — no az-subprocess spawning. /// public class ClientAppValidatorTests { private readonly ILogger _logger; - private readonly CommandExecutor _executor; + private readonly GraphApiService _graphApiService; 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"; + private const string AppObjId = "object-id-123"; + private const string SpObjId = "sp-object-id-123"; + + // Stable test GUIDs for required permissions — must match between SetupPermissionResolution + // and SetupAppInfoWithAllPermissions so the validation resolves all permissions as present. + private const string ApplicationReadWriteAllId = "aaaa0001-0000-0000-0000-000000000000"; + private const string AgentBlueprintReadWriteAllId = "aaaa0002-0000-0000-0000-000000000000"; + private const string AgentBlueprintUpdateAuthId = "aaaa0003-0000-0000-0000-000000000000"; + private const string AgentBlueprintAddRemoveCredsId = "aaaa0004-0000-0000-0000-000000000000"; + private const string DelegatedPermissionGrantReadWriteAllId = "aaaa0005-0000-0000-0000-000000000000"; + private const string DirectoryReadAllId = "aaaa0006-0000-0000-0000-000000000000"; public ClientAppValidatorTests() { _logger = Substitute.For>(); - - // CommandExecutor requires a logger in its constructor for NSubstitute to create a proxy + + // Use Substitute.For<> (full mock) so unmatched GraphGetAsync calls return + // Task.FromResult(null) — the null path in ClientAppValidator is + // always a graceful "best-effort check" or early return, never an exception. var executorLogger = Substitute.For>(); - _executor = Substitute.ForPartsOf(executorLogger); - - _validator = new ClientAppValidator(_logger, _executor); + var executor = Substitute.For(executorLogger); + var graphServiceLogger = Substitute.For>(); + _graphApiService = Substitute.For(graphServiceLogger, executor); + + _validator = new ClientAppValidator(_logger, _graphApiService); } #region Constructor Tests @@ -41,21 +59,19 @@ public ClientAppValidatorTests() [Fact] public void Constructor_WithNullLogger_ThrowsArgumentNullException() { - // Act & Assert - var exception = Assert.Throws(() => - new ClientAppValidator(null!, _executor)); - + var exception = Assert.Throws(() => + new ClientAppValidator(null!, _graphApiService)); + exception.ParamName.Should().Be("logger"); } [Fact] - public void Constructor_WithNullExecutor_ThrowsArgumentNullException() + public void Constructor_WithNullGraphApiService_ThrowsArgumentNullException() { - // Act & Assert - var exception = Assert.Throws(() => + var exception = Assert.Throws(() => new ClientAppValidator(_logger, null!)); - - exception.ParamName.Should().Be("executor"); + + exception.ParamName.Should().Be("graphApiService"); } #endregion @@ -63,66 +79,31 @@ public void Constructor_WithNullExecutor_ThrowsArgumentNullException() #region EnsureValidClientAppAsync - Input Validation Tests [Fact] - public async Task EnsureValidClientAppAsync_WithNullClientAppId_ThrowsArgumentException() + public async Task EnsureValidClientAppAsync_WithNullClientAppId_ThrowsArgumentNullException() { - // Act & Assert - await Assert.ThrowsAsync(() => + await Assert.ThrowsAsync(() => _validator.EnsureValidClientAppAsync(null!, ValidTenantId)); } [Fact] public async Task EnsureValidClientAppAsync_WithEmptyClientAppId_ThrowsArgumentException() { - // Act & Assert - await Assert.ThrowsAsync(() => + await Assert.ThrowsAsync(() => _validator.EnsureValidClientAppAsync(string.Empty, ValidTenantId)); } [Fact] - public async Task EnsureValidClientAppAsync_WithInvalidClientAppIdFormat_ReturnsInvalidFormatFailure() - { - // Act - await Assert.ThrowsAsync(async () => await _validator.EnsureValidClientAppAsync(InvalidGuid, ValidTenantId)); - } - - [Fact] - public async Task EnsureValidClientAppAsync_WithInvalidTenantIdFormat_ReturnsInvalidFormatFailure() - { - // Act - await Assert.ThrowsAsync(async () => await _validator.EnsureValidClientAppAsync(ValidClientAppId, InvalidGuid)); - } - - #endregion - - #region EnsureValidClientAppAsync - Token Acquisition Tests - - [Fact] - public async Task EnsureValidClientAppAsync_WhenTokenAcquisitionFails_ReturnsAuthenticationFailed() + public async Task EnsureValidClientAppAsync_WithInvalidClientAppIdFormat_ThrowsClientAppValidationException() { - // 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 - await Assert.ThrowsAsync(async () => await _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId)); + await Assert.ThrowsAsync(async () => + await _validator.EnsureValidClientAppAsync(InvalidGuid, ValidTenantId)); } [Fact] - public async Task EnsureValidClientAppAsync_WhenTokenIsEmpty_ThrowsClientAppValidationException() + public async Task EnsureValidClientAppAsync_WithInvalidTenantIdFormat_ThrowsClientAppValidationException() { - // 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 & Assert - await Assert.ThrowsAsync( - () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId)); + await Assert.ThrowsAsync(async () => + await _validator.EnsureValidClientAppAsync(ValidClientAppId, InvalidGuid)); } #endregion @@ -130,46 +111,38 @@ await Assert.ThrowsAsync( #region EnsureValidClientAppAsync - App Existence Tests [Fact] - public async Task EnsureValidClientAppAsync_WhenAppDoesNotExist_ReturnsAppNotFound() + public async Task EnsureValidClientAppAsync_WhenAppDoesNotExist_ThrowsClientAppValidationException() { - // 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 }); + SetupAppInfoGetEmpty(); - // Act - await Assert.ThrowsAsync(async () => await _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId)); + await Assert.ThrowsAsync(async () => + await _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId)); } [Fact] public async Task EnsureValidClientAppAsync_WhenGraphQueryFails_ThrowsClientAppValidationException() { - // 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 & Assert - await Assert.ThrowsAsync( + // Simulate a 401 on both the first attempt and the retry after cache invalidation. + // TokenRevoked is only thrown when the failure is specifically a 401 (auth error), + // not for transient failures like 503 — which would produce AppNotFound instead. + _graphApiService.GraphGetWithResponseAsync( + Arg.Any(), + Arg.Is(p => p.Contains("displayName")), + Arg.Any(), + Arg.Any?>()) + .Returns(_ => Task.FromResult(new GraphApiService.GraphResponse + { + IsSuccess = false, + StatusCode = 401, + ReasonPhrase = "Unauthorized" + })); + + var exception = await Assert.ThrowsAsync( () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId)); + + exception.ErrorCode.Should().Be(ErrorCodes.ClientAppValidationFailed); + exception.IssueDescription.Should().Contain("revoked", + because: "a persistent 401 from Graph indicates a CAE token revocation, not a transient error"); } #endregion @@ -177,25 +150,20 @@ await Assert.ThrowsAsync( #region EnsureValidClientAppAsync - Permission Validation Tests [Fact] - public async Task EnsureValidClientAppAsync_WhenAppHasNoRequiredResourceAccess_ReturnsMissingPermissions() + public async Task EnsureValidClientAppAsync_WhenAppHasNoRequiredResourceAccess_ThrowsMissingPermissions() { - // Arrange - var token = "fake-token-123"; - SetupTokenAcquisition(token); - SetupAppExists(ValidClientAppId, "Test App", requiredResourceAccess: null); + // requiredResourceAccess: null → all permissions reported as missing + SetupAppInfoGet(ValidClientAppId, requiredResourceAccess: "null"); + SetupPermissionResolution(); - // Act - await Assert.ThrowsAsync(async () => await _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId)); + await Assert.ThrowsAsync(async () => + await _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId)); } [Fact] public async Task EnsureValidClientAppAsync_WhenAppMissingGraphPermissions_ThrowsClientAppValidationException() { - // Arrange - var token = "fake-token-123"; - SetupTokenAcquisition(token); - - var requiredResourceAccess = $$""" + var requiredResourceAccess = """ [ { "resourceAppId": "some-other-app-id", @@ -203,10 +171,10 @@ public async Task EnsureValidClientAppAsync_WhenAppMissingGraphPermissions_Throw } ] """; - - SetupAppExists(ValidClientAppId, "Test App", requiredResourceAccess); - // Act & Assert + SetupAppInfoGet(ValidClientAppId, requiredResourceAccess: requiredResourceAccess); + SetupPermissionResolution(); + await Assert.ThrowsAsync( () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId)); } @@ -214,29 +182,21 @@ await Assert.ThrowsAsync( [Fact] public async Task EnsureValidClientAppAsync_WhenAppMissingSomePermissions_ThrowsClientAppValidationException() { - // Arrange - var token = "fake-token-123"; - SetupTokenAcquisition(token); - SetupGraphPermissionResolution(token); - - // Only include Application.ReadWrite.All, missing others + // Only Application.ReadWrite.All present — missing the other 5 var requiredResourceAccess = $$""" [ { "resourceAppId": "{{AuthenticationConstants.MicrosoftGraphResourceAppId}}", "resourceAccess": [ - { - "id": "1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9", - "type": "Scope" - } + {"id": "{{ApplicationReadWriteAllId}}", "type": "Scope"} ] } ] """; - - SetupAppExists(ValidClientAppId, "Test App", requiredResourceAccess); - // Act & Assert + SetupAppInfoGet(ValidClientAppId, requiredResourceAccess: requiredResourceAccess); + SetupPermissionResolution(); + await Assert.ThrowsAsync( () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId)); } @@ -248,34 +208,23 @@ await Assert.ThrowsAsync( [Fact] public async Task EnsureValidClientAppAsync_WhenAllValidationsPass_DoesNotThrow() { - // Arrange - var token = "fake-token-123"; - SetupTokenAcquisition(token); - SetupAppExistsWithAllPermissions(ValidClientAppId, "Test App"); - SetupAdminConsentGranted(ValidClientAppId); + SetupAppInfoWithAllPermissions(ValidClientAppId); + SetupPermissionResolution(); + // Admin consent: SP query returns null (unmatched) → best-effort returns true + // Redirect URIs / public client flows: null → silent skip — no exception - // Act & Assert - should not throw await _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId); } #endregion - #region EnsureValidClientAppAsync Exception Tests + #region EnsureValidClientAppAsync - Exception Detail Tests [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")), - suppressErrorLogging: Arg.Any(), - cancellationToken: Arg.Any()) - .Returns(new CommandResult { ExitCode = 0, StandardOutput = "{\"value\": []}", StandardError = string.Empty }); - - // Act & Assert + public async Task EnsureValidClientAppAsync_WhenAppNotFound_ThrowsWithCorrectErrorCode() + { + SetupAppInfoGetEmpty(); + var exception = await Assert.ThrowsAsync( () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId)); @@ -284,14 +233,11 @@ public async Task EnsureValidClientAppAsync_WhenAppNotFound_ThrowsClientAppValid } [Fact] - public async Task EnsureValidClientAppAsync_WhenMissingPermissions_ThrowsClientAppValidationException() + public async Task EnsureValidClientAppAsync_WhenMissingPermissions_ThrowsWithCorrectMessage() { - // Arrange - var token = "fake-token-123"; - SetupTokenAcquisition(token); - SetupAppExists(ValidClientAppId, "Test App", requiredResourceAccess: "[]"); + SetupAppInfoGet(ValidClientAppId, requiredResourceAccess: "[]"); + SetupPermissionResolution(); - // Act & Assert var exception = await Assert.ThrowsAsync( () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId)); @@ -300,15 +246,13 @@ public async Task EnsureValidClientAppAsync_WhenMissingPermissions_ThrowsClientA } [Fact] - public async Task EnsureValidClientAppAsync_WhenMissingAdminConsent_ThrowsClientAppValidationException() + public async Task EnsureValidClientAppAsync_WhenMissingAdminConsent_ThrowsWithCorrectMessage() { - // Arrange - var token = "fake-token-123"; - SetupTokenAcquisition(token); - SetupAppExistsWithAllPermissions(ValidClientAppId, "Test App"); - SetupAdminConsentNotGranted(ValidClientAppId); + SetupAppInfoWithAllPermissions(ValidClientAppId); + SetupPermissionResolution(); + SetupAdminConsentSp(ValidClientAppId, SpObjId); + SetupAdminConsentGrantsEmpty(SpObjId); - // Act & Assert var exception = await Assert.ThrowsAsync( () => _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId)); @@ -318,285 +262,61 @@ public async Task EnsureValidClientAppAsync_WhenMissingAdminConsent_ThrowsClient #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")), - suppressErrorLogging: Arg.Any(), - 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")), - suppressErrorLogging: Arg.Any(), - 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 - #region EnsurePublicClientFlowsEnabledAsync Tests [Fact] public async Task EnsureValidClientAppAsync_WhenPublicClientFlowsAlreadyEnabled_DoesNotPatchPublicClientFlows() { - // Arrange - var token = "fake-token-123"; - SetupTokenAcquisition(token); - SetupAppExistsWithAllPermissions(ValidClientAppId, "Test App"); - SetupAdminConsentGranted(ValidClientAppId); - SetupPublicClientFlowsCheck(enabled: true); + SetupAppInfoWithAllPermissions(ValidClientAppId); + SetupPermissionResolution(); + SetupPublicClientFlowsGet(enabled: true); + // Redirect URIs GET returns null (unmatched) → no PATCH for redirect URIs - // Act await _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId); - // Assert - PATCH for isFallbackPublicClient should NOT be called - await _executor.DidNotReceive().ExecuteAsync( - Arg.Is(s => s == "az"), - Arg.Is(s => s.Contains("rest --method PATCH") && s.Contains("isFallbackPublicClient")), - cancellationToken: Arg.Any()); + // Neither redirect URIs nor public client flows should issue a PATCH + await _graphApiService.DidNotReceive().GraphPatchAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any?>()); } [Fact] public async Task EnsureValidClientAppAsync_WhenPublicClientFlowsDisabled_PatchesPublicClientFlows() { - // Arrange - var token = "fake-token-123"; - SetupTokenAcquisition(token); - SetupAppExistsWithAllPermissions(ValidClientAppId, "Test App"); - SetupAdminConsentGranted(ValidClientAppId); - SetupPublicClientFlowsCheck(enabled: false); - - _executor.ExecuteAsync( - Arg.Is(s => s == "az"), - Arg.Is(s => s.Contains("rest --method PATCH") && s.Contains("isFallbackPublicClient")), - cancellationToken: Arg.Any()) - .Returns(new CommandResult { ExitCode = 0, StandardOutput = "{}", StandardError = string.Empty }); + SetupAppInfoWithAllPermissions(ValidClientAppId); + SetupPermissionResolution(); + SetupPublicClientFlowsGet(enabled: false); + _graphApiService.GraphPatchAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any?>()) + .Returns(Task.FromResult(true)); + // Redirect URIs GET returns null (unmatched) → no separate PATCH - // Act - should not throw (non-fatal) await _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId); - // Assert - PATCH for isFallbackPublicClient should be called once - await _executor.Received(1).ExecuteAsync( - Arg.Is(s => s == "az"), - Arg.Is(s => s.Contains("rest --method PATCH") && s.Contains("isFallbackPublicClient")), - cancellationToken: Arg.Any()); + // Exactly one PATCH — the public client flows enable + await _graphApiService.Received(1).GraphPatchAsync( + Arg.Any(), + Arg.Is(p => p.Contains(AppObjId)), + Arg.Any(), + Arg.Any(), + Arg.Any?>()); } [Fact] public async Task EnsureValidClientAppAsync_WhenPublicClientFlowsPatchFails_DoesNotThrow() { - // Arrange - var token = "fake-token-123"; - SetupTokenAcquisition(token); - SetupAppExistsWithAllPermissions(ValidClientAppId, "Test App"); - SetupAdminConsentGranted(ValidClientAppId); - SetupPublicClientFlowsCheck(enabled: false); - - _executor.ExecuteAsync( - Arg.Is(s => s == "az"), - Arg.Is(s => s.Contains("rest --method PATCH") && s.Contains("isFallbackPublicClient")), - cancellationToken: Arg.Any()) - .Returns(new CommandResult { ExitCode = 1, StandardOutput = string.Empty, StandardError = "Forbidden" }); + SetupAppInfoWithAllPermissions(ValidClientAppId); + SetupPermissionResolution(); + SetupPublicClientFlowsGet(enabled: false); + // GraphPatchAsync returns false (default) — operation is non-fatal - // Act - should not throw (non-fatal operation) await _validator.EnsureValidClientAppAsync(ValidClientAppId, ValidTenantId); } - private void SetupPublicClientFlowsCheck(bool enabled) - { - var appJson = $$""" - { - "value": [{ - "id": "object-id-123", - "isFallbackPublicClient": {{(enabled ? "true" : "false")}} - }] - } - """; - - _executor.ExecuteAsync( - Arg.Is(s => s == "az"), - Arg.Is(s => s.Contains("isFallbackPublicClient")), - cancellationToken: Arg.Any()) - .Returns(new CommandResult { ExitCode = 0, StandardOutput = appJson, StandardError = string.Empty }); - } - #endregion #region EnsureRedirectUrisAsync Tests @@ -604,221 +324,300 @@ private void SetupPublicClientFlowsCheck(bool enabled) [Fact] public async Task EnsureRedirectUrisAsync_WhenAllUrisPresent_DoesNotUpdate() { - // Arrange - var token = "test-token"; - // Include all required URIs: localhost, localhost:8400, and WAM broker URI var wamBrokerUri = $"ms-appx-web://microsoft.aad.brokerplugin/{ValidClientAppId}"; var appResponseJson = $$""" { "value": [{ - "id": "object-id-123", + "id": "{{AppObjId}}", "publicClient": { "redirectUris": ["http://localhost", "http://localhost:8400/", "{{wamBrokerUri}}"] } }] } """; + SetupRedirectUrisGet(appResponseJson); - _executor.ExecuteAsync( - Arg.Is(s => s == "az"), - Arg.Is(s => s.Contains("rest --method GET") && s.Contains("publicClient")), - cancellationToken: Arg.Any()) - .Returns(new CommandResult { ExitCode = 0, StandardOutput = appResponseJson, StandardError = string.Empty }); - - // Act - await _validator.EnsureRedirectUrisAsync(ValidClientAppId, token); + await _validator.EnsureRedirectUrisAsync(ValidClientAppId, ValidTenantId); - // Assert - Should not call PATCH - await _executor.DidNotReceive().ExecuteAsync( - Arg.Is(s => s == "az"), - Arg.Is(s => s.Contains("rest --method PATCH")), - cancellationToken: Arg.Any()); + await _graphApiService.DidNotReceive().GraphPatchAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any?>()); } [Fact] public async Task EnsureRedirectUrisAsync_WhenUrisMissing_AddsThemSuccessfully() { - // Arrange - var token = "test-token"; var appResponseJson = $$""" { "value": [{ - "id": "object-id-123", + "id": "{{AppObjId}}", "publicClient": { "redirectUris": ["http://localhost:8400/"] } }] } """; + SetupRedirectUrisGet(appResponseJson); + _graphApiService.GraphPatchAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any?>()) + .Returns(Task.FromResult(true)); - _executor.ExecuteAsync( - Arg.Is(s => s == "az"), - Arg.Is(s => s.Contains("rest --method GET") && s.Contains("publicClient")), - cancellationToken: Arg.Any()) - .Returns(new CommandResult { ExitCode = 0, StandardOutput = appResponseJson, StandardError = string.Empty }); - - _executor.ExecuteAsync( - Arg.Is(s => s == "az"), - Arg.Is(s => s.Contains("rest --method PATCH")), - cancellationToken: Arg.Any()) - .Returns(new CommandResult { ExitCode = 0, StandardOutput = "{}", StandardError = string.Empty }); - - // Act - await _validator.EnsureRedirectUrisAsync(ValidClientAppId, token); + await _validator.EnsureRedirectUrisAsync(ValidClientAppId, ValidTenantId); - // Assert - Should call PATCH with both URIs - await _executor.Received(1).ExecuteAsync( - Arg.Is(s => s == "az"), - Arg.Is(s => s.Contains("rest --method PATCH") && - s.Contains("http://localhost") && - s.Contains("http://localhost:8400/")), - cancellationToken: Arg.Any()); + await _graphApiService.Received(1).GraphPatchAsync( + Arg.Any(), + Arg.Is(p => p.Contains(AppObjId)), + Arg.Any(), + Arg.Any(), + Arg.Any?>()); } [Fact] public async Task EnsureRedirectUrisAsync_WhenNoRedirectUris_AddsAllRequired() { - // Arrange - var token = "test-token"; var appResponseJson = $$""" { "value": [{ - "id": "object-id-123", + "id": "{{AppObjId}}", "publicClient": { "redirectUris": [] } }] } """; + SetupRedirectUrisGet(appResponseJson); + _graphApiService.GraphPatchAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any?>()) + .Returns(Task.FromResult(true)); - _executor.ExecuteAsync( - Arg.Is(s => s == "az"), - Arg.Is(s => s.Contains("rest --method GET") && s.Contains("publicClient")), - cancellationToken: Arg.Any()) - .Returns(new CommandResult { ExitCode = 0, StandardOutput = appResponseJson, StandardError = string.Empty }); + await _validator.EnsureRedirectUrisAsync(ValidClientAppId, ValidTenantId); - _executor.ExecuteAsync( - Arg.Is(s => s == "az"), - Arg.Is(s => s.Contains("rest --method PATCH")), - cancellationToken: Arg.Any()) - .Returns(new CommandResult { ExitCode = 0, StandardOutput = "{}", StandardError = string.Empty }); - - // Act - await _validator.EnsureRedirectUrisAsync(ValidClientAppId, token); - - // Assert - await _executor.Received(1).ExecuteAsync( - Arg.Is(s => s == "az"), - Arg.Is(s => s.Contains("rest --method PATCH")), - cancellationToken: Arg.Any()); + await _graphApiService.Received(1).GraphPatchAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any?>()); } [Fact] public async Task EnsureRedirectUrisAsync_WhenGetFails_LogsWarningAndContinues() { - // Arrange - var token = "test-token"; - - _executor.ExecuteAsync( - Arg.Is(s => s == "az"), - Arg.Is(s => s.Contains("rest --method GET")), - cancellationToken: Arg.Any()) - .Returns(new CommandResult { ExitCode = 1, StandardOutput = string.Empty, StandardError = "Error getting app" }); + // GraphGetAsync returns null (unmatched default) — simulates Graph API failure - // Act - Should not throw - await _validator.EnsureRedirectUrisAsync(ValidClientAppId, token); + await _validator.EnsureRedirectUrisAsync(ValidClientAppId, ValidTenantId); - // Assert - Should not call PATCH - await _executor.DidNotReceive().ExecuteAsync( - Arg.Is(s => s == "az"), - Arg.Is(s => s.Contains("rest --method PATCH")), - cancellationToken: Arg.Any()); + await _graphApiService.DidNotReceive().GraphPatchAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any?>()); } [Fact] public async Task EnsureRedirectUrisAsync_WhenPatchFails_LogsWarningButDoesNotThrow() { - // Arrange - var token = "test-token"; var appResponseJson = $$""" { "value": [{ - "id": "object-id-123", + "id": "{{AppObjId}}", "publicClient": { "redirectUris": [] } }] } """; + SetupRedirectUrisGet(appResponseJson); + // GraphPatchAsync returns false (default) — non-fatal - _executor.ExecuteAsync( - Arg.Is(s => s == "az"), - Arg.Is(s => s.Contains("rest --method GET")), - cancellationToken: Arg.Any()) - .Returns(new CommandResult { ExitCode = 0, StandardOutput = appResponseJson, StandardError = string.Empty }); + await _validator.EnsureRedirectUrisAsync(ValidClientAppId, ValidTenantId); - _executor.ExecuteAsync( - Arg.Is(s => s == "az"), - Arg.Is(s => s.Contains("rest --method PATCH")), - cancellationToken: Arg.Any()) - .Returns(new CommandResult { ExitCode = 1, StandardOutput = string.Empty, StandardError = "Patch failed" }); + await _graphApiService.Received(1).GraphPatchAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any?>()); + } + + #endregion - // Act - Should not throw - await _validator.EnsureRedirectUrisAsync(ValidClientAppId, token); + #region Helper Methods - // Assert - Method completes without exception - await _executor.Received(1).ExecuteAsync( - Arg.Is(s => s == "az"), - Arg.Is(s => s.Contains("rest --method PATCH")), - cancellationToken: Arg.Any()); + /// + /// Sets up the app info GET (select includes displayName) to return an app with the given requiredResourceAccess JSON. + /// Pass "null" to simulate a null requiredResourceAccess; pass "[]" for an empty array. + /// + private void SetupAppInfoGet(string appId, string requiredResourceAccess = "[]") + { + var json = $$""" + { + "value": [ + { + "id": "{{AppObjId}}", + "appId": "{{appId}}", + "displayName": "Test App", + "requiredResourceAccess": {{requiredResourceAccess}} + } + ] + } + """; + + // GetClientAppInfoAsync now calls GraphGetWithResponseAsync; GraphGetAsync is used by + // subsequent steps (permission resolution, consent checks, redirect URIs, etc.). + _graphApiService.GraphGetWithResponseAsync( + Arg.Any(), + Arg.Is(p => p.Contains("displayName")), + Arg.Any(), + Arg.Any?>()) + .Returns(_ => Task.FromResult(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDocument.Parse(json) + })); + } + + /// + /// Sets up the app info GET to return an empty value array (app not found). + /// + private void SetupAppInfoGetEmpty() + { + _graphApiService.GraphGetWithResponseAsync( + Arg.Any(), + Arg.Is(p => p.Contains("displayName")), + Arg.Any(), + Arg.Any?>()) + .Returns(_ => Task.FromResult(new GraphApiService.GraphResponse + { + IsSuccess = true, + StatusCode = 200, + Json = JsonDocument.Parse("""{"value": []}""") + })); } - [Fact] - public async Task EnsureRedirectUrisAsync_EscapesJsonBodyForPowerShell() + /// + /// Sets up the app info GET with all 6 required permissions. + /// The permission GUIDs match those returned by SetupPermissionResolution so validation passes. + /// + private void SetupAppInfoWithAllPermissions(string appId) { - // Arrange - var token = "test-token"; - var appResponseJson = $$""" + var requiredResourceAccess = $$""" + [ + { + "resourceAppId": "{{AuthenticationConstants.MicrosoftGraphResourceAppId}}", + "resourceAccess": [ + {"id": "{{ApplicationReadWriteAllId}}", "type": "Scope"}, + {"id": "{{AgentBlueprintReadWriteAllId}}", "type": "Scope"}, + {"id": "{{AgentBlueprintUpdateAuthId}}", "type": "Scope"}, + {"id": "{{AgentBlueprintAddRemoveCredsId}}", "type": "Scope"}, + {"id": "{{DelegatedPermissionGrantReadWriteAllId}}", "type": "Scope"}, + {"id": "{{DirectoryReadAllId}}", "type": "Scope"} + ] + } + ] + """; + + SetupAppInfoGet(appId, requiredResourceAccess: requiredResourceAccess); + } + + /// + /// Sets up the Microsoft Graph SP permission resolution GET (select includes oauth2PermissionScopes). + /// Returns the 6 required permissions with GUIDs matching the test constants. + /// + private void SetupPermissionResolution() + { + var json = $$""" { - "value": [{ - "id": "object-id-123", - "publicClient": { - "redirectUris": ["http://localhost:8400/"] + "value": [ + { + "id": "graph-sp-id-123", + "oauth2PermissionScopes": [ + {"id": "{{ApplicationReadWriteAllId}}", "value": "Application.ReadWrite.All"}, + {"id": "{{AgentBlueprintReadWriteAllId}}", "value": "AgentIdentityBlueprint.ReadWrite.All"}, + {"id": "{{AgentBlueprintUpdateAuthId}}", "value": "AgentIdentityBlueprint.UpdateAuthProperties.All"}, + {"id": "{{AgentBlueprintAddRemoveCredsId}}", "value": "AgentIdentityBlueprint.AddRemoveCreds.All"}, + {"id": "{{DelegatedPermissionGrantReadWriteAllId}}", "value": "DelegatedPermissionGrant.ReadWrite.All"}, + {"id": "{{DirectoryReadAllId}}", "value": "Directory.Read.All"} + ] + } + ] + } + """; + + _graphApiService.GraphGetAsync( + Arg.Any(), + Arg.Is(p => p.Contains("oauth2PermissionScopes")), + Arg.Any(), + Arg.Any?>()) + .Returns(_ => Task.FromResult(JsonDocument.Parse(json))); + } + + /// + /// Sets up the admin consent SP GET (select includes id,appId — used by ValidateAdminConsentAsync). + /// + private void SetupAdminConsentSp(string clientAppId, string spObjectId) + { + var json = $$""" + { + "value": [ + { + "id": "{{spObjectId}}", + "appId": "{{clientAppId}}" } + ] + } + """; + + _graphApiService.GraphGetAsync( + Arg.Any(), + Arg.Is(p => p.Contains("servicePrincipals") && p.Contains("id,appId")), + Arg.Any(), + Arg.Any?>()) + .Returns(_ => Task.FromResult(JsonDocument.Parse(json))); + } + + /// + /// Sets up the oauth2PermissionGrants GET for a given SP object ID to return no grants. + /// + private void SetupAdminConsentGrantsEmpty(string spObjectId) + { + _graphApiService.GraphGetAsync( + Arg.Any(), + Arg.Is(p => p.Contains("oauth2PermissionGrants") && p.Contains(spObjectId)), + Arg.Any(), + Arg.Any?>()) + .Returns(_ => Task.FromResult(JsonDocument.Parse("""{"value": []}"""))); + } + + /// + /// Sets up the redirect URIs GET (select includes publicClient). + /// + private void SetupRedirectUrisGet(string appResponseJson) + { + _graphApiService.GraphGetAsync( + Arg.Any(), + Arg.Is(p => p.Contains("publicClient") && !p.Contains("displayName")), + Arg.Any(), + Arg.Any?>()) + .Returns(_ => Task.FromResult(JsonDocument.Parse(appResponseJson))); + } + + /// + /// Sets up the public client flows GET (select includes isFallbackPublicClient). + /// + private void SetupPublicClientFlowsGet(bool enabled) + { + var json = $$""" + { + "value": [{ + "id": "{{AppObjId}}", + "isFallbackPublicClient": {{(enabled ? "true" : "false")}} }] } """; - _executor.ExecuteAsync( - Arg.Is(s => s == "az"), - Arg.Is(s => s.Contains("rest --method GET")), - cancellationToken: Arg.Any()) - .Returns(new CommandResult { ExitCode = 0, StandardOutput = appResponseJson, StandardError = string.Empty }); - - _executor.ExecuteAsync( - Arg.Is(s => s == "az"), - Arg.Is(s => s.Contains("rest --method PATCH")), - cancellationToken: Arg.Any()) - .Returns(new CommandResult { ExitCode = 0, StandardOutput = "{}", StandardError = string.Empty }); - - // Act - await _validator.EnsureRedirectUrisAsync(ValidClientAppId, token); - - // Assert - Verify JSON body is properly escaped with double quotes for PowerShell - await _executor.Received(1).ExecuteAsync( - Arg.Is(s => s == "az"), - Arg.Is(s => - s.Contains("rest --method PATCH") && - // Should use --body "..." with escaped quotes (not --body '...') - s.Contains("--body \"") && - // JSON should have doubled quotes: ""publicClient"" - s.Contains("\"\"publicClient\"\"") && - s.Contains("\"\"redirectUris\"\"") && - // Should NOT use single quotes around body - !s.Contains("--body '")), - cancellationToken: Arg.Any()); + _graphApiService.GraphGetAsync( + Arg.Any(), + Arg.Is(p => p.Contains("isFallbackPublicClient")), + Arg.Any(), + Arg.Any?>()) + .Returns(_ => Task.FromResult(JsonDocument.Parse(json))); } #endregion } - diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/CommandExecutorTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/CommandExecutorTests.cs index 4bb87cde..c7f69d67 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/CommandExecutorTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/CommandExecutorTests.cs @@ -9,6 +9,13 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; +// The cancellation regression tests snapshot process lists by name (ping/sleep). +// Running them in parallel risks false failures when unrelated processes with the same +// name start concurrently. DisableParallelization ensures stable process-list assertions. +[CollectionDefinition("CommandExecutorTests", DisableParallelization = true)] +public class CommandExecutorTestsCollection { } + +[Collection("CommandExecutorTests")] public class CommandExecutorTests { private readonly ILogger _logger; @@ -289,4 +296,106 @@ public void IsJwtToken_WithInvalidJwtFormat_ShouldNotDetect(string invalidToken) } #endregion + + #region Regression Tests for Ctrl+C / process cancellation + + [Fact] + public async Task ExecuteAsync_WhenCancelled_KillsChildProcessAndThrows() + { + // REGRESSION TEST: Verify that cancelling WaitForExitAsync kills the child process. + // Before the fix, Ctrl+C left a zombie az subprocess holding stdin, blocking the console. + // NOTE: WaitForExitAsync throws OperationCanceledException regardless of whether Kill() + // is called. The meaningful assertion is that no child process survives after cancellation. + using var cts = new CancellationTokenSource(); + + var command = OperatingSystem.IsWindows() ? "cmd.exe" : "sleep"; + // Runs for 30 s — still alive when we cancel at 300 ms. + var args = OperatingSystem.IsWindows() ? "/c ping -n 30 127.0.0.1" : "30"; + var childName = OperatingSystem.IsWindows() ? "ping" : "sleep"; + + // Snapshot existing child process IDs before we start, so we can identify ours. + var pidsBefore = System.Diagnostics.Process.GetProcessesByName(childName) + .Select(p => p.Id).ToHashSet(); + + // captureOutput:false avoids output-stream EOF waiting in WaitForExitAsync, making cancellation faster. + var invokeTask = _executor.ExecuteAsync(command, args, captureOutput: false, cancellationToken: cts.Token); + await Task.Delay(100); + cts.Cancel(); + + // Must throw OperationCanceledException and must not hang. + await Assert.ThrowsAnyAsync(() => invokeTask) + .WaitAsync(TimeSpan.FromSeconds(5)); // timeout proves the zombie fix: without Kill() the console would block + + // Give the OS a moment to propagate the kill signal. + await Task.Delay(100); + + // No child processes spawned by this test should still be running. + var zombiePids = System.Diagnostics.Process.GetProcessesByName(childName) + .Select(p => p.Id).Except(pidsBefore).ToList(); + + zombiePids.Should().BeEmpty( + because: "executor must kill child processes on cancellation; " + + "surviving PIDs indicate the zombie-process regression is reintroduced"); + } + + [Fact] + public async Task ExecuteWithStreamingAsync_WhenCancelled_KillsChildProcessAndThrows() + { + // REGRESSION TEST: Same as above but for the streaming path. + using var cts = new CancellationTokenSource(); + + var command = OperatingSystem.IsWindows() ? "cmd.exe" : "sleep"; + var args = OperatingSystem.IsWindows() ? "/c ping -n 30 127.0.0.1" : "30"; + var childName = OperatingSystem.IsWindows() ? "ping" : "sleep"; + + var pidsBefore = System.Diagnostics.Process.GetProcessesByName(childName) + .Select(p => p.Id).ToHashSet(); + + var invokeTask = _executor.ExecuteWithStreamingAsync(command, args, cancellationToken: cts.Token); + await Task.Delay(100); + cts.Cancel(); + + // Must throw OperationCanceledException and must not hang. + await Assert.ThrowsAnyAsync(() => invokeTask) + .WaitAsync(TimeSpan.FromSeconds(5)); // timeout proves the zombie fix: without Kill() the console would block + + await Task.Delay(100); + + var zombiePids = System.Diagnostics.Process.GetProcessesByName(childName) + .Select(p => p.Id).Except(pidsBefore).ToList(); + + zombiePids.Should().BeEmpty( + because: "executor must kill child processes on cancellation (streaming path); " + + "surviving PIDs indicate the zombie-process regression is reintroduced"); + } + + #endregion + + #region Regression Tests for non-actionable stderr suppression + + [Theory] + [InlineData("UserWarning: You are using cryptography on a 32-bit Python on a 64-bit Windows Operating System.", true)] + [InlineData(" UserWarning: leading whitespace", true)] + [InlineData("userwarning: case-insensitive match", true)] + [InlineData("Readonly attribute name will be ignored in class ", true)] + [InlineData("warnings.warn(msg, category)", true)] + [InlineData("ERROR: Operation cannot be completed without additional quota.", false)] + [InlineData("Cannot create App Service Plan", false)] + [InlineData("", false)] + public void IsNonActionableStderrLine_FiltersCorrectly(string line, bool expectedFiltered) + { + // REGRESSION TEST: Python UserWarning and az-SDK Readonly warnings must be suppressed + // from the console so they do not appear as fake ERRORs alongside real failure messages. + // Before the fix, lines like "ERROR: Error output: ...UserWarning: You are using + // cryptography on a 32-bit Python..." were shown to the user as if they were the root cause. + var method = typeof(CommandExecutor).GetMethod("IsNonActionableStderrLine", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static); + + var result = (bool)method!.Invoke(null, new object[] { line })!; + + result.Should().Be(expectedFiltered, + because: "non-actionable Python/az-CLI diagnostics must be suppressed; real error lines must pass through"); + } + + #endregion } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/DotNetSdkValidationTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/DotNetSdkValidationTests.cs index d0157eb1..6b8e7754 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/DotNetSdkValidationTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/DotNetSdkValidationTests.cs @@ -332,66 +332,61 @@ public async Task ResolveDotNetRuntimeVersion_WhenCancelledDuringRetry_ThrowsOpe } /// - /// Test that exponential backoff respects the maximum delay cap + /// Verifies that the retry loop observes the injected delay between attempts. + /// Uses a small but detectable override (50ms base → 50ms, 100ms) so the test + /// completes in ~150ms instead of ~1500ms while still proving delays are applied. + /// The mathematical formula (InitialRetryDelayMs, MaxRetryDelayMs, exponential growth) + /// is covered separately by . /// [Fact] - public async Task ResolveDotNetRuntimeVersion_ExponentialBackoff_RespectsMaximumDelayCap() + public async Task ResolveDotNetRuntimeVersion_ExponentialBackoff_AppliesDelaysBetweenAttempts() { // Arrange CreateTestProject("net8.0"); - + + const int delayOverrideMs = 50; var callTimes = new List(); - - // Mock: All attempts fail to test full retry sequence + + // Mock: All attempts fail to exercise the full retry sequence _commandExecutor.ExecuteAsync("dotnet", "--version", captureOutput: true, cancellationToken: Arg.Any()) .Returns(callInfo => { callTimes.Add(DateTime.UtcNow); - var callNumber = callTimes.Count; - - _output.WriteLine($"Attempt {callNumber} at {callTimes.Last():HH:mm:ss.fff}"); - - return Task.FromResult(new CommandResult - { - ExitCode = 1, - StandardError = "dotnet command failed" - }); + _output.WriteLine($"Attempt {callTimes.Count} at {callTimes.Last():HH:mm:ss.fff}"); + return Task.FromResult(new CommandResult { ExitCode = 1, StandardError = "dotnet command failed" }); }); - - // Act + + // Act — small non-zero override: detectable delay without real production waits try { await InvokeResolveDotNetRuntimeVersionAsync( ProjectPlatform.DotNet, _testProjectPath, - CancellationToken.None); + CancellationToken.None, + retryDelayMsOverride: delayOverrideMs); } catch (DotNetSdkVersionMismatchException) { - // Expected - all retries failed + // Expected — all retries failed } - - // Assert - Verify exponential backoff delays + + // Assert — all attempts ran callTimes.Should().HaveCount(3); // MaxSdkValidationAttempts = 3 - + + // Assert — a measurable delay was applied between each attempt (at least half the override) if (callTimes.Count >= 2) { var delay1 = (callTimes[1] - callTimes[0]).TotalMilliseconds; - _output.WriteLine($"Delay between attempt 1 and 2: {delay1}ms (expected ~500ms)"); - - // Allow some tolerance for execution time - delay1.Should().BeGreaterOrEqualTo(450).And.BeLessThan(1500); + _output.WriteLine($"Delay 1→2: {delay1}ms (expected ≥{delayOverrideMs / 2}ms)"); + delay1.Should().BeGreaterOrEqualTo(delayOverrideMs / 2); } - + if (callTimes.Count >= 3) { var delay2 = (callTimes[2] - callTimes[1]).TotalMilliseconds; - _output.WriteLine($"Delay between attempt 2 and 3: {delay2}ms (expected ~1000ms)"); - - delay2.Should().BeGreaterOrEqualTo(950).And.BeLessThan(2500); + _output.WriteLine($"Delay 2→3: {delay2}ms (expected ≥{delayOverrideMs / 2}ms)"); + delay2.Should().BeGreaterOrEqualTo(delayOverrideMs / 2); } - - _output.WriteLine("Exponential backoff delays verified: 500ms -> 1000ms"); } /// @@ -451,14 +446,15 @@ private string CreateTestProject(string targetFramework) } private async Task InvokeResolveDotNetRuntimeVersionAsync( - ProjectPlatform platform, + ProjectPlatform platform, string projectPath, - CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default, + int? retryDelayMsOverride = 0) { // Use reflection to call the private static async method var infrastructureType = typeof(InfrastructureSubcommand); var method = infrastructureType.GetMethod( - "ResolveDotNetRuntimeVersionAsync", + "ResolveDotNetRuntimeVersionAsync", BindingFlags.NonPublic | BindingFlags.Static); if (method == null) @@ -468,20 +464,21 @@ private string CreateTestProject(string targetFramework) try { - var task = method.Invoke(null, new object[] - { - platform, - projectPath, - _commandExecutor, + var task = method.Invoke(null, new object?[] + { + platform, + projectPath, + _commandExecutor, _logger, - cancellationToken + cancellationToken, + retryDelayMsOverride }) as Task; - + if (task == null) { throw new InvalidOperationException("Method did not return a Task"); } - + return await task; } catch (TargetInvocationException ex) diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/FederatedCredentialServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/FederatedCredentialServiceTests.cs index ea729d71..7e0560ce 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/FederatedCredentialServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/FederatedCredentialServiceTests.cs @@ -62,7 +62,8 @@ public async Task GetFederatedCredentialsAsync_WhenCredentialsExist_ReturnsListO _graphApiService.GraphGetAsync( TestTenantId, $"/beta/applications/{TestBlueprintObjectId}/federatedIdentityCredentials", - Arg.Any()) + Arg.Any(), + Arg.Any?>()) .Returns(jsonDoc); // Act @@ -86,7 +87,8 @@ public async Task GetFederatedCredentialsAsync_WhenNoCredentials_ReturnsEmptyLis _graphApiService.GraphGetAsync( TestTenantId, $"/beta/applications/{TestBlueprintObjectId}/federatedIdentityCredentials", - Arg.Any()) + Arg.Any(), + Arg.Any?>()) .Returns(jsonDoc); // Act @@ -117,7 +119,8 @@ public async Task CheckFederatedCredentialExistsAsync_WhenMatchingCredentialExis _graphApiService.GraphGetAsync( TestTenantId, $"/beta/applications/{TestBlueprintObjectId}/federatedIdentityCredentials", - Arg.Any()) + Arg.Any(), + Arg.Any?>()) .Returns(jsonDoc); // Act @@ -155,7 +158,8 @@ public async Task CheckFederatedCredentialExistsAsync_WhenNoMatchingCredential_R _graphApiService.GraphGetAsync( TestTenantId, $"/beta/applications/{TestBlueprintObjectId}/federatedIdentityCredentials", - Arg.Any()) + Arg.Any(), + Arg.Any?>()) .Returns(jsonDoc); // Act @@ -191,7 +195,8 @@ public async Task CheckFederatedCredentialExistsAsync_IsCaseInsensitive() _graphApiService.GraphGetAsync( TestTenantId, $"/beta/applications/{TestBlueprintObjectId}/federatedIdentityCredentials", - Arg.Any()) + Arg.Any(), + Arg.Any?>()) .Returns(jsonDoc); // Act - Pass in different casing @@ -397,7 +402,8 @@ public async Task GetFederatedCredentialsAsync_OnException_ReturnsEmptyList() _graphApiService.GraphGetAsync( TestTenantId, Arg.Any(), - Arg.Any()) + Arg.Any(), + Arg.Any?>()) .Throws(new Exception("Network error")); // Act @@ -422,7 +428,8 @@ public async Task GetFederatedCredentialsAsync_WhenStandardEndpointReturnsEmpty_ _graphApiService.GraphGetAsync( TestTenantId, standardEndpoint, - Arg.Any()) + Arg.Any(), + Arg.Any?>()) .Returns(emptyJsonDoc); // Fallback endpoint returns credentials @@ -442,7 +449,8 @@ public async Task GetFederatedCredentialsAsync_WhenStandardEndpointReturnsEmpty_ _graphApiService.GraphGetAsync( TestTenantId, fallbackEndpoint, - Arg.Any()) + Arg.Any(), + Arg.Any?>()) .Returns(fallbackJsonDoc); // Act @@ -458,12 +466,14 @@ public async Task GetFederatedCredentialsAsync_WhenStandardEndpointReturnsEmpty_ await _graphApiService.Received(1).GraphGetAsync( TestTenantId, standardEndpoint, - Arg.Any()); + Arg.Any(), + Arg.Any?>()); await _graphApiService.Received(1).GraphGetAsync( TestTenantId, fallbackEndpoint, - Arg.Any()); + Arg.Any(), + Arg.Any?>()); } [Fact] @@ -501,7 +511,8 @@ public async Task GetFederatedCredentialsAsync_WithMalformedCredentials_ReturnsO _graphApiService.GraphGetAsync( TestTenantId, $"/beta/applications/{TestBlueprintObjectId}/federatedIdentityCredentials", - Arg.Any()) + Arg.Any(), + Arg.Any?>()) .Returns(jsonDoc); // Act diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceAddRequiredResourceAccessTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceAddRequiredResourceAccessTests.cs index 542a8438..909846c4 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceAddRequiredResourceAccessTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceAddRequiredResourceAccessTests.cs @@ -5,13 +5,20 @@ using System.Text.Json; using FluentAssertions; using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; -public class AgentBlueprintServiceAddRequiredResourceAccessTests +/// +/// Isolated from other tests because AzCliHelper token cache is static state. +/// Without isolation, parallel tests calling ResetAzCliTokenCacheForTesting() clear +/// the warmup and cause real az subprocess spawns (~20s per test). +/// +[Collection("AgentBlueprintServiceAddRequiredResourceAccessTests")] +public class AgentBlueprintServiceAddRequiredResourceAccessTests : IDisposable { private const string TenantId = "test-tenant-id"; private const string AppId = "test-app-id"; @@ -19,6 +26,14 @@ public class AgentBlueprintServiceAddRequiredResourceAccessTests private const string ObjectId = "object-id-123"; private const string SpObjectId = "sp-object-id-456"; + public AgentBlueprintServiceAddRequiredResourceAccessTests() + { + AzCliHelper.ResetAzCliTokenCacheForTesting(); + AzCliHelper.WarmAzCliTokenCache("https://graph.microsoft.com/", TenantId, "fake-graph-token"); + } + + public void Dispose() => AzCliHelper.ResetAzCliTokenCacheForTesting(); + [Fact] public async Task AddRequiredResourceAccessAsync_Success_WithValidPermissionIds() { @@ -312,3 +327,6 @@ private static void QueuePatchResponse(FakeHttpMessageHandler handler) handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.NoContent)); } } + +[CollectionDefinition("AgentBlueprintServiceAddRequiredResourceAccessTests", DisableParallelization = true)] +public class AgentBlueprintServiceAddRequiredResourceAccessTestCollection { } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceIsApplicationOwnerTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceIsApplicationOwnerTests.cs index d245147d..9ae8d3b1 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceIsApplicationOwnerTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceIsApplicationOwnerTests.cs @@ -6,6 +6,7 @@ using System.Text.Json; using FluentAssertions; using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -22,6 +23,7 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; /// all queued responses in their Dispose methods. Suppressing CA2000 for this pattern. /// #pragma warning disable CA2000 // Dispose objects before losing scope +[Collection("AzCliTokenCache")] public class GraphApiServiceIsApplicationOwnerTests { private readonly ILogger _mockLogger; @@ -32,6 +34,8 @@ public GraphApiServiceIsApplicationOwnerTests() _mockLogger = Substitute.For>(); var mockExecutorLogger = Substitute.For>(); _mockExecutor = Substitute.ForPartsOf(mockExecutorLogger); + AzCliHelper.ResetAzCliTokenCacheForTesting(); + AzCliHelper.WarmAzCliTokenCache("https://graph.microsoft.com/", "tenant-123", "fake-graph-token"); // Mock Azure CLI authentication _mockExecutor.ExecuteAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs index c9be1f8d..7866a283 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTests.cs @@ -5,13 +5,16 @@ using System.Net.Http; using System.Text.Json; using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Models; using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; +[Collection("AzCliTokenCache")] public class GraphApiServiceTests { private readonly ILogger _mockLogger; @@ -24,6 +27,9 @@ public GraphApiServiceTests() var mockExecutorLogger = Substitute.For>(); _mockExecutor = Substitute.ForPartsOf(mockExecutorLogger); _mockTokenProvider = Substitute.For(); + AzCliHelper.ResetAzCliTokenCacheForTesting(); + AzCliHelper.WarmAzCliTokenCache("https://graph.microsoft.com/", "tenant-123", "fake-graph-token"); + AzCliHelper.WarmAzCliTokenCache("https://graph.microsoft.com/", "tid", "fake-graph-token"); } @@ -47,7 +53,7 @@ public async Task GraphPostWithResponseAsync_Returns_Success_And_ParsesJson() return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty }); }); - var service = new GraphApiService(logger, executor, handler); + var service = new GraphApiService(logger, executor, handler, loginHintResolver: () => Task.FromResult(null)); // Queue successful POST with JSON body var bodyObj = new { result = "ok" }; @@ -88,7 +94,7 @@ public async Task GraphPostWithResponseAsync_Returns_Failure_With_Body() return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty }); }); - var service = new GraphApiService(logger, executor, handler); + var service = new GraphApiService(logger, executor, handler, loginHintResolver: () => Task.FromResult(null)); // Queue failing POST with JSON error body var errorBody = new { error = new { code = "Authorization_RequestDenied", message = "Insufficient privileges" } }; @@ -156,7 +162,7 @@ public async Task LookupServicePrincipalAsync_DoesNotIncludeConsistencyLevelHead }); // Create GraphApiService with our capturing handler - var service = new GraphApiService(logger, executor, handler); + var service = new GraphApiService(logger, executor, handler, loginHintResolver: () => Task.FromResult(null)); // Queue response for service principal lookup var spResponse = new { value = new[] { new { id = "sp-object-id-123", appId = "blueprint-456" } } }; @@ -238,7 +244,7 @@ public async Task GraphGetAsync_SanitizesTokenWithNewlineCharacters(string token return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty }); }); - var service = new GraphApiService(logger, executor, handler); + var service = new GraphApiService(logger, executor, handler, loginHintResolver: () => Task.FromResult(null)); // Queue a successful response using var queuedResponse = new HttpResponseMessage(HttpStatusCode.OK) @@ -283,10 +289,11 @@ public async Task GraphGetAsync_TokenFromTokenProvider_SanitizesNewlines() Arg.Any>(), Arg.Any(), Arg.Any(), - Arg.Any()) + Arg.Any(), + Arg.Any()) .Returns("token-from-provider\r\nwith-embedded-newlines\n"); - var service = new GraphApiService(logger, executor, handler, tokenProvider); + var service = new GraphApiService(logger, executor, handler, tokenProvider, loginHintResolver: () => Task.FromResult(null)); // Queue a successful response using var queuedResponse = new HttpResponseMessage(HttpStatusCode.OK) @@ -315,6 +322,12 @@ public async Task CheckServicePrincipalCreationPrivilegesAsync_SanitizesTokenWit // sanitizes tokens with newlines. This method has its own token handling code // separate from EnsureGraphHeadersAsync. + // Overwrite the "fake-graph-token" warmed in the constructor with a token that has + // embedded newlines. GetGraphAccessTokenAsync returns it from the process-level cache; + // CheckServicePrincipalCreationPrivilegesAsync must trim it before using it in the + // Authorization header. Warming directly avoids spawning a real az subprocess. + AzCliHelper.WarmAzCliTokenCache("https://graph.microsoft.com/", "tenant-123", "privileges-check-token\r\n\n"); + // Arrange HttpRequestMessage? capturedRequest = null; var handler = new CapturingHttpMessageHandler((req) => capturedRequest = req); @@ -352,7 +365,7 @@ public async Task CheckServicePrincipalCreationPrivilegesAsync_SanitizesTokenWit return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty }); }); - var service = new GraphApiService(logger, executor, handler); + var service = new GraphApiService(logger, executor, handler, loginHintResolver: () => Task.FromResult(null)); // Queue a successful response for the directory roles query using var queuedResponse = new HttpResponseMessage(HttpStatusCode.OK) @@ -402,7 +415,7 @@ public async Task GetServicePrincipalDisplayNameAsync_SuccessfulLookup_ReturnsDi return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty }); }); - var service = new GraphApiService(logger, executor, handler); + var service = new GraphApiService(logger, executor, handler, loginHintResolver: () => Task.FromResult(null)); // Queue successful response with Microsoft Graph service principal var spResponse = new { value = new[] { new { displayName = "Microsoft Graph" } } }; @@ -438,7 +451,7 @@ public async Task GetServicePrincipalDisplayNameAsync_ServicePrincipalNotFound_R return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty }); }); - var service = new GraphApiService(logger, executor, handler); + var service = new GraphApiService(logger, executor, handler, loginHintResolver: () => Task.FromResult(null)); // Queue response with empty array (service principal not found) var spResponse = new { value = Array.Empty() }; @@ -474,7 +487,7 @@ public async Task GetServicePrincipalDisplayNameAsync_NullResponse_ReturnsNull() return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty }); }); - var service = new GraphApiService(logger, executor, handler); + var service = new GraphApiService(logger, executor, handler, loginHintResolver: () => Task.FromResult(null)); // Queue error response (simulating network error or Graph API error) handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.InternalServerError) @@ -509,7 +522,7 @@ public async Task GetServicePrincipalDisplayNameAsync_MissingDisplayNameProperty return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty }); }); - var service = new GraphApiService(logger, executor, handler); + var service = new GraphApiService(logger, executor, handler, loginHintResolver: () => Task.FromResult(null)); // Queue response with malformed object (missing displayName) var spResponse = new { value = new[] { new { id = "sp-id-123", appId = "00000003-0000-0000-c000-000000000000" } } }; @@ -526,6 +539,248 @@ public async Task GetServicePrincipalDisplayNameAsync_MissingDisplayNameProperty } #endregion + + #region IsCurrentUserAdminAsync + + [Fact] + public async Task IsCurrentUserAdminAsync_UserWithGlobalAdminRole_ReturnsHasRole() + { + // Arrange — user holds the Global Administrator role + using var handler = new TestHttpMessageHandler(); + var service = CreateServiceWithTokenProvider(handler); + + var rolesResponse = new + { + value = new[] + { + new { roleTemplateId = "62e90394-69f5-4237-9190-012177145e10" } // Global Administrator + } + }; + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(rolesResponse)) + }); + + // Act + var result = await service.IsCurrentUserAdminAsync("tenant-123"); + + // Assert + result.Should().Be(RoleCheckResult.HasRole, "a user holding the Global Administrator role should pass the admin check"); + } + + [Fact] + public async Task IsCurrentUserAdminAsync_UserWithNoAdminRole_ReturnsDoesNotHaveRole() + { + // Arrange — user has no admin roles + using var handler = new TestHttpMessageHandler(); + var service = CreateServiceWithTokenProvider(handler); + + var rolesResponse = new { value = Array.Empty() }; + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(rolesResponse)) + }); + + // Act + var result = await service.IsCurrentUserAdminAsync("tenant-123"); + + // Assert + result.Should().Be(RoleCheckResult.DoesNotHaveRole, "a user with no admin role should not pass the Global Administrator check"); + } + + [Fact] + public async Task IsCurrentUserAdminAsync_GraphFails_ReturnsUnknown() + { + // Arrange — Graph call fails (500 causes GraphGetAsync to return null) + using var handler = new TestHttpMessageHandler(); + var service = CreateServiceWithTokenProvider(handler); + + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.InternalServerError)); + + // Act + var result = await service.IsCurrentUserAdminAsync("tenant-123"); + + // Assert + result.Should().Be(RoleCheckResult.Unknown, "a failed Graph call should return Unknown, not DoesNotHaveRole"); + } + + #endregion + + #region IsCurrentUserAgentIdAdminAsync + + private static GraphApiService CreateServiceWithTokenProvider(TestHttpMessageHandler handler) + { + var logger = Substitute.For>(); + var executor = Substitute.For(Substitute.For>()); + var tokenProvider = Substitute.For(); + tokenProvider.GetMgGraphAccessTokenAsync( + Arg.Any(), Arg.Any>(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns("fake-token"); + return new GraphApiService(logger, executor, handler, tokenProvider, loginHintResolver: () => Task.FromResult(null)); + } + + [Fact] + public async Task IsCurrentUserAgentIdAdminAsync_UserWithNoRelevantRole_ReturnsDoesNotHaveRole() + { + // Arrange — user is an Agent ID developer (no admin roles) + using var handler = new TestHttpMessageHandler(); + var service = CreateServiceWithTokenProvider(handler); + + var rolesResponse = new { value = Array.Empty() }; + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(rolesResponse)) + }); + + // Act + var result = await service.IsCurrentUserAgentIdAdminAsync("tenant-123"); + + // Assert + result.Should().Be(RoleCheckResult.DoesNotHaveRole, "a developer with no admin roles should not pass the Agent ID Administrator check"); + } + + [Fact] + public async Task IsCurrentUserAgentIdAdminAsync_UserWithAgentIdAdminRole_ReturnsHasRole() + { + // Arrange — user holds the Agent ID Administrator role + using var handler = new TestHttpMessageHandler(); + var service = CreateServiceWithTokenProvider(handler); + + var rolesResponse = new + { + value = new[] + { + new { roleTemplateId = "db506228-d27e-4b7d-95e5-295956d6615f" } // Agent ID Administrator + } + }; + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(rolesResponse)) + }); + + // Act + var result = await service.IsCurrentUserAgentIdAdminAsync("tenant-123"); + + // Assert + result.Should().Be(RoleCheckResult.HasRole, "a user holding the Agent ID Administrator role should pass the check"); + } + + [Fact] + public async Task IsCurrentUserAgentIdAdminAsync_UserWithGlobalAdminRoleOnly_ReturnsDoesNotHaveRole() + { + // Arrange — user is a Global Administrator but not an Agent ID Administrator + using var handler = new TestHttpMessageHandler(); + var service = CreateServiceWithTokenProvider(handler); + + // User holds Global Administrator only — not Agent ID Administrator + var rolesResponse = new + { + value = new[] + { + new { roleTemplateId = "62e90394-69f5-4237-9190-012177145e10" } // Global Administrator + } + }; + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(rolesResponse)) + }); + + // Act + var result = await service.IsCurrentUserAgentIdAdminAsync("tenant-123"); + + // Assert + result.Should().Be(RoleCheckResult.DoesNotHaveRole, "Global Administrator alone does not satisfy the Agent ID Administrator role requirement"); + } + + [Fact] + public async Task IsCurrentUserAgentIdAdminAsync_GraphReturnsNull_ReturnsUnknown() + { + // Arrange — Graph call fails (null response simulates network/auth error) + using var handler = new TestHttpMessageHandler(); + var service = CreateServiceWithTokenProvider(handler); + + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.InternalServerError)); + + // Act + var result = await service.IsCurrentUserAgentIdAdminAsync("tenant-123"); + + // Assert + result.Should().Be(RoleCheckResult.Unknown, "a failed Graph call should return Unknown, not DoesNotHaveRole"); + } + + #endregion + + #region GetCurrentUserObjectIdAsync + + [Fact] + public async Task GetCurrentUserObjectIdAsync_WhenGraphReturnsId_ReturnsObjectId() + { + using var handler = new TestHttpMessageHandler(); + var service = CreateServiceWithTokenProvider(handler); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"id\":\"user-obj-id-123\"}") + }); + + var result = await service.GetCurrentUserObjectIdAsync("tenant-123"); + + result.Should().Be("user-obj-id-123", + because: "the object ID is read from the 'id' property of the /me response"); + } + + [Fact] + public async Task GetCurrentUserObjectIdAsync_WhenGraphFails_ReturnsNull() + { + using var handler = new TestHttpMessageHandler(); + var service = CreateServiceWithTokenProvider(handler); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.Unauthorized) + { + Content = new StringContent(string.Empty) + }); + + var result = await service.GetCurrentUserObjectIdAsync("tenant-123"); + + result.Should().BeNull(because: "a failed Graph call should return null so the caller can fall back to az CLI"); + } + + #endregion + + #region ServicePrincipalExistsAsync + + [Fact] + public async Task ServicePrincipalExistsAsync_WhenSpFound_ReturnsTrue() + { + using var handler = new TestHttpMessageHandler(); + var service = CreateServiceWithTokenProvider(handler); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"id\":\"sp-obj-id\"}") + }); + + var result = await service.ServicePrincipalExistsAsync("tenant-123", "sp-obj-id"); + + result.Should().BeTrue(because: "a 200 response means the service principal is visible in the tenant"); + } + + [Fact] + public async Task ServicePrincipalExistsAsync_WhenSpNotFound_ReturnsFalse() + { + // MSI propagation polling: SP is not yet visible immediately after creation. + using var handler = new TestHttpMessageHandler(); + var service = CreateServiceWithTokenProvider(handler); + handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.NotFound) + { + Content = new StringContent(string.Empty) + }); + + var result = await service.ServicePrincipalExistsAsync("tenant-123", "sp-obj-id"); + + result.Should().BeFalse( + because: "a 404 means the service principal has not yet propagated — the retry loop should keep polling"); + } + + #endregion } // Simple test handler that returns queued responses sequentially diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTokenCacheTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTokenCacheTests.cs index bb65ed76..93e9d9da 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTokenCacheTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTokenCacheTests.cs @@ -5,6 +5,7 @@ using System.Net.Http; using FluentAssertions; using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -12,208 +13,207 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; /// -/// Tests to validate that Azure CLI Graph tokens are cached across consecutive -/// Graph API calls, avoiding redundant 'az' subprocess spawns. +/// Tests to validate that Azure CLI Graph tokens are cached at the process level +/// (via AzCliHelper) so a single CLI invocation only spawns one 'az' subprocess +/// per (resource, tenantId) pair, regardless of how many GraphApiService instances +/// or callers request the same token. /// -public class GraphApiServiceTokenCacheTests +[Collection("GraphApiServiceTokenCacheTests")] +public class GraphApiServiceTokenCacheTests : IDisposable { + public GraphApiServiceTokenCacheTests() + { + AzCliHelper.AzCliTokenAcquirerOverride = null; + AzCliHelper.ResetAzCliTokenCacheForTesting(); + } + + public void Dispose() + { + AzCliHelper.AzCliTokenAcquirerOverride = null; + AzCliHelper.ResetAzCliTokenCacheForTesting(); + } + /// - /// Helper: create a GraphApiService with a mock executor that counts calls - /// and returns a predictable token. + /// Sets the process-level token acquirer override and returns a counter reference. + /// The override is invoked inside GetOrAdd, so the cache still applies — only one + /// invocation per (resource, tenantId) key within a test. /// - private static (GraphApiService service, TestHttpMessageHandler handler, CommandExecutor executor) CreateService(string token = "cached-token") + private static int[] SetupTokenAcquirerWithCounter(string token = "cached-token") + { + var callCount = new int[1]; + AzCliHelper.AzCliTokenAcquirerOverride = (resource, tenantId) => + { + callCount[0]++; + return Task.FromResult(token); + }; + return callCount; + } + + private static (GraphApiService service, TestHttpMessageHandler handler) CreateService() { var handler = new TestHttpMessageHandler(); var logger = Substitute.For>(); var executor = Substitute.For(Substitute.For>()); + // 'az account show' is still used in GetGraphAccessTokenAsync for the auth-check + // fallback path; stub it to succeed so tests that hit the fallback don't hang. executor.ExecuteAsync( Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(callInfo => { - var cmd = callInfo.ArgAt(0); var args = callInfo.ArgAt(1); - if (cmd == "az" && args != null && args.StartsWith("account show", StringComparison.OrdinalIgnoreCase)) + if (args != null && args.StartsWith("account show", StringComparison.OrdinalIgnoreCase)) return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = "{}", StandardError = string.Empty }); - if (cmd == "az" && args != null && args.Contains("get-access-token", StringComparison.OrdinalIgnoreCase)) - return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = token, StandardError = string.Empty }); return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty }); }); var service = new GraphApiService(logger, executor, handler); - return (service, handler, executor); + return (service, handler); } [Fact] public async Task MultipleGraphGetAsync_SameTenant_AcquiresTokenOnlyOnce() { - // Arrange - var (service, handler, executor) = CreateService(); + var callCount = SetupTokenAcquirerWithCounter(); + var (service, handler) = CreateService(); try { - // Queue 3 successful GET responses for (int i = 0; i < 3; i++) - { handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("{\"value\":[]}") - }); - } - - // Act - make 3 consecutive Graph GET calls to the same tenant - var r1 = await service.GraphGetAsync("tenant-1", "/v1.0/path1"); - var r2 = await service.GraphGetAsync("tenant-1", "/v1.0/path2"); - var r3 = await service.GraphGetAsync("tenant-1", "/v1.0/path3"); - - // Assert - all calls should succeed - r1.Should().NotBeNull(); - r2.Should().NotBeNull(); - r3.Should().NotBeNull(); - - // The token should be acquired only ONCE (1 account show + 1 get-access-token = 2 az calls) - await executor.Received(1).ExecuteAsync( - "az", - Arg.Is(s => s.Contains("get-access-token")), - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); - - await executor.Received(1).ExecuteAsync( - "az", - Arg.Is(s => s.Contains("account show")), - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); - } - finally - { - handler.Dispose(); + { Content = new StringContent("{\"value\":[]}") }); + + await service.GraphGetAsync("tenant-1", "/v1.0/path1"); + await service.GraphGetAsync("tenant-1", "/v1.0/path2"); + await service.GraphGetAsync("tenant-1", "/v1.0/path3"); + + callCount[0].Should().Be(1, + because: "the process-level cache must serve the same (resource, tenant) token " + + "from the first acquisition — re-running az account get-access-token on every " + + "Graph call within a single command costs 20-40s per call"); } + finally { handler.Dispose(); } } [Fact] public async Task GraphGetAsync_DifferentTenants_AcquiresTokenForEach() { - // Arrange - var (service, handler, executor) = CreateService(); + var callCount = SetupTokenAcquirerWithCounter(); + var (service, handler) = CreateService(); try { - // Queue 2 responses for (int i = 0; i < 2; i++) - { handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("{\"value\":[]}") - }); - } - - // Act - make calls to different tenants - var r1 = await service.GraphGetAsync("tenant-1", "/v1.0/path1"); - var r2 = await service.GraphGetAsync("tenant-2", "/v1.0/path2"); - - // Assert - r1.Should().NotBeNull(); - r2.Should().NotBeNull(); - - // Token should be acquired twice (once per tenant) - await executor.Received(2).ExecuteAsync( - "az", - Arg.Is(s => s.Contains("get-access-token")), - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); - } - finally - { - handler.Dispose(); + { Content = new StringContent("{\"value\":[]}") }); + + await service.GraphGetAsync("tenant-1", "/v1.0/path1"); + await service.GraphGetAsync("tenant-2", "/v1.0/path2"); + + callCount[0].Should().Be(2, + because: "different tenant IDs are different cache keys — each tenant requires " + + "its own 'az account get-access-token --tenant' call"); } + finally { handler.Dispose(); } } [Fact] public async Task MixedGraphOperations_SameTenant_AcquiresTokenOnlyOnce() { - // Arrange - var (service, handler, executor) = CreateService(); + var callCount = SetupTokenAcquirerWithCounter(); + var (service, handler) = CreateService(); try { - // Queue responses for GET, POST, GET sequence handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("{\"value\":[]}") - }); + { Content = new StringContent("{\"value\":[]}") }); handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("{\"id\":\"123\"}") - }); + { Content = new StringContent("{\"id\":\"123\"}") }); handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("{\"value\":[]}") - }); + { Content = new StringContent("{\"value\":[]}") }); - // Act - interleave GET and POST calls - var r1 = await service.GraphGetAsync("tenant-1", "/v1.0/path1"); - var r2 = await service.GraphPostAsync("tenant-1", "/v1.0/path2", new { name = "test" }); - var r3 = await service.GraphGetAsync("tenant-1", "/v1.0/path3"); - - // Assert - r1.Should().NotBeNull(); - r2.Should().NotBeNull(); - r3.Should().NotBeNull(); - - // Only one token acquisition across all operations - await executor.Received(1).ExecuteAsync( - "az", - Arg.Is(s => s.Contains("get-access-token")), - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); - } - finally - { - handler.Dispose(); + await service.GraphGetAsync("tenant-1", "/v1.0/path1"); + await service.GraphPostAsync("tenant-1", "/v1.0/path2", new { name = "test" }); + await service.GraphGetAsync("tenant-1", "/v1.0/path3"); + + callCount[0].Should().Be(1, + because: "GET and POST operations share the same process-level token cache — " + + "mixed Graph operations within a command must not each re-acquire a token"); } + finally { handler.Dispose(); } } [Fact] - public void AzCliTokenCacheDuration_IsFiveMinutes() + public async Task MultipleGraphApiServiceInstances_SameTenant_AcquireTokenOnlyOnce() { - // The cache duration should be a reasonable window to avoid stale tokens - // while eliminating redundant subprocess spawns within a single command. - GraphApiService.AzCliTokenCacheDuration.Should().Be(TimeSpan.FromMinutes(5)); + // This is the key regression scenario: previously, each GraphApiService instance had + // its own instance-level cache, so a new instance in each setup phase would re-run + // 'az account get-access-token'. With a process-level cache, all instances share one token. + var callCount = SetupTokenAcquirerWithCounter(); + + var handler1 = new TestHttpMessageHandler(); + var handler2 = new TestHttpMessageHandler(); + + try + { + handler1.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent("{\"value\":[]}") }); + handler2.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent("{\"value\":[]}") }); + + var logger = Substitute.For>(); + var executor = Substitute.For(Substitute.For>()); + executor.ExecuteAsync(Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = "{}", StandardError = string.Empty })); + + var service1 = new GraphApiService(logger, executor, handler1); + var service2 = new GraphApiService(logger, executor, handler2); + + await service1.GraphGetAsync("tenant-1", "/v1.0/path1"); + await service2.GraphGetAsync("tenant-1", "/v1.0/path1"); + + callCount[0].Should().Be(1, + because: "the process-level cache is shared across all GraphApiService instances — " + + "a second instance must not re-run 'az account get-access-token' for the same tenant"); + } + finally + { + handler1.Dispose(); + handler2.Dispose(); + } } [Fact] - public async Task GraphGetAsync_ExpiredCache_AcquiresNewToken() + public async Task GraphGetAsync_AfterCacheInvalidation_AcquiresNewToken() { - // Arrange - var (service, handler, executor) = CreateService(); + // Validates that InvalidateAzCliTokenCache() forces fresh token acquisition — + // used by ClientAppValidator and DelegatedConsentService after az login/CAE events. + var callCount = SetupTokenAcquirerWithCounter(); + var (service, handler) = CreateService(); try { - // Queue 2 successful GET responses handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("{\"value\":[]}") - }); + { Content = new StringContent("{\"value\":[]}") }); handler.QueueResponse(new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("{\"value\":[]}") - }); + { Content = new StringContent("{\"value\":[]}") }); - // Act - First call should acquire token and cache it await service.GraphGetAsync("tenant-1", "/v1.0/path1"); - // Simulate cache expiry by setting expiry to past - service.CachedAzCliTokenExpiry = DateTimeOffset.UtcNow.AddMinutes(-1); + // Simulate a CAE event or forced re-auth that invalidates all cached tokens + AzCliHelper.InvalidateAzCliTokenCache(); - // Second call should acquire new token because cache expired await service.GraphGetAsync("tenant-1", "/v1.0/path2"); - // Assert - Token should be acquired twice (once for each call since cache expired) - await executor.Received(2).ExecuteAsync( - "az", - Arg.Is(s => s.Contains("get-access-token")), - Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); - } - finally - { - handler.Dispose(); + callCount[0].Should().Be(2, + because: "InvalidateAzCliTokenCache clears the process-level cache — " + + "the next call must re-acquire a fresh token (e.g., after CAE revocation or az login)"); } + finally { handler.Dispose(); } } } + +[CollectionDefinition("GraphApiServiceTokenCacheTests", DisableParallelization = true)] +public class GraphApiServiceTokenCacheTestCollection { } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTokenTrimTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTokenTrimTests.cs index 26c0d87d..ec07a71b 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTokenTrimTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceTokenTrimTests.cs @@ -5,6 +5,7 @@ using System.Net.Http; using FluentAssertions; using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -17,6 +18,13 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; /// public class GraphApiServiceTokenTrimTests { + public GraphApiServiceTokenTrimTests() + { + // Pre-warm the process-level token cache with a token that includes a newline so + // EnsureGraphHeadersAsync reads from cache and the trimming at line 256 is exercised. + AzCliHelper.WarmAzCliTokenCache("https://graph.microsoft.com/", "tid", "fake-graph-token\n"); + } + [Theory] [InlineData("fake-token\n")] [InlineData("fake-token\r\n")] @@ -44,7 +52,7 @@ public async Task EnsureGraphHeadersAsync_TrimsNewlineCharactersFromToken(string return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty }); }); - var service = new GraphApiService(logger, executor, handler); + var service = new GraphApiService(logger, executor, handler, loginHintResolver: () => Task.FromResult(null)); // Queue successful GET response using var response = new HttpResponseMessage(HttpStatusCode.OK) @@ -75,10 +83,11 @@ public async Task EnsureGraphHeadersAsync_WithTokenProvider_TrimsNewlineCharacte Arg.Any>(), Arg.Any(), Arg.Any(), - Arg.Any()) + Arg.Any(), + Arg.Any()) .Returns("fake-token\n"); - var service = new GraphApiService(logger, executor, handler, tokenProvider); + var service = new GraphApiService(logger, executor, handler, tokenProvider, loginHintResolver: () => Task.FromResult(null)); // Queue successful GET response using var response = new HttpResponseMessage(HttpStatusCode.OK) @@ -114,7 +123,7 @@ public async Task CheckServicePrincipalCreationPrivilegesAsync_TrimsNewlineChara return Task.FromResult(new CommandResult { ExitCode = 0, StandardOutput = string.Empty, StandardError = string.Empty }); }); - var service = new GraphApiService(logger, executor, handler); + var service = new GraphApiService(logger, executor, handler, loginHintResolver: () => Task.FromResult(null)); // Queue successful response for directory roles using var response = new HttpResponseMessage(HttpStatusCode.OK) diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceVerifyInheritablePermissionsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceVerifyInheritablePermissionsTests.cs index 85f7ae63..ee4b32a5 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceVerifyInheritablePermissionsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/GraphApiServiceVerifyInheritablePermissionsTests.cs @@ -5,6 +5,7 @@ using System.Text.Json; using FluentAssertions; using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; using Microsoft.Extensions.Logging; using NSubstitute; using Xunit; @@ -13,6 +14,11 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; public class AgentBlueprintServiceVerifyInheritablePermissionsTests { + public AgentBlueprintServiceVerifyInheritablePermissionsTests() + { + AzCliHelper.WarmAzCliTokenCache("https://graph.microsoft.com/", "tid", "fake-graph-token"); + } + [Fact] public async Task VerifyInheritablePermissionsAsync_PermissionsExist_ReturnsScopes() { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/AzCliHelperTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/AzCliHelperTests.cs new file mode 100644 index 00000000..29379277 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/AzCliHelperTests.cs @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services.Helpers; + +/// +/// Tests for AzCliHelper.ResolveLoginHintAsync caching and override behavior. +/// Isolated from other tests because the cache and override are static state. +/// +[Collection("AzCliHelperTests")] +public class AzCliHelperTests : IDisposable +{ + public AzCliHelperTests() + { + // Start each test with a clean slate — both static caches + AzCliHelper.LoginHintResolverOverride = null; + AzCliHelper.ResetLoginHintCacheForTesting(); + AzCliHelper.AzCliTokenAcquirerOverride = null; + AzCliHelper.ResetAzCliTokenCacheForTesting(); + } + + public void Dispose() + { + // Restore static state so other tests are not affected + AzCliHelper.LoginHintResolverOverride = null; + AzCliHelper.ResetLoginHintCacheForTesting(); + AzCliHelper.AzCliTokenAcquirerOverride = null; + AzCliHelper.ResetAzCliTokenCacheForTesting(); + } + + [Fact] + public async Task ResolveLoginHintAsync_WhenOverrideSet_ReturnsOverrideValue() + { + AzCliHelper.LoginHintResolverOverride = () => Task.FromResult("admin@contoso.com"); + + var result = await AzCliHelper.ResolveLoginHintAsync(); + + result.Should().Be("admin@contoso.com", + because: "the override replaces the real az subprocess — used in tests and to inject known identities"); + } + + [Fact] + public async Task ResolveLoginHintAsync_CalledTwice_ReturnsSameTaskInstance() + { + // Override returns a known value so we never hit the real 'az' process + AzCliHelper.LoginHintResolverOverride = () => Task.FromResult("user@test.com"); + + // Populate the cache on the first call, then reset override to simulate production + var firstResult = await AzCliHelper.ResolveLoginHintAsync(); + + // Clear override — subsequent calls must use the cache, not the resolver + AzCliHelper.LoginHintResolverOverride = null; + + // The cached Task should be returned directly — no new subprocess + var cachedTask = AzCliHelper.ResolveLoginHintAsync(); + var secondResult = await cachedTask; + + secondResult.Should().Be(firstResult, + because: "the cached result must be returned on subsequent calls — re-running az account show on every token acquire costs 20-40s per call"); + } + + [Fact] + public async Task ResolveLoginHintAsync_OverrideInvokedOnce_WhenCalledMultipleTimes() + { + var callCount = 0; + AzCliHelper.LoginHintResolverOverride = () => + { + callCount++; + return Task.FromResult("counted@test.com"); + }; + + // First call populates the cache via the override + await AzCliHelper.ResolveLoginHintAsync(); + + // Reset override to null — cache should serve subsequent calls without invoking anything + AzCliHelper.LoginHintResolverOverride = null; + await AzCliHelper.ResolveLoginHintAsync(); + await AzCliHelper.ResolveLoginHintAsync(); + + callCount.Should().Be(1, + because: "the resolver must be invoked exactly once per process lifetime — the cache eliminates the repeated 20-40s az account show calls across setup phases"); + } + + [Fact] + public async Task ResolveLoginHintAsync_AfterCacheReset_InvokesResolverAgain() + { + var callCount = 0; + AzCliHelper.LoginHintResolverOverride = () => + { + callCount++; + return Task.FromResult("reset@test.com"); + }; + + await AzCliHelper.ResolveLoginHintAsync(); + AzCliHelper.ResetLoginHintCacheForTesting(); + await AzCliHelper.ResolveLoginHintAsync(); + + callCount.Should().Be(2, + because: "ResetLoginHintCacheForTesting clears the cache, forcing a fresh resolve — required for test isolation"); + } + + // ------------------------------------------------------------------------- + // AcquireAzCliTokenAsync — process-level token cache + // ------------------------------------------------------------------------- + + [Fact] + public async Task AcquireAzCliTokenAsync_WhenOverrideSet_ReturnsOverrideValue() + { + AzCliHelper.AzCliTokenAcquirerOverride = (_, __) => Task.FromResult("test-token"); + + var result = await AzCliHelper.AcquireAzCliTokenAsync("https://graph.microsoft.com/", "tenant-1"); + + result.Should().Be("test-token", + because: "the override replaces the real az subprocess — used in tests to inject known tokens"); + } + + [Fact] + public async Task AcquireAzCliTokenAsync_CalledTwiceSameKey_InvokesAcquirerOnce() + { + var callCount = 0; + AzCliHelper.AzCliTokenAcquirerOverride = (_, __) => + { + callCount++; + return Task.FromResult("shared-token"); + }; + + await AzCliHelper.AcquireAzCliTokenAsync("https://graph.microsoft.com/", "tenant-1"); + await AzCliHelper.AcquireAzCliTokenAsync("https://graph.microsoft.com/", "tenant-1"); + + callCount.Should().Be(1, + because: "the process-level cache must serve the same (resource, tenant) token " + + "after the first acquisition — calling az account get-access-token on every " + + "request costs 20-40s per call"); + } + + [Fact] + public async Task AcquireAzCliTokenAsync_DifferentTenants_InvokesAcquirerForEach() + { + var callCount = 0; + AzCliHelper.AzCliTokenAcquirerOverride = (_, __) => + { + callCount++; + return Task.FromResult("token"); + }; + + await AzCliHelper.AcquireAzCliTokenAsync("https://graph.microsoft.com/", "tenant-1"); + await AzCliHelper.AcquireAzCliTokenAsync("https://graph.microsoft.com/", "tenant-2"); + + callCount.Should().Be(2, + because: "different tenant IDs are different cache keys — each tenant requires its own token"); + } + + [Fact] + public async Task AcquireAzCliTokenAsync_AfterInvalidation_InvokesAcquirerAgain() + { + var callCount = 0; + AzCliHelper.AzCliTokenAcquirerOverride = (_, __) => + { + callCount++; + return Task.FromResult("token"); + }; + + await AzCliHelper.AcquireAzCliTokenAsync("https://graph.microsoft.com/", "tenant-1"); + AzCliHelper.InvalidateAzCliTokenCache(); + await AzCliHelper.AcquireAzCliTokenAsync("https://graph.microsoft.com/", "tenant-1"); + + callCount.Should().Be(2, + because: "InvalidateAzCliTokenCache clears the cache — the next call must re-acquire " + + "a fresh token; this is required after 'az login' or a CAE token revocation event"); + } + + [Fact] + public async Task WarmAzCliTokenCache_InjectedToken_ReturnedOnNextCall() + { + // Override that always fails — should NOT be called after warming the cache + AzCliHelper.AzCliTokenAcquirerOverride = (_, __) => + Task.FromResult(null); + + AzCliHelper.WarmAzCliTokenCache("https://graph.microsoft.com/", "tenant-1", "warmed-token"); + + // The warmup bypasses the GetOrAdd — the cache entry is set directly. + // Reset override so we can verify the warmed value is returned, not re-acquired. + AzCliHelper.AzCliTokenAcquirerOverride = null; + var result = await AzCliHelper.AcquireAzCliTokenAsync("https://graph.microsoft.com/", "tenant-1"); + + result.Should().Be("warmed-token", + because: "WarmAzCliTokenCache injects a token acquired via auth recovery into the " + + "process-level cache — subsequent callers must receive the injected token " + + "without re-running az account get-access-token"); + } +} + +[CollectionDefinition("AzCliHelperTests", DisableParallelization = true)] +public class AzCliHelperTestCollection { } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/CleanConsoleFormatterTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/CleanConsoleFormatterTests.cs index 0f111494..5f0984b9 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/CleanConsoleFormatterTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/CleanConsoleFormatterTests.cs @@ -104,8 +104,10 @@ public void Write_WithWarningLevel_OutputsMessageWithoutWarningPrefix() } [Fact] - public void Write_WithException_IncludesExceptionDetails() + public void Write_WithException_SuppressesExceptionDetailsFromConsole() { + // Exception details (stack traces) are intentionally suppressed from console output. + // The file logger captures the full exception for diagnostics. // Arrange var message = "Error occurred"; var exception = new InvalidOperationException("Test exception"); @@ -118,13 +120,17 @@ public void Write_WithException_IncludesExceptionDetails() var output = _consoleWriter.ToString(); output.Should().Contain("ERROR:"); output.Should().Contain(message); - output.Should().Contain("Test exception"); - output.Should().Contain("InvalidOperationException"); + output.Should().NotContain("Test exception", + because: "exception details are suppressed from console to prevent leaking stack traces to users — file logger captures full exception for diagnostics"); + output.Should().NotContain("InvalidOperationException", + because: "exception type names are suppressed from console output for the same reason"); } [Fact] - public void Write_WithExceptionAndWarning_IncludesExceptionDetails() + public void Write_WithExceptionAndWarning_SuppressesExceptionDetailsFromConsole() { + // Exception details (stack traces) are intentionally suppressed from console output. + // The file logger captures the full exception for diagnostics. // Arrange var message = "Warning with exception"; var exception = new ArgumentException("Test warning exception"); @@ -137,8 +143,10 @@ public void Write_WithExceptionAndWarning_IncludesExceptionDetails() var output = _consoleWriter.ToString(); output.Should().NotContain("WARNING:"); output.Should().Contain(message); - output.Should().Contain("Test warning exception"); - output.Should().Contain("ArgumentException"); + output.Should().NotContain("Test warning exception", + because: "exception details are suppressed from console to prevent leaking stack traces to users — file logger captures full exception for diagnostics"); + output.Should().NotContain("ArgumentException", + because: "exception type names are suppressed from console output for the same reason"); } [Fact] @@ -155,7 +163,7 @@ public void Write_WithNullMessage_DoesNotWriteAnything() } [Fact] - public void Write_WithEmptyMessage_DoesNotWriteAnything() + public void Write_WithEmptyMessage_WritesBlankLine() { // Arrange var logEntry = CreateLogEntry(LogLevel.Information, string.Empty); @@ -163,8 +171,8 @@ public void Write_WithEmptyMessage_DoesNotWriteAnything() // Act _formatter.Write(logEntry, null, _consoleWriter); - // Assert - _consoleWriter.ToString().Should().BeEmpty(); + // Assert - empty string creates intentional blank line for visual spacing + _consoleWriter.ToString().Should().Be(Environment.NewLine); } [Fact] diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/RetryHelperTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/RetryHelperTests.cs index 1d9fccaa..e458df5f 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/RetryHelperTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Helpers/RetryHelperTests.cs @@ -160,9 +160,9 @@ await _retryHelper.ExecuteWithRetryAsync( }, result => callCount < 4, maxRetries: 4, - baseDelaySeconds: 2); + baseDelaySeconds: 0); - // Assert - verify exponential backoff: 2, 4, 8 seconds + // Assert - verify retry count (baseDelaySeconds=0 so no real waits; backoff timing not tested here) callCount.Should().Be(4); } @@ -172,7 +172,7 @@ public async Task ExecuteWithRetryAsync_MultipleRetries_CompletesAllAttempts() // Arrange var callCount = 0; - // Act - use small base delay to test retry logic quickly + // Act - use zero base delay to test retry logic without real waits await _retryHelper.ExecuteWithRetryAsync( ct => { @@ -181,7 +181,7 @@ await _retryHelper.ExecuteWithRetryAsync( }, result => callCount < 3, maxRetries: 3, - baseDelaySeconds: 1); + baseDelaySeconds: 0); // Assert - should complete all attempts callCount.Should().Be(3); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/InteractiveGraphAuthServiceTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/InteractiveGraphAuthServiceTests.cs index 6c8a1dce..c6dcb42e 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/InteractiveGraphAuthServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/InteractiveGraphAuthServiceTests.cs @@ -212,6 +212,7 @@ private sealed class StubTokenCredential : TokenCredential private const string ValidGuid = "12345678-1234-1234-1234-123456789abc"; private const string ValidTenantId = "87654321-4321-4321-4321-cba987654321"; + private static readonly Func> NoOpLoginHint = () => Task.FromResult(null); /// /// Verifies that a credential failure surfaced during eager token acquisition @@ -232,7 +233,8 @@ public async Task GetAuthenticatedGraphClientAsync_WhenCredentialFails_ThrowsGra var logger = Substitute.For>(); var sut = new InteractiveGraphAuthService(logger, ValidGuid, - credentialFactory: (_, _) => failingCredential); + credentialFactory: (_, _) => failingCredential, + loginHintResolver: NoOpLoginHint); // Act var act = async () => await sut.GetAuthenticatedGraphClientAsync(ValidTenantId); @@ -252,7 +254,8 @@ public async Task GetAuthenticatedGraphClientAsync_WhenCredentialSucceeds_Return var workingCredential = new StubTokenCredential("token-value", DateTimeOffset.UtcNow.AddHours(1)); var logger = Substitute.For>(); var sut = new InteractiveGraphAuthService(logger, ValidGuid, - credentialFactory: (_, _) => workingCredential); + credentialFactory: (_, _) => workingCredential, + loginHintResolver: NoOpLoginHint); // Act var client = await sut.GetAuthenticatedGraphClientAsync(ValidTenantId); @@ -273,7 +276,8 @@ public async Task GetAuthenticatedGraphClientAsync_ForSameTenant_ReturnsCachedCl var logger = Substitute.For>(); int callCount = 0; var sut = new InteractiveGraphAuthService(logger, ValidGuid, - credentialFactory: (_, _) => { callCount++; return workingCredential; }); + credentialFactory: (_, _) => { callCount++; return workingCredential; }, + loginHintResolver: NoOpLoginHint); // Act — call twice for the same tenant var client1 = await sut.GetAuthenticatedGraphClientAsync(ValidTenantId); @@ -296,7 +300,8 @@ public async Task GetAuthenticatedGraphClientAsync_ForDifferentTenant_Authentica var logger = Substitute.For>(); int callCount = 0; var sut = new InteractiveGraphAuthService(logger, ValidGuid, - credentialFactory: (_, _) => { callCount++; return workingCredential; }); + credentialFactory: (_, _) => { callCount++; return workingCredential; }, + loginHintResolver: NoOpLoginHint); const string otherTenant = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; @@ -322,7 +327,8 @@ public async Task GetAuthenticatedGraphClientAsync_WhenAccessDenied_ThrowsGraphA var logger = Substitute.For>(); var sut = new InteractiveGraphAuthService(logger, ValidGuid, - credentialFactory: (_, _) => failingCredential); + credentialFactory: (_, _) => failingCredential, + loginHintResolver: NoOpLoginHint); // Act var act = async () => await sut.GetAuthenticatedGraphClientAsync(ValidTenantId); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs index 18a05ccd..66b68b96 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/MicrosoftGraphTokenProviderTests.cs @@ -40,7 +40,11 @@ public async Task GetMgGraphAccessTokenAsync_WithValidClientAppId_IncludesClient Arg.Any()) .Returns(new CommandResult { ExitCode = 0, StandardOutput = expectedToken, StandardError = string.Empty }); - var provider = new MicrosoftGraphTokenProvider(_executor, _logger); + // MSAL is primary but we skip it here to test PS-path behavior (ClientId in script) + var provider = new MicrosoftGraphTokenProvider(_executor, _logger) + { + MsalTokenAcquirerOverride = (_, _, _, _) => Task.FromResult(null) + }; // Act var token = await provider.GetMgGraphAccessTokenAsync(tenantId, scopes, false, clientAppId); @@ -208,6 +212,92 @@ public async Task GetMgGraphAccessTokenAsync_WithValidToken_ReturnsToken() token.Should().Be(expectedToken); } + [Fact] + public async Task GetMgGraphAccessTokenAsync_WhenMsalSucceeds_ReturnsMsalTokenWithoutCallingPowerShell() + { + // Arrange + var tenantId = "12345678-1234-1234-1234-123456789abc"; + var scopes = new[] { "AgentIdentityBlueprint.DeleteRestore.All" }; + var clientAppId = "87654321-4321-4321-4321-cba987654321"; + var msalToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzZWxsYWsifQ.signature"; + + var provider = new MicrosoftGraphTokenProvider(_executor, _logger) + { + MsalTokenAcquirerOverride = (_, _, _, _) => Task.FromResult(msalToken) + }; + + // Act + var token = await provider.GetMgGraphAccessTokenAsync(tenantId, scopes, false, clientAppId); + + // Assert + token.Should().Be(msalToken); + await _executor.DidNotReceive().ExecuteWithStreamingAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any?>(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GetMgGraphAccessTokenAsync_WhenMsalFails_FallsBackToPowerShell() + { + // Arrange + var tenantId = "12345678-1234-1234-1234-123456789abc"; + var scopes = new[] { "AgentIdentityBlueprint.DeleteRestore.All" }; + var clientAppId = "87654321-4321-4321-4321-cba987654321"; + var psToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmYWxsYmFjayJ9.signature"; + + _executor.ExecuteWithStreamingAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any?>(), + Arg.Any(), Arg.Any()) + .Returns(new CommandResult { ExitCode = 0, StandardOutput = psToken, StandardError = string.Empty }); + + var provider = new MicrosoftGraphTokenProvider(_executor, _logger) + { + MsalTokenAcquirerOverride = (_, _, _, _) => Task.FromResult(null) // MSAL fails + }; + + // Act + var token = await provider.GetMgGraphAccessTokenAsync(tenantId, scopes, false, clientAppId); + + // Assert + token.Should().Be(psToken); + await _executor.Received(1).ExecuteWithStreamingAsync( + Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any?>(), + Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task GetMgGraphAccessTokenAsync_WhenMsalSucceeds_SecondCallReturnsCachedToken() + { + // Arrange + var tenantId = "12345678-1234-1234-1234-123456789abc"; + var scopes = new[] { "AgentIdentityBlueprint.DeleteRestore.All" }; + var clientAppId = "87654321-4321-4321-4321-cba987654321"; + // Valid JWT with a future exp claim (year 2099) + var msalToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzZWxsYWsiLCJleHAiOjQwNzA5MDg4MDB9.signature"; + var callCount = 0; + + var provider = new MicrosoftGraphTokenProvider(_executor, _logger) + { + MsalTokenAcquirerOverride = (_, _, _, _) => + { + callCount++; + return Task.FromResult(msalToken); + } + }; + + // Act + var token1 = await provider.GetMgGraphAccessTokenAsync(tenantId, scopes, false, clientAppId); + var token2 = await provider.GetMgGraphAccessTokenAsync(tenantId, scopes, false, clientAppId); + + // Assert + token1.Should().Be(msalToken); + token2.Should().Be(msalToken); + callCount.Should().Be(1, "second call should return cached token without re-invoking MSAL"); + } + [Theory] [InlineData("User.Read'; Invoke-Expression 'malicious'")] [InlineData("User.Read\"; Invoke-Expression \"malicious\"")] @@ -246,7 +336,11 @@ public async Task GetMgGraphAccessTokenAsync_EscapesSingleQuotesInClientAppId() Arg.Any()) .Returns(new CommandResult { ExitCode = 0, StandardOutput = expectedToken, StandardError = string.Empty }); - var provider = new MicrosoftGraphTokenProvider(_executor, _logger); + // MSAL is primary but we skip it here to test PS-path escaping behavior + var provider = new MicrosoftGraphTokenProvider(_executor, _logger) + { + MsalTokenAcquirerOverride = (_, _, _, _) => Task.FromResult(null) + }; // Act var token = await provider.GetMgGraphAccessTokenAsync(tenantId, scopes, false, clientAppId); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/AzureAuthRequirementCheckTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/AzureAuthRequirementCheckTests.cs index e803b4e0..eb6ff482 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/AzureAuthRequirementCheckTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/AzureAuthRequirementCheckTests.cs @@ -34,7 +34,7 @@ public async Task CheckAsync_WhenAuthenticationSucceeds_ShouldReturnSuccess() var check = new AzureAuthRequirementCheck(_mockAuthValidator); var config = new Agent365Config { SubscriptionId = "test-sub-id" }; - _mockAuthValidator.ValidateAuthenticationAsync(Arg.Any()) + _mockAuthValidator.ValidateAuthenticationAsync(Arg.Any(), Arg.Any()) .Returns(true); // Act @@ -73,14 +73,14 @@ public async Task CheckAsync_ShouldPassSubscriptionIdToValidator() var check = new AzureAuthRequirementCheck(_mockAuthValidator); var config = new Agent365Config { SubscriptionId = "specific-sub-id" }; - _mockAuthValidator.ValidateAuthenticationAsync(Arg.Any()) + _mockAuthValidator.ValidateAuthenticationAsync(Arg.Any(), Arg.Any()) .Returns(true); // Act await check.CheckAsync(config, _mockLogger); // Assert - await _mockAuthValidator.Received(1).ValidateAuthenticationAsync("specific-sub-id"); + await _mockAuthValidator.Received(1).ValidateAuthenticationAsync("specific-sub-id", Arg.Any()); } [Fact] @@ -90,14 +90,14 @@ public async Task CheckAsync_WithEmptySubscriptionId_ShouldPassEmptyStringToVali var check = new AzureAuthRequirementCheck(_mockAuthValidator); var config = new Agent365Config(); - _mockAuthValidator.ValidateAuthenticationAsync(Arg.Any()) + _mockAuthValidator.ValidateAuthenticationAsync(Arg.Any(), Arg.Any()) .Returns(true); // Act await check.CheckAsync(config, _mockLogger); // Assert - await _mockAuthValidator.Received(1).ValidateAuthenticationAsync(string.Empty); + await _mockAuthValidator.Received(1).ValidateAuthenticationAsync(string.Empty, Arg.Any()); } [Fact] diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/FrontierPreviewRequirementCheckTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/FrontierPreviewRequirementCheckTests.cs index c4fa69c2..d82c14ff 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/FrontierPreviewRequirementCheckTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/FrontierPreviewRequirementCheckTests.cs @@ -37,8 +37,8 @@ public async Task CheckAsync_ShouldReturnWarning_WithDetails() result.Passed.Should().BeTrue("check should pass to allow user to proceed despite warning"); result.IsWarning.Should().BeTrue("check should be flagged as a warning"); result.Details.Should().NotBeNullOrEmpty(); - result.Details.Should().Contain("auto-verified"); - result.ErrorMessage.Should().Contain("Cannot automatically verify"); + result.Details.Should().Contain("enrolled"); + result.ErrorMessage.Should().Contain("cannot be verified automatically"); result.ResolutionGuidance.Should().BeNullOrEmpty("warning checks don't have resolution guidance"); } @@ -92,7 +92,7 @@ public async Task CheckAsync_ShouldIncludePreviewContext() // Assert // Verify the result mentions the auto-verification limitation - result.Details.Should().Contain("auto-verified"); + result.Details.Should().Contain("enrolled"); } [Fact] diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/PowerShellModulesRequirementCheckTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/PowerShellModulesRequirementCheckTests.cs index afdf6deb..e179aa9b 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/PowerShellModulesRequirementCheckTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Requirements/PowerShellModulesRequirementCheckTests.cs @@ -66,21 +66,17 @@ public async Task IsWslEnvironment_WhenProcVersionContainsMicrosoft_ReturnsTrue( [Fact] public async Task CheckAsync_WhenPwshMissingAndWslDistroNameSet_ResolutionGuidanceContainsLinuxUrl() { - // Only meaningful when pwsh is absent; exits early on machines with PowerShell installed - // so the test never gives a misleading green result. + // Use injected runner that reports pwsh unavailable — no real process spawned. + var noRunner = NoPwshRunner(); + var check = new PowerShellModulesRequirementCheck(noRunner); var config = new Agent365Config(); - var probe = await _check.CheckAsync(config, _mockLogger); - if (probe.Passed) - { - return; // pwsh is available — WSL guidance path is not exercised on this machine. - } var original = Environment.GetEnvironmentVariable("WSL_DISTRO_NAME"); try { Environment.SetEnvironmentVariable("WSL_DISTRO_NAME", "Ubuntu-22.04"); - var result = await _check.CheckAsync(config, _mockLogger); + var result = await check.CheckAsync(config, _mockLogger); result.Passed.Should().BeFalse(); result.ResolutionGuidance.Should().Contain( @@ -96,13 +92,10 @@ public async Task CheckAsync_WhenPwshMissingAndWslDistroNameSet_ResolutionGuidan [Fact] public async Task CheckAsync_WhenPwshMissingAndNotWsl_ResolutionGuidanceContainsGeneralUrl() { - // Only meaningful when pwsh is absent; exits early on machines with PowerShell installed. + // Use injected runner that reports pwsh unavailable — no real process spawned. + var noRunner = NoPwshRunner(); + var check = new PowerShellModulesRequirementCheck(noRunner); var config = new Agent365Config(); - var probe = await _check.CheckAsync(config, _mockLogger); - if (probe.Passed) - { - return; // pwsh is available — non-WSL guidance path is not exercised on this machine. - } // Ensure WSL_DISTRO_NAME is not set so the non-WSL branch is taken. var original = Environment.GetEnvironmentVariable("WSL_DISTRO_NAME"); @@ -110,7 +103,7 @@ public async Task CheckAsync_WhenPwshMissingAndNotWsl_ResolutionGuidanceContains { Environment.SetEnvironmentVariable("WSL_DISTRO_NAME", null); - var result = await _check.CheckAsync(config, _mockLogger); + var result = await check.CheckAsync(config, _mockLogger); result.Passed.Should().BeFalse(); result.ResolutionGuidance.Should().Contain( @@ -131,13 +124,35 @@ public async Task CheckAsync_WhenPwshMissingAndNotWsl_ResolutionGuidanceContains [Fact] public async Task CheckAsync_ShouldReturnResult_WithoutThrowing() { - // Validates the check runs end-to-end without exceptions. - // The pass/fail result depends on whether pwsh is installed in the test environment. + // Use injected runner that reports pwsh available with modules installed — no real process spawned. var config = new Agent365Config(); + var check = new PowerShellModulesRequirementCheck(AllModulesInstalledRunner()); - var result = await _check.CheckAsync(config, _mockLogger); + var result = await check.CheckAsync(config, _mockLogger); // The key assertion: CheckAsync completes without throwing regardless of environment. result.Should().NotBeNull(); + result.Passed.Should().BeTrue(); } + + // ── Helpers ──────────────────────────────────────────────────────────── + + /// Returns a command runner that reports pwsh as unavailable (exit 1). + private static Func> NoPwshRunner() + => (_, _, _) => Task.FromResult((false, (string?)null)); + + /// Returns a command runner that reports pwsh 7 available and all required modules installed. + private static Func> AllModulesInstalledRunner() + => (_, command, _) => + { + // Availability check: "$PSVersionTable.PSVersion.Major" + if (command.Contains("PSVersionTable")) + return Task.FromResult((true, (string?)"7")); + // Module check: returns the module name + if (command.Contains("Microsoft.Graph.Authentication")) + return Task.FromResult((true, (string?)"Microsoft.Graph.Authentication")); + if (command.Contains("Microsoft.Graph.Applications")) + return Task.FromResult((true, (string?)"Microsoft.Graph.Applications")); + return Task.FromResult((true, (string?)string.Empty)); + }; }