Setup permissions: replace oauth2PermissionGrants POST with unified /v2.0/adminconsent URL#424
Setup permissions: replace oauth2PermissionGrants POST with unified /v2.0/adminconsent URL#424sellakumaran wants to merge 10 commits into
Conversation
…d scopes; drop oauth2PermissionGrants POST
- BatchPermissionsOrchestrator: remove Phase 2b oauth2 grant calls; admin path now opens a single
/v2.0/adminconsent URL covering Graph + non-Graph resources (api://{appId}/{scope}) and polls
Graph for completion. Non-admin path returns the same URL via LogPermissionsActionRequired.
- SetupHelpers.LogPermissionsActionRequired: replace per-resource Invoke-MgGraphRequest snippets
with the unified consent URL; retain New-MgServicePrincipalAppRoleAssignment for S2S grants.
- PermissionsSubcommand: thread localResults + specs into LogAdminConsentNextSteps so standalone
runs of permissions mcp|bot|custom emit the same Action Required block as setup all.
- SetupCommand / PermissionsSubcommand help: clarify role separation (Agent ID Developer vs
Global Administrator) and surface all granular subcommands.
- BrowserHelper.OpenUrlOverrideForTests + AdminConsentHelper.BypassConsentChecksForTests:
AsyncLocal-scoped test overrides so admin-path unit tests do not launch real browsers or poll
Graph for 180s; wired into PermissionsSubcommandTests and BatchPermissionsOrchestratorTests.
- Tests: rewrite BatchPermissionsOrchestratorTests + LogPermissionsActionRequired tests for the
unified flow. Full suite: 1525 passed, 12 skipped, 0 failed.
…elper extractions Blueprint create path: retry the initial application PATCH (identifier URI + access_agent_as_user OBO scope) and the EnsureOboScope safety-net PATCH on transient Request_ResourceNotFound. Newly created blueprint apps frequently have a write replica that lags the read replica for several seconds, so the existing GET propagation check could succeed while the immediately-following PATCH 404'd, silently leaving the blueprint without identifier URI or OBO scope. AdminConsentHelper canary 403 path: replace the immediate Enter prompt with a poll loop that re-runs the canary every 5s, prints a 30s progress update, and responds promptly to Enter or Ctrl+C. Rewrite the jargon-heavy oauth2PermissionGrants / DelegatedPermissionGrant message in plain English; demote technical detail to Debug. Refactors: extract shared helpers from NonDwBlueprintSetupOrchestrator into SetupHelpers; add ConsoleHelper.ReadLineCancellable for prompts that need cancellation; small adjustments to BatchPermissionsOrchestrator, AuthenticationService, GraphApiService. Test counts hold at 1521 passing / 12 skipped.
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
There was a problem hiding this comment.
Pull request overview
This PR refactors Agent365 CLI setup permission flows to stop using Graph POST /oauth2PermissionGrants for delegated consent and instead build a unified Entra /v2.0/adminconsent URL covering delegated scopes across all configured resources, while also improving reliability around newly-created blueprint app PATCH propagation.
Changes:
- Replace per-resource delegated consent orchestration (and its PowerShell snippets) with a single combined
/v2.0/adminconsentURL for Graph + non-Graph resources. - Add retry/backoff for blueprint application PATCH operations that can 404 due to write-replica lag, and improve cancellation handling in Graph/auth flows.
- Introduce test hooks to suppress real browser launches/long consent polling, plus a cancellable console input helper used by setup prompts.
Reviewed changes
Copilot reviewed 16 out of 17 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersLogPermissionsActionRequiredTests.cs | New unit tests for the Action Required block output for consent URL + S2S PowerShell. |
| src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs | Suppresses browser/polling in tests via overrides; adds IDisposable cleanup. |
| src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/NonDwBlueprintSetupOrchestratorExecuteTests.cs | Removes tests for the retired per-identity oauth2PermissionGrants path. |
| src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BatchPermissionsOrchestratorTests.cs | Updates tests for unified consent URL behavior; suppresses browser/polling in tests. |
| src/Microsoft.Agents.A365.DevTools.Cli/Services/Helpers/AdminConsentHelper.cs | Adds canary-based polling behavior and a test bypass hook; improves UX around lacking grant-read permission. |
| src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs | Ensures OperationCanceledException is not swallowed during token acquisition. |
| src/Microsoft.Agents.A365.DevTools.Cli/Services/AuthenticationService.cs | Ensures OperationCanceledException is not wrapped as authentication failure. |
| src/Microsoft.Agents.A365.DevTools.Cli/Helpers/ConsoleHelper.cs | Adds cancellable Console.ReadLine helper with a test override hook. |
| src/Microsoft.Agents.A365.DevTools.Cli/Helpers/BrowserHelper.cs | Adds AsyncLocal test override to avoid launching real browsers in tests. |
| src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs | Uses cancellable console input; updates summary logic; adds unified resource identifier/scope helpers; adds LogPermissionsActionRequired helper. |
| src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs | Updates help text; ensures Action Required output includes tenant/blueprint context for standalone subcommands. |
| src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs | Removes per-agent-identity delegated grant POST path; relies on blueprint inheritance + admin consent. |
| src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs | Adds retry/backoff around blueprint PATCH operations; throws SetupValidationException after exhaustion. |
| src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs | Removes oauth2PermissionGrants POST orchestration; builds unified consent URL and uses browser+polling for admins. |
| src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupCommand.cs | Refreshes CLI help text to emphasize setup all and clarify role requirements. |
| CHANGELOG.md | Documents unified consent URL flow and blueprint PATCH retry behavior. |
| .gitignore | Adds Other/ ignore entry. |
Comments suppressed due to low confidence (1)
src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs:70
- Dispose() currently sets up NSubstitute return values on _mockGraphApiService. Stubbing in Dispose is unexpected and ineffective for the tests that just ran, and it makes cleanup code harder to reason about. Move these default Returns(...) calls into the constructor (or remove them if unused) and keep Dispose limited to resetting the test overrides.
public void Dispose()
{
BrowserHelper.OpenUrlOverrideForTests = null;
AdminConsentHelper.BypassConsentChecksForTests = false;
GC.SuppressFinalize(this);
_mockGraphApiService.IsCurrentUserAdminAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Microsoft.Agents.A365.DevTools.Cli.Models.RoleCheckResult.Unknown);
_mockGraphApiService.IsCurrentUserAgentIdAdminAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Microsoft.Agents.A365.DevTools.Cli.Models.RoleCheckResult.Unknown);
}
1. (HIGH) PollAdminConsentAsync Graph overload returns tri-state ConsentPollResult (Verified / AssumedComplete / NotDetected) so callers can distinguish a directly observed grant from an unverified canary-403 fallback. BatchPermissionsOrchestrator now persists ResourceConsents only when Verified, and keeps the consent URL visible in the Action Required block on AssumedComplete. BlueprintSubcommand logs different messages per outcome. 2. (MED) Refresh stale comment in PermissionsSubcommand.LogAdminConsentNextSteps. The block does not surface PowerShell snippets for non-Graph resources anymore; the comment now matches the real behavior (unified /v2.0/adminconsent URL + optional S2S app-role guidance). 3. (MED) Narrow the identifier-URI / OBO-scope PATCH retry in BlueprintSubcommand to only retry on 404 Request_ResourceNotFound (the documented write-replica-lag race). Other failures abort the retry loop immediately so the SetupValidationException fires on the first attempt. Tests: 1524 pass (12 skipped, 0 failed). Added 3 new tests in AdminConsentHelperTests with 'because:' clauses locking in the tri-state contract: canary 403 -> AssumedComplete, canary already shows grant -> Verified, missing SP id -> NotDetected.
When the Graph API path returns 403 for a Global Administrator (the delegated token does not carry AppRoleAssignment.ReadWrite.All), setup now automatically runs the assignment script via pwsh. On success, BlueprintS2SOutcome is set to Granted and the "Action Required" block is suppressed. Requires pwsh 7+ and Microsoft.Graph modules; run 'a365 setup requirements' to check prerequisites. Demotes intermediate S2S failure log levels to Debug since the PowerShell fallback now handles those cases, reducing console noise. Adds a user-visible pre-launch message so agents driving the CLI can detect and relay the interactive device code step. Removes dead ExecuteWithStdinAsync method from CommandExecutor.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 23 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs:70
- The stubs for IsCurrentUserAdminAsync / IsCurrentUserAgentIdAdminAsync are currently inside Dispose(), so they run after each test instead of before. Because _mockGraphApiService is a partial substitute, leaving these unstubbed during the test can call the real implementation and trigger auth/Graph calls (or hang CI). Move these .Returns(...) setups into the constructor (or into each test) and keep Dispose() only for resetting AsyncLocal test overrides.
_mockGraphApiService.IsCurrentUserAdminAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Microsoft.Agents.A365.DevTools.Cli.Models.RoleCheckResult.Unknown);
_mockGraphApiService.IsCurrentUserAgentIdAdminAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Microsoft.Agents.A365.DevTools.Cli.Models.RoleCheckResult.Unknown);
}
…ary logic - Fix CI test crash: mock EnsureServicePrincipalForAppIdAsync and SetInheritablePermissionsAsync in NonAdmin_BuildsUnifiedConsentUrl test so ForPartsOf substitutes do not reach real Azure endpoints in CI - Fix hasS2SPowerShell to check BlueprintS2SOutcome != Granted so the Action Required S2S PowerShell block is suppressed when the automatic PowerShell fallback already completed the assignment - Fix s2sOk in non-DW flow to require both agentIdS2sGranted and !blueprintS2sFailed, preventing an inconsistent summary where the grants row shows OK while the Action Required block fires for blueprint S2S - Fix Connect-MgGraph snippet to omit -TenantId when tenantId is null rather than emitting an unusable empty placeholder
…in S2S tests - BatchPermissionsOrchestrator: set BlueprintS2SOutcome=Failed for non-admin callers that have S2S specs, so DisplaySetupSummary surfaces the PowerShell hand-off block in the Action Required section alongside the consent URL. Previously the block was silently suppressed for non-GA users. - SetupHelpers: add blank line before the S2S Action Required item so it is visually separated from the preceding consent URL block. - BatchPermissionsOrchestratorTests: mock EnsureServicePrincipalForAppIdAsync and SetInheritablePermissionsAsync in ArrangeS2SPhase1AndAdminCheck to prevent real Azure HTTP calls in CI (ForPartsOf<> calls real implementations for unmocked methods — caused a 5-minute hang and crash on Linux CI). The three existing GA S2S tests are unaffected: null resource SP still yields BlueprintS2SOutcome=Failed, which is the precondition for the PowerShell fallback under test. Also adds a new test covering the non-admin S2S outcome.
…splay path tests - Admin consent polling now uses the az-cli (az rest) overload unconditionally when commandExecutor is available, fixing the 403 that caused every poll to time out even when consent was already granted (MSAL delegated token lacks DelegatedPermissionGrant.Read.All since PR #409; az-cli token carries GA-level access) - adminConsentUrl was being unconditionally nulled for all consentGranted=true cases, making it impossible to distinguish Verified from AssumedComplete; now passed through unchanged from GrantAdminConsentAsync - GrantOutcome.AssumedGranted renamed to GrantOutcome.Unverified; setup summary row shows "unverified — run 'a365 query-entra inheritance' to confirm" and an Action Required block surfaces the re-grant URL - Add three display path tests for Unverified outcome in SetupHelpersDisplaySetupSummaryTests - Fix PollAdminConsentAsync_Graph_ReturnsVerified test to use factory lambda so each mock call gets a fresh JsonDocument (avoids ObjectDisposedException on reuse) - Fix CheckConsentExistsAsync test stubs to use 4-arg GraphGetAsync overload - Update stale ConsentPollResult.AssumedComplete and TryConsumeEnterKey doc comments after canary-403 path removal
…auth instead Device code requires the operator to open a browser, navigate to login.microsoft.com/device, and type a code — more steps than necessary. Without the flag, Connect-MgGraph uses WAM/browser auth on Windows (potentially silent if a cached token exists) and browser auth on macOS/Linux desktop. Headless scenarios are not a concern for this flow since it requires an interactive Global Administrator session regardless.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 24 out of 25 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (2)
src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs:88
ConfigureAllPermissionsAsyncfilters out any spec withScopes.Length == 0and then uses the filtered list for all phases. That made sense when Phase 2b created OAuth2 grants, but now Phase 2b is S2S app-role assignment; S2S-only specs (empty delegated scopes but non-emptyAppRoleScopes) would be dropped and never assigned (or surfaced as Action Required). The filter should keep specs that have delegated scopes OR app-role scopes (and possibly keep inheritable-only specs if those are valid).
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)
src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PermissionsSubcommandTests.cs:70
Dispose()sets up newReturns(...)stubs on_mockGraphApiService(IsCurrentUserAdminAsync/IsCurrentUserAgentIdAdminAsync). This has no practical effect (the substitute instance is about to be discarded) and makes it harder to see what the test setup actually is. These stubs should either be removed or moved into the constructor if they are required for the tests.
public void Dispose()
{
BrowserHelper.OpenUrlOverrideForTests = null;
AdminConsentHelper.BypassConsentChecksForTests = false;
GC.SuppressFinalize(this);
_mockGraphApiService.IsCurrentUserAdminAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Microsoft.Agents.A365.DevTools.Cli.Models.RoleCheckResult.Unknown);
_mockGraphApiService.IsCurrentUserAgentIdAdminAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Microsoft.Agents.A365.DevTools.Cli.Models.RoleCheckResult.Unknown);
}
| /// AsyncLocal scoping prevents leaks across parallel xUnit test classes; tests that set this | ||
| /// must still reset it in a finally/Dispose block. Not intended for production code. | ||
| /// </summary> | ||
| public static bool BypassConsentChecksForTests |
| "Non-admin flow: any step that needs Global Administrator action prints\n" + | ||
| "copy-paste PowerShell that an admin can run out-of-band."); |
- GrantAdminConsentAsync: return (true, null) when allConsented so ResourceConsents is persisted (verified signal requires null consentUrl) - GrantAdminConsentAsync: return (true, null) when no delegated scopes exist (nothing to consent = success, not failure) - BlueprintSubcommand: use <name> placeholder in mitigation step instead of interpolating displayName which may contain spaces or shell-special characters - EnsureOboScopeAsync: reduce maxRetries 5->2 for non-fatal OBO PATCH to avoid ~45s wait on deterministic 400/403 failures - PowerShellS2SRunner: replace Unicode em dashes with ASCII hyphens in all user-facing log messages and embedded PowerShell error strings - AdminConsentHelper: fix progress report interval 60s->30s in both overloads to match CHANGELOG - AdminConsentHelper: rethrow OperationCanceledException in Graph polling overload so Ctrl+C during consent polling terminates setup cleanly
Summary
a365 setup all/setup permissionsno longer calls the Microsoft Graphoauth2PermissionGrantsPOST API to grant delegated consent on the agentblueprint. Instead, the CLI builds a single
/v2.0/adminconsentURL thatcovers every delegated scope across all configured resources (Observability,
Power Platform, Messaging Bot, custom) and routes all delegated consent
through it.
Why
oauth2PermissionGrantsPOST requiresDelegatedPermissionGrant.ReadWrite.Allon the caller's token — a high-privilege permission most setup operators
don't have, leading to per-resource 403s and partial grants.
the admin to click through several screens for what is logically one
decision.
/v2.0/adminconsentURL works for both GA-and-non-GA callers,is the same flow the Entra portal uses, and lands all scopes atomically.
The legacy Phase 2b grant code path is removed entirely; ~280 lines of
orchestration and ~150 lines of associated tests are gone with it.
Also in this PR
access_agent_as_userOBO scope) on transientRequest_ResourceNotFound.New blueprint apps have a Graph write replica that lags the read replica
by several seconds, so the existing GET propagation check could succeed
while the immediately following PATCH 404'd, silently leaving the
blueprint incomplete. Retries are exhausted →
SetupValidationExceptionwith a
Re-run: a365 setup blueprint --agent-name <name>mitigation.DelegatedPermissionGrant.Read.All. The CLI now polls Graph every 5sin case the read permission becomes available, prints a 30s progress
update, and responds promptly to Enter or Ctrl+C. The jargon-heavy
message about
oauth2PermissionGrantsis demoted to Debug.NonDwBlueprintSetupOrchestratorinto
SetupHelpers; addHelpers/ConsoleHelper.ReadLineCancellable.Validation
a365 setup all --agent-name MCPPermissionsTest4:blueprint PATCH succeeded after replica catch-up, OBO scope added,
unified consent URL surfaced, canary polling and Enter handling behaved
as expected.
CHANGELOG.md[Unreleased]updated under### Fixedand### Changed.Admin Consent and S2S Permission Flow — Verified End-to-End
What the CLI configures on the blueprint
An Agent 365 blueprint service principal carries two distinct layers of permissions, both verified in the live run below:
1. Delegated permissions — scopes the blueprint grants to end-user sessions via the Agent Identity inheritance mechanism. These cover what an agent instance can do on behalf of a signed-in user (read mail, send chat, write telemetry, etc.). Consent is tenant-wide and is granted by a GA or Application Administrator completing the browser consent screen that the CLI opens.
2. Application permissions (S2S) — app role assignments granted directly to the blueprint's service principal so it can call APIs as itself (no user in the loop). The Observability API
Agent365.Observability.OtelWriterole is the only one configured in this profile. These requireAppRoleAssignment.ReadWrite.Allto assign via the Graph API.Before this PR — what failed and why
POST /v1.0/oauth2PermissionGrantsDelegatedPermissionGrant.ReadWrite.AllPOST /v1.0/servicePrincipals/{id}/appRoleAssignmentsAppRoleAssignment.ReadWrite.AllBoth operations were being attempted programmatically, requiring high-privilege Graph API scopes that the CLI's minimal 7-permission client app does not have and should not have.
After this PR — what happens now
Delegated consent: The CLI builds a single
/v2.0/adminconsent?client_id=…&scope=…URL covering all required delegated scopes for all resources atomically and opens the browser. A GA or Application Administrator completes the standard Entra consent screen — the same flow the Entra portal uses. The CLI no longer needsDelegatedPermissionGrant.ReadWrite.Allon its own token; the privilege is exercised by the human admin in the browser.S2S app role assignment: The CLI first tries the Graph API (fast path). On 403, it automatically runs the assignment script via the Microsoft Graph PowerShell SDK (
Connect-MgGraph -Scopes 'AppRoleAssignment.ReadWrite.All','Directory.Read.All'). The SDK authenticates via its own well-known app registration ("Microsoft Graph Command Line Tools") and — if those scopes have not already been consented to in the tenant — prompts the operator via device code flow at that point. No manual script copy-paste required.Verified run — 2026-05-21
Command:
a365 setup all --agent-name MCPPermissionsTest4Operator:
sellak@agent365002.onmicrosoft.com(Global Administrator)CLI version: 1.1.188+781b997
Tenant:
bc51ddf2-9c8b-45e7-8e08-c7ac238f6520(agent365002.onmicrosoft.com)Token scopes used by the CLI
From the
scpclaim logged at DEBUG level during the run:DelegatedPermissionGrant.ReadWrite.All— absent.AppRoleAssignment.ReadWrite.All— absent.The CLI operated entirely within its minimal 7-permission set.
Key log events
MCPPermissionsTest4 Blueprint(ID:1f6607fc-c27b-4d85-9ea1-f45b5b203b7f)AppRoleAssignment.ReadWrite.Allnot on CLI token)AYPJDCWVU)pwshexited 0 —A365-S2S-OKconfirmed — Observability API app role assignedDelegatedPermissionGrant.Read.All)Blueprint permissions confirmed via
a365 query-entra blueprint-scopesRun post-setup to confirm consent was applied and app role assignment is in place:
Observability API has both delegated and application (S2S) permissions — confirming the PowerShell fallback completed the app role assignment that the Graph API path could not.
CLI client app permission set (for reference)
The 7 delegated permissions required on the custom client app registration — no high-privilege admin scopes:
AgentIdentityBlueprint.ReadWrite.AllAgentIdentityBlueprintPrincipal.CreateAgentIdentity.Read.AllAgentIdentity.DeleteRestore.AllAgentRegistration.ReadWrite.AllApplication.Read.AllUser.Read