From d357089560d254c1c6b038526e021ae00014121c Mon Sep 17 00:00:00 2001 From: slreznit Date: Sun, 9 Aug 2026 23:32:25 +0300 Subject: [PATCH 1/7] Add Defender consent guidance --- CHANGELOG.md | 5 ++ .../Commands/QueryEntraCommand.cs | 1 + .../NonDwBlueprintSetupOrchestrator.cs | 13 ++-- .../SetupSubcommands/PermissionsSubcommand.cs | 2 + .../Commands/SetupSubcommands/SetupHelpers.cs | 66 +++++++++++++++---- .../Constants/ConfigConstants.cs | 19 ++++++ .../Services/LogRedactionService.cs | 1 + .../SetupSubcommands/PermissionSpecsTests.cs | 23 ++++++- ...tupHelpersAdminConsentInstructionsTests.cs | 16 +++++ .../Helpers/SetupHelpersConsentUrlTests.cs | 24 +++++-- 10 files changed, 145 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2b2eb14..e097c4e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,12 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g **Option B — CLI** (`a365 setup admin`) has been removed in this release. Use Option A above, or copy the PowerShell instructions printed in the `a365 setup all` summary output. +#### Existing agents: grant Defender API permissions + +Agents provisioned before this release need `AIAgentsRTP.ToolInvocation` granted as both a **delegated** and an **application** permission on the blueprint app for the Defender security integration. Requires Global Administrator. Follow the steps above, searching for `86a21212-634e-4553-b3d6-e477e4c9d9ec` in step 2 and selecting `AIAgentsRTP.ToolInvocation` in steps 3 and 4. Re-running `a365 setup all` grants it automatically. + ### Added +- `AIAgentsRTP.ToolInvocation` on the Defender API is now granted automatically during `a365 setup` as both a delegated and an application permission, enabling the Microsoft Defender security integration without manual Entra steps. - Log separator written at the start of each CLI invocation now redacts values for secret-bearing options (e.g. `--idp-client-secret`) so they are not written to the log file in plain text. - Authentication context (tenant and user) is now logged at the `Information` level whenever the resolved sign-in identity changes, giving operators a clear audit trail in the log file of who the CLI is acting as, without exposing credentials. - `a365 develop-mcp evaluate` command for evaluating MCP server tool schema quality — runs deterministic and semantic checks (via GitHub Copilot or Claude Code CLIs), computes maturity scoring, and generates an interactive HTML report diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs index 46ad66c9..c544afbf 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/QueryEntraCommand.cs @@ -616,6 +616,7 @@ private static Command CreateInstanceScopesSubcommand( AuthenticationConstants.MicrosoftGraphResourceAppId => "Microsoft Graph", ConfigConstants.MessagingBotApiAppId => "Messaging Bot API", ConfigConstants.ObservabilityApiAppId => "Observability API", + ConfigConstants.DefenderApiAppId => "Defender API", PowerPlatformConstants.PowerPlatformApiResourceAppId => "Power Platform API", "00000002-0000-0000-c000-000000000000" => "Azure Active Directory Graph", "797f4846-ba00-4fd7-ba43-dac1f8f63013" => "Azure Service Management", diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs index 9a6150cc..e5b5e10b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs @@ -20,8 +20,8 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; /// 1. Requirements validation /// 2. Blueprint creation (shared with DW) /// 3. Batch permissions on the blueprint (shared with DW pipeline; non-DW spec set: -/// Observability API, Power Platform API, custom). MAC reads from the blueprint, -/// so stamping here gives the same set visibility there. +/// Observability API, Defender API, Power Platform API, custom). MAC reads +/// from the blueprint, so stamping here gives the same set visibility there. /// 4. Agent Identity creation via POST /beta/servicePrincipals/Microsoft.Graph.AgentIdentity /// 5. Agent Identity permission grants (same spec set as step 3) — OBO or S2S /// 6. Agent registration via Graph API (copilot/agentRegistrations) @@ -117,14 +117,15 @@ public static void PrintDryRunPlan(Agent365Config config, ILogger logger, bool i logger.LogInformation(sub + "create managed identity"); } - // 3. Inheritable Permissions — non-DW spec set (Observability API, Power Platform API, custom) - // stamped on the blueprint via SetInheritablePermissionsAsync so MAC and other dependent - // systems can see them. The same set is applied to the agent identity SP in step 5. + // 3. Inheritable Permissions — non-DW spec set (Observability API, Defender API, + // Power Platform API, custom) stamped on the blueprint via SetInheritablePermissionsAsync + // so MAC and other dependent systems can see them. The same set is applied to the agent + // identity SP in step 5. var selectedAuthMode = authMode ?? config.AuthMode; var effectiveMode = string.IsNullOrWhiteSpace(selectedAuthMode) ? "obo" : selectedAuthMode.Trim().ToLowerInvariant(); - logger.LogInformation(SetupHelpers.DryRunRow(3, "Inheritable Permissions") + "configure for Observability API, Power Platform API, and custom permissions (Global Administrator required; consent URL printed if absent)"); + logger.LogInformation(SetupHelpers.DryRunRow(3, "Inheritable Permissions") + "configure for Observability API, Defender API, Power Platform API, and custom permissions (Global Administrator required; consent URL printed if absent)"); // 4. Blueprint Permission Grants — per authMode. The consent URL targets the blueprint // app, and S2S app-role assignments are persisted as grants flowing from the blueprint; 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 c1b4aaea..e9d12b08 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs @@ -310,6 +310,7 @@ private static Command CreateBotSubcommand( logger.LogInformation(" - Blueprint: {BlueprintId}", dryRunConfig.AgentBlueprintId); logger.LogInformation(" - Messaging Bot API: {Scope}", ConfigConstants.MessagingBotApiAdminConsentScope); logger.LogInformation(" - Observability API: {OtelScope} (delegated + application)", ConfigConstants.ObservabilityApiOtelWriteScope); + logger.LogInformation(" - Defender API: {DefenderScope} (delegated + application)", ConfigConstants.DefenderApiToolInvocationScope); logger.LogInformation(" - Power Platform API: Connectivity.Connections.Read"); logger.LogInformation("No changes made. Run without --dry-run to execute."); return; @@ -852,6 +853,7 @@ internal static async Task RemoveStaleCustomPermissionsAsync( envAtgAppId, ConfigConstants.MessagingBotApiAppId, ConfigConstants.ObservabilityApiAppId, + ConfigConstants.DefenderApiAppId, PowerPlatformConstants.PowerPlatformApiResourceAppId, AuthenticationConstants.MicrosoftGraphResourceAppId, }; 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 53e85506..a24bef0b 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs @@ -49,9 +49,9 @@ internal static void PrintDryRunBlueprintReuseRows(ILogger logger, string bluepr /// Returns the fixed-scope ResourcePermissionSpecs for the platform APIs that every /// agent blueprint requires. /// - /// Observability API and Power Platform API are always included. Messaging Bot API is - /// included only when is true — non-M365 (blueprint-only) agents - /// have no messaging surface so Bot scopes serve no purpose. + /// Observability API, Defender API, and Power Platform API are always included. + /// Messaging Bot API is included only when is true — non-M365 + /// (blueprint-only) agents have no messaging surface so Bot scopes serve no purpose. /// /// internal static ResourcePermissionSpec[] GetFixedApiPermissionSpecs(bool setInheritable, bool isM365) @@ -79,6 +79,12 @@ internal static ResourcePermissionSpec[] GetFixedApiPermissionSpecs(bool setInhe new[] { ConfigConstants.ObservabilityApiOtelWriteScope }, setInheritable, AppRoleScopes: new[] { ConfigConstants.ObservabilityApiOtelWriteScope })); + specs.Add(new ResourcePermissionSpec( + ConfigConstants.DefenderApiAppId, + "Defender API", + new[] { ConfigConstants.DefenderApiToolInvocationScope }, + setInheritable, + AppRoleScopes: new[] { ConfigConstants.DefenderApiToolInvocationScope })); specs.Add(new ResourcePermissionSpec( PowerPlatformConstants.PowerPlatformApiResourceAppId, "Power Platform API", @@ -362,8 +368,8 @@ internal static async Task> BuildConfiguredPermissi /// /// Fixed permission specs for the non-DW admin consent flow. - /// Observability API requires both Application (app role for S2S) and Delegated (oauth2 grant for OBO). - /// Power Platform API requires Delegated only. + /// Observability API and Defender API require both Application (app role for S2S) + /// and Delegated (oauth2 grant for OBO). Power Platform API requires Delegated only. /// Extend this list or pass an override to /// when additional APIs are required (e.g. dynamic MCP scopes, custom permissions). /// @@ -371,14 +377,26 @@ internal static async Task> BuildConfiguredPermissi [ ("Observability API", ConfigConstants.ObservabilityApiAppId, ConfigConstants.ObservabilityApiOtelWriteScope, "Application"), ("Observability API", ConfigConstants.ObservabilityApiAppId, ConfigConstants.ObservabilityApiOtelWriteScope, "Delegated"), + ("Defender API", ConfigConstants.DefenderApiAppId, ConfigConstants.DefenderApiToolInvocationScope, "Application"), + ("Defender API", ConfigConstants.DefenderApiAppId, ConfigConstants.DefenderApiToolInvocationScope, "Delegated"), ("Power Platform API", PowerPlatformConstants.PowerPlatformApiResourceAppId, PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead, "Delegated"), ]; + /// + /// Fixed platform APIs that expose an application (S2S) app role, used to render the manual + /// PowerShell hand-off when the programmatic assignment could not complete. + /// + internal static readonly IReadOnlyList<(string ResourceName, string ResourceAppId, string Role)> FixedApiAppRoleHandoffSpecs = + [ + ("Observability API", ConfigConstants.ObservabilityApiAppId, ConfigConstants.ObservabilityApiOtelWriteScope), + ("Defender API", ConfigConstants.DefenderApiAppId, ConfigConstants.DefenderApiToolInvocationScope), + ]; + /// /// Logs step-by-step instructions for a Global Administrator to grant admin consent /// for the blueprint app, with two options: Entra portal and PowerShell. /// - /// Defaults to (Observability API + Power Platform API). + /// Defaults to (Observability, Defender, and Power Platform APIs). /// Pass an explicit list to support dynamic or extended permission sets. /// /// @@ -831,7 +849,7 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger) { actionCount++; logger.LogInformation(""); - logger.LogInformation(" {N}. Observability API S2S app role (PowerShell):", actionCount); + logger.LogInformation(" {N}. Application (S2S) app roles (PowerShell):", actionCount); logger.LogInformation(" Required role: {Roles}", AuthenticationConstants.S2SGrantRequiredRoles); if (!string.IsNullOrWhiteSpace(results.TenantId)) logger.LogInformation(" Connect-MgGraph -TenantId '{TenantId}' -Scopes 'AppRoleAssignment.ReadWrite.All','Application.Read.All'", results.TenantId); @@ -844,9 +862,14 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger) // Grant targets the agent identity SP directly (SP object ID, not an app ID). var agentSpId = results.AgentIdentityId ?? ""; logger.LogInformation(" $agentSpId = '{AgentSpId}'", agentSpId); - logger.LogInformation(" $obs = Get-MgServicePrincipal -Filter \"appId eq '{ObsApiAppId}'\"", ConfigConstants.ObservabilityApiAppId); - logger.LogInformation(" $rid = ($obs.AppRoles | Where-Object {{ $_.Value -eq '{ObsScope}' }}).Id", ConfigConstants.ObservabilityApiOtelWriteScope); - logger.LogInformation(" New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $agentSpId -PrincipalId $agentSpId -ResourceId $obs.Id -AppRoleId $rid"); + foreach (var (resourceName, resourceAppId, role) in FixedApiAppRoleHandoffSpecs) + { + logger.LogInformation(""); + logger.LogInformation(" # {ResourceName}: {Role}", resourceName, role); + logger.LogInformation(" $res = Get-MgServicePrincipal -Filter \"appId eq '{ResAppId}'\"", resourceAppId); + logger.LogInformation(" $rid = ($res.AppRoles | Where-Object {{ $_.Value -eq '{Role}' }}).Id", role); + logger.LogInformation(" New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $agentSpId -PrincipalId $agentSpId -ResourceId $res.Id -AppRoleId $rid"); + } logger.LogInformation(""); if (!string.IsNullOrWhiteSpace(results.TenantId)) logger.LogInformation(" Tenant : {TenantId}", results.TenantId); @@ -856,9 +879,14 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger) { // DW: grant targets the blueprint SP (looked up by app ID). logger.LogInformation(" $bp = Get-MgServicePrincipal -Filter \"appId eq '{BlueprintAppId}'\"", blueprintAppId); - logger.LogInformation(" $obs = Get-MgServicePrincipal -Filter \"appId eq '{ObsApiAppId}'\"", ConfigConstants.ObservabilityApiAppId); - logger.LogInformation(" $rid = ($obs.AppRoles | Where-Object {{ $_.Value -eq '{ObsScope}' }}).Id", ConfigConstants.ObservabilityApiOtelWriteScope); - logger.LogInformation(" New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $bp.Id -PrincipalId $bp.Id -ResourceId $obs.Id -AppRoleId $rid"); + foreach (var (resourceName, resourceAppId, role) in FixedApiAppRoleHandoffSpecs) + { + logger.LogInformation(""); + logger.LogInformation(" # {ResourceName}: {Role}", resourceName, role); + logger.LogInformation(" $res = Get-MgServicePrincipal -Filter \"appId eq '{ResAppId}'\"", resourceAppId); + logger.LogInformation(" $rid = ($res.AppRoles | Where-Object {{ $_.Value -eq '{Role}' }}).Id", role); + logger.LogInformation(" New-MgServicePrincipalAppRoleAssignment -ServicePrincipalId $bp.Id -PrincipalId $bp.Id -ResourceId $res.Id -AppRoleId $rid"); + } logger.LogInformation(""); logger.LogInformation(" To share with your {Roles}:", AuthenticationConstants.S2SGrantRequiredRoles); logger.LogInformation(" Blueprint : {BlueprintAppId}", blueprintAppId); @@ -883,6 +911,11 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger) logger.LogInformation(" $body = @{{ clientId = $agentSpId; consentType = 'AllPrincipals'; resourceId = $obsSp.Id; scope = '{ObsScope}' }} | ConvertTo-Json", ConfigConstants.ObservabilityApiOtelWriteScope); logger.LogInformation(" Invoke-MgGraphRequest -Method POST -Uri 'https://graph.microsoft.com/v1.0/oauth2PermissionGrants' -Body $body -ContentType 'application/json'"); logger.LogInformation(""); + logger.LogInformation(" # Defender API"); + logger.LogInformation(" $defenderSp = Get-MgServicePrincipal -Filter \"appId eq '{DefenderAppId}'\"", ConfigConstants.DefenderApiAppId); + logger.LogInformation(" $body = @{{ clientId = $agentSpId; consentType = 'AllPrincipals'; resourceId = $defenderSp.Id; scope = '{DefenderScope}' }} | ConvertTo-Json", ConfigConstants.DefenderApiToolInvocationScope); + logger.LogInformation(" Invoke-MgGraphRequest -Method POST -Uri 'https://graph.microsoft.com/v1.0/oauth2PermissionGrants' -Body $body -ContentType 'application/json'"); + logger.LogInformation(""); logger.LogInformation(" # Power Platform API"); logger.LogInformation(" $ppSp = Get-MgServicePrincipal -Filter \"appId eq '{PpAppId}'\"", PowerPlatformConstants.PowerPlatformApiResourceAppId); logger.LogInformation(" $body = @{{ clientId = $agentSpId; consentType = 'AllPrincipals'; resourceId = $ppSp.Id; scope = '{PpScope}' }} | ConvertTo-Json", PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead); @@ -1076,6 +1109,7 @@ internal static List PopulateAdminConsentUrls( ["Agent 365 Tools"] = mcpResourceAppId, ["Messaging Bot API"] = ConfigConstants.MessagingBotApiAppId, ["Observability API"] = ConfigConstants.ObservabilityApiAppId, + ["Defender API"] = ConfigConstants.DefenderApiAppId, ["Power Platform API"] = PowerPlatformConstants.PowerPlatformApiResourceAppId, }; @@ -1177,6 +1211,8 @@ internal static string GetResourceIdentifierUri(string resourceAppId, bool isMcp return ConfigConstants.MessagingBotApiIdentifierUri; if (string.Equals(resourceAppId, ConfigConstants.ObservabilityApiAppId, StringComparison.OrdinalIgnoreCase)) return ConfigConstants.ObservabilityApiIdentifierUri; + if (string.Equals(resourceAppId, ConfigConstants.DefenderApiAppId, StringComparison.OrdinalIgnoreCase)) + return ConfigConstants.DefenderApiIdentifierUri; if (string.Equals(resourceAppId, PowerPlatformConstants.PowerPlatformApiResourceAppId, StringComparison.OrdinalIgnoreCase)) return PowerPlatformConstants.PowerPlatformApiIdentifierUri; // WorkIQ Tools shared (issue #429): match by appId, not display name. V2 per-server @@ -1316,6 +1352,7 @@ static string Build(string tenant, string client, string resourceUri, IEnumerabl urls.Add(("Messaging Bot API", Build(tenantId, blueprintClientId, ConfigConstants.MessagingBotApiIdentifierUri, new[] { ConfigConstants.MessagingBotApiAdminConsentScope }))); urls.Add(("Observability API", Build(tenantId, blueprintClientId, ConfigConstants.ObservabilityApiIdentifierUri, new[] { ConfigConstants.ObservabilityApiOtelWriteScope }))); + urls.Add(("Defender API", Build(tenantId, blueprintClientId, ConfigConstants.DefenderApiIdentifierUri, new[] { ConfigConstants.DefenderApiToolInvocationScope }))); urls.Add(("Power Platform API", Build(tenantId, blueprintClientId, PowerPlatformConstants.PowerPlatformApiIdentifierUri, new[] { PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead }))); return urls; @@ -1371,6 +1408,7 @@ internal static string BuildCombinedConsentUrl( if (isM365) allScopes.Add($"{ConfigConstants.MessagingBotApiIdentifierUri}/{ConfigConstants.MessagingBotApiAdminConsentScope}"); allScopes.Add($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiOtelWriteScope}"); + allScopes.Add($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiToolInvocationScope}"); allScopes.Add($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}"); return BuildAdminConsentUrl(tenantId, blueprintClientId, allScopes); } @@ -1454,7 +1492,7 @@ internal static void PrintDwSetupAllDryRunPlan( } // 4. Inheritable Permissions - logger.LogInformation(DryRunRow(4, "Inheritable Permissions") + "configure for Microsoft Graph, Agent 365 Tools, Messaging Bot API, Observability API, Power Platform API"); + logger.LogInformation(DryRunRow(4, "Inheritable Permissions") + "configure for Microsoft Graph, Agent 365 Tools, Messaging Bot API, Observability API, Defender API, Power Platform API"); // 5. Blueprint Permission Grants logger.LogInformation(DryRunRow(5, "Blueprint Permission Grants") + "admin approval required — see 'Action Required' in setup output"); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs index bf2fe665..a6a808c4 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs @@ -80,6 +80,18 @@ public static class ConfigConstants /// public const string ObservabilityApiIdentifierUri = "api://9b975845-388f-4429-889e-eab1ef63949c"; + /// + /// Defender API App ID. Hosts the security inspection endpoint used by the Defender integration. + /// + public const string DefenderApiAppId = "86a21212-634e-4553-b3d6-e477e4c9d9ec"; + + /// + /// Defender API identifier URI. Unlike the Observability API this resource + /// publishes an https identifier URI only — api://{appId} is not in its + /// servicePrincipalNames and consent fails with AADSTS500011. + /// + public const string DefenderApiIdentifierUri = "https://rtp-a365.ai.defender.microsoft.com"; + /// /// Single source of truth for the Messaging Bot API delegated scope. /// The resource SP (appId 5a807f24-c9de-44ee-a3a7-329e88a00ffc) exposes exactly @@ -97,6 +109,13 @@ public static class ConfigConstants /// public const string ObservabilityApiOtelWriteScope = "Agent365.Observability.OtelWrite"; + /// + /// Defender API scope for tool invocation inspection, enabling the Defender + /// security integration. Published on the resource as both a delegated scope (OBO) and an + /// application app role (S2S), so it is granted through both paths like OtelWrite. + /// + public const string DefenderApiToolInvocationScope = "AIAgentsRTP.ToolInvocation"; + /// /// Delegated scope value exposed on the blueprint app registration to enable /// OBO (On-Behalf-Of) callers to acquire tokens scoped to the agent. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/LogRedactionService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/LogRedactionService.cs index cf678914..b3d09442 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/LogRedactionService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/LogRedactionService.cs @@ -59,6 +59,7 @@ public sealed class LogRedactionService : ILogRedactionService "00000003-0000-0000-c000-000000000000", // Microsoft Graph "5a807f24-c9de-44ee-a3a7-329e88a00ffc", // Agent 365 Messaging Bot API "9b975845-388f-4429-889e-eab1ef63949c", // Agent 365 Observability API + "86a21212-634e-4553-b3d6-e477e4c9d9ec", // Agent 365 Defender API "8578e004-a5c6-46e7-913e-12f58912df43", // Power Platform API (Connectivity) "ea9ffc3e-8a23-4a7d-836d-234d7c7565c1", // Agent 365 Tools (MCP audience, production) }; diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs index 674d6a52..38d4d71b 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs @@ -89,9 +89,10 @@ public async Task DwPath_NoManifest_NoCustom_ProducesBaselineSpecSet() AuthenticationConstants.MicrosoftGraphResourceAppId, ConfigConstants.MessagingBotApiAppId, ConfigConstants.ObservabilityApiAppId, + ConfigConstants.DefenderApiAppId, PowerPlatformConstants.PowerPlatformApiResourceAppId, McpConstants.WorkIQToolsProdAppId, - }, because: "the DW baseline spec set is the four fixed platform APIs plus the ATG AppId (seeded with McpServersMetadata.Read.All for V1 compatibility)"); + }, because: "the DW baseline spec set is the five fixed platform APIs (including the Defender API required by the security integration) plus the ATG AppId (seeded with McpServersMetadata.Read.All for V1 compatibility)"); // Assert: ATG entry carries only the seeded V1-compat scope when no manifest is present. SpecFor(specs, McpConstants.WorkIQToolsProdAppId).Scopes.Should().BeEquivalentTo(new[] { McpServersMetadataReadAll }, @@ -260,6 +261,7 @@ public async Task Unified_WithManifest_IsM365_StampsFullSet() AuthenticationConstants.MicrosoftGraphResourceAppId, ConfigConstants.MessagingBotApiAppId, ConfigConstants.ObservabilityApiAppId, + ConfigConstants.DefenderApiAppId, PowerPlatformConstants.PowerPlatformApiResourceAppId, McpConstants.WorkIQToolsProdAppId, }, because: "with a manifest present and isM365 true, blueprint agents must receive the same spec set as DW agents — this is the unified-pipeline contract"); @@ -286,6 +288,25 @@ public async Task ObservabilityApi_CarriesBothDelegatedScopeAndAppRole() because: "Observability API app role grants OtelWrite for the s2s path — losing either side breaks one auth mode"); } + [Fact] + public async Task DefenderApi_CarriesBothDelegatedScopeAndAppRole() + { + // Arrange: smallest config that produces the Defender spec on either path. + var config = new Agent365Config { DeploymentProjectPath = _tempDir }; + + // Act + var specs = await SetupHelpers.BuildConfiguredPermissionSpecsAsync(config, setInheritable: true, isM365: true); + + // Assert + var defender = SpecFor(specs, ConfigConstants.DefenderApiAppId); + defender.Scopes.Should().BeEquivalentTo(new[] { ConfigConstants.DefenderApiToolInvocationScope }, + because: "the Defender API delegated scope grants ToolInvocation for the OBO path"); + defender.AppRoleScopes.Should().BeEquivalentTo(new[] { ConfigConstants.DefenderApiToolInvocationScope }, + because: "the Defender API app role grants ToolInvocation for the s2s path — the Defender webhook rejects tokens without the roles claim, so losing either side breaks one auth mode"); + defender.SetInheritable.Should().BeTrue( + because: "agent identities minted from the blueprint must inherit the Defender permission, exactly as they do for OtelWrite"); + } + [Fact] public async Task MessagingBotApi_UsesScopeConstantSoSpecAndConsentUrlAgree() { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs index 4e8d61e1..c5b14b36 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs @@ -61,6 +61,22 @@ public void LogNonDwAdminConsentInstructions_OptionA_ShowsDelegatedPermissionsIn because: "only delegated grants are needed for OBO — no Application permissions"); } + [Fact] + public void LogNonDwAdminConsentInstructions_OptionA_ShowsDefenderDelegatedPermission() + { + var logger = new CapturingLogger(); + + SetupHelpers.LogNonDwAdminConsentInstructions(logger, BlueprintId); + + var defenderLines = logger.Messages + .Where(m => m.Contains("Defender API") && m.Contains(ConfigConstants.DefenderApiToolInvocationScope)) + .ToList(); + defenderLines.Should().HaveCount(1, + because: "the Defender API delegated scope must appear exactly once so the admin grants it alongside the other platform APIs"); + defenderLines[0].Should().Contain("Delegated", + because: "only delegated grants are needed for OBO — no Application permissions"); + } + [Fact] public void LogNonDwAdminConsentInstructions_DoesNotEmitOptionBPowerShell() { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs index 9607bbb4..13adc210 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs @@ -26,13 +26,14 @@ public void BuildAdminConsentUrls_WithGraphAndMcpScopes_ReturnsUrlForEachResourc var urls = SetupHelpers.BuildAdminConsentUrls(TenantId, BlueprintClientId, graphScopes, mcpScopes); - urls.Should().HaveCount(5); + urls.Should().HaveCount(6); urls.Select(u => u.ResourceName).Should().Contain(new[] { "Microsoft Graph", "Agent 365 Tools", "Messaging Bot API", "Observability API", + "Defender API", "Power Platform API" }); } @@ -72,6 +73,18 @@ public void BuildAdminConsentUrls_ObservabilityApi_UsesCorrectScopeConstant() because: "OtelWrite is the published delegated scope on the Observability API used for admin consent"); } + [Fact] + public void BuildAdminConsentUrls_DefenderApi_UsesHttpsIdentifierUriNotApiScheme() + { + var urls = SetupHelpers.BuildAdminConsentUrls(TenantId, BlueprintClientId, new[] { "Mail.Send" }, new[] { "scope" }); + var defenderUrl = urls.First(u => u.ResourceName == "Defender API").ConsentUrl; + + defenderUrl.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiToolInvocationScope}"), + because: "the Defender resource publishes only the https identifier URI — api://{appId} is not in its servicePrincipalNames and consent fails with AADSTS500011"); + defenderUrl.Should().NotContain(Uri.EscapeDataString($"api://{ConfigConstants.DefenderApiAppId}"), + because: "the api:// form of the Defender resource is not a registered servicePrincipalName"); + } + [Fact] public void BuildAdminConsentUrls_PowerPlatformApi_UsesCorrectScopeConstant() { @@ -211,9 +224,9 @@ public void BuildCombinedConsentUrl_IncludesAllMcpScopes() } [Fact] - public void BuildCombinedConsentUrl_AlwaysIncludesAllThreeFixedResources() + public void BuildCombinedConsentUrl_AlwaysIncludesAllFixedResources() { - // Even with empty graph and MCP scopes, the three fixed resources must be present + // Even with empty graph and MCP scopes, the fixed resources must be present var url = SetupHelpers.BuildCombinedConsentUrl( TenantId, BlueprintClientId, Array.Empty(), Array.Empty()); @@ -222,6 +235,8 @@ public void BuildCombinedConsentUrl_AlwaysIncludesAllThreeFixedResources() because: "scope URIs are Uri.EscapeDataString-encoded in the query string — required by AAD for adminconsent"); url.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiOtelWriteScope}"), because: "OtelWrite is the published delegated scope on the Observability API used for admin consent"); + url.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiToolInvocationScope}"), + because: "ToolInvocation is the published delegated scope on the Defender API — without it the agent cannot call the Defender security webhook"); url.Should().Contain(Uri.EscapeDataString($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}")); } @@ -264,8 +279,9 @@ public void BuildAdminConsentUrls_NonM365_ExcludesMessagingBotButKeepsAllOthers( "Microsoft Graph", "Agent 365 Tools", "Observability API", + "Defender API", "Power Platform API", - }, because: "non-M365 tenants lack the Messaging Bot resource SP — Bot would cause AADSTS650053 if included"); + }, because: "non-M365 tenants lack the Messaging Bot resource SP — Bot would cause AADSTS650053 if included; the Defender API is required for the security integration on every agent regardless of M365 surface"); } [Fact] From a345450c653c069e0063b1604148047665da1e61 Mon Sep 17 00:00:00 2001 From: slreznit Date: Tue, 11 Aug 2026 16:24:18 +0300 Subject: [PATCH 2/7] Rename Defender app role to RealtimeProtection.Process Updates the Defender API app role and delegated scope value from AIAgentsRTP.ToolInvocation to RealtimeProtection.Process, and renames the constant accordingly since the old name no longer described the role. NOT YET VERIFIED AGAINST A LIVE RESOURCE. The resource SP still publishes AIAgentsRTP.ToolInvocation in the agent365003 tenant, and the resource is not provisioned in the corp tenant at all, so the new value could not be confirmed anywhere. Until the Defender-side rename ships, the combined /v2.0/adminconsent URL will fail with AADSTS650053 for every resource in the request - Graph, MCP, Bot, Observability and Power Platform - not just Defender, and the S2S app role lookup will find no matching appRoles entry. Confirm the resource publishes the new value before merging. Adds DefenderApi_ScopeValue_MatchesValuePublishedOnResource pinning the literal string so future drift fails a test that explains the blast radius. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 ++-- .../SetupSubcommands/PermissionsSubcommand.cs | 2 +- .../Commands/SetupSubcommands/SetupHelpers.cs | 16 ++++++++-------- .../Constants/ConfigConstants.cs | 9 +++++---- .../SetupSubcommands/PermissionSpecsTests.cs | 18 ++++++++++++++---- ...etupHelpersAdminConsentInstructionsTests.cs | 2 +- .../Helpers/SetupHelpersConsentUrlTests.cs | 6 +++--- 7 files changed, 34 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e097c4e3..4ee34c3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,10 +24,10 @@ Agents provisioned before this release need `Agent365.Observability.OtelWrite` g #### Existing agents: grant Defender API permissions -Agents provisioned before this release need `AIAgentsRTP.ToolInvocation` granted as both a **delegated** and an **application** permission on the blueprint app for the Defender security integration. Requires Global Administrator. Follow the steps above, searching for `86a21212-634e-4553-b3d6-e477e4c9d9ec` in step 2 and selecting `AIAgentsRTP.ToolInvocation` in steps 3 and 4. Re-running `a365 setup all` grants it automatically. +Agents provisioned before this release need `RealtimeProtection.Process` granted as both a **delegated** and an **application** permission on the blueprint app for the Defender security integration. Requires Global Administrator. Follow the steps above, searching for `86a21212-634e-4553-b3d6-e477e4c9d9ec` in step 2 and selecting `RealtimeProtection.Process` in steps 3 and 4. Re-running `a365 setup all` grants it automatically. ### Added -- `AIAgentsRTP.ToolInvocation` on the Defender API is now granted automatically during `a365 setup` as both a delegated and an application permission, enabling the Microsoft Defender security integration without manual Entra steps. +- `RealtimeProtection.Process` on the Defender API is now granted automatically during `a365 setup` as both a delegated and an application permission, enabling the Microsoft Defender security integration without manual Entra steps. - Log separator written at the start of each CLI invocation now redacts values for secret-bearing options (e.g. `--idp-client-secret`) so they are not written to the log file in plain text. - Authentication context (tenant and user) is now logged at the `Information` level whenever the resolved sign-in identity changes, giving operators a clear audit trail in the log file of who the CLI is acting as, without exposing credentials. - `a365 develop-mcp evaluate` command for evaluating MCP server tool schema quality — runs deterministic and semantic checks (via GitHub Copilot or Claude Code CLIs), computes maturity scoring, and generates an interactive HTML report 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 e9d12b08..0f32b988 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/PermissionsSubcommand.cs @@ -310,7 +310,7 @@ private static Command CreateBotSubcommand( logger.LogInformation(" - Blueprint: {BlueprintId}", dryRunConfig.AgentBlueprintId); logger.LogInformation(" - Messaging Bot API: {Scope}", ConfigConstants.MessagingBotApiAdminConsentScope); logger.LogInformation(" - Observability API: {OtelScope} (delegated + application)", ConfigConstants.ObservabilityApiOtelWriteScope); - logger.LogInformation(" - Defender API: {DefenderScope} (delegated + application)", ConfigConstants.DefenderApiToolInvocationScope); + logger.LogInformation(" - Defender API: {DefenderScope} (delegated + application)", ConfigConstants.DefenderApiRealtimeProtectionScope); logger.LogInformation(" - Power Platform API: Connectivity.Connections.Read"); logger.LogInformation("No changes made. Run without --dry-run to execute."); return; 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 a24bef0b..0f0e69a8 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs @@ -82,9 +82,9 @@ internal static ResourcePermissionSpec[] GetFixedApiPermissionSpecs(bool setInhe specs.Add(new ResourcePermissionSpec( ConfigConstants.DefenderApiAppId, "Defender API", - new[] { ConfigConstants.DefenderApiToolInvocationScope }, + new[] { ConfigConstants.DefenderApiRealtimeProtectionScope }, setInheritable, - AppRoleScopes: new[] { ConfigConstants.DefenderApiToolInvocationScope })); + AppRoleScopes: new[] { ConfigConstants.DefenderApiRealtimeProtectionScope })); specs.Add(new ResourcePermissionSpec( PowerPlatformConstants.PowerPlatformApiResourceAppId, "Power Platform API", @@ -377,8 +377,8 @@ internal static async Task> BuildConfiguredPermissi [ ("Observability API", ConfigConstants.ObservabilityApiAppId, ConfigConstants.ObservabilityApiOtelWriteScope, "Application"), ("Observability API", ConfigConstants.ObservabilityApiAppId, ConfigConstants.ObservabilityApiOtelWriteScope, "Delegated"), - ("Defender API", ConfigConstants.DefenderApiAppId, ConfigConstants.DefenderApiToolInvocationScope, "Application"), - ("Defender API", ConfigConstants.DefenderApiAppId, ConfigConstants.DefenderApiToolInvocationScope, "Delegated"), + ("Defender API", ConfigConstants.DefenderApiAppId, ConfigConstants.DefenderApiRealtimeProtectionScope, "Application"), + ("Defender API", ConfigConstants.DefenderApiAppId, ConfigConstants.DefenderApiRealtimeProtectionScope, "Delegated"), ("Power Platform API", PowerPlatformConstants.PowerPlatformApiResourceAppId, PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead, "Delegated"), ]; @@ -389,7 +389,7 @@ internal static async Task> BuildConfiguredPermissi internal static readonly IReadOnlyList<(string ResourceName, string ResourceAppId, string Role)> FixedApiAppRoleHandoffSpecs = [ ("Observability API", ConfigConstants.ObservabilityApiAppId, ConfigConstants.ObservabilityApiOtelWriteScope), - ("Defender API", ConfigConstants.DefenderApiAppId, ConfigConstants.DefenderApiToolInvocationScope), + ("Defender API", ConfigConstants.DefenderApiAppId, ConfigConstants.DefenderApiRealtimeProtectionScope), ]; /// @@ -913,7 +913,7 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger) logger.LogInformation(""); logger.LogInformation(" # Defender API"); logger.LogInformation(" $defenderSp = Get-MgServicePrincipal -Filter \"appId eq '{DefenderAppId}'\"", ConfigConstants.DefenderApiAppId); - logger.LogInformation(" $body = @{{ clientId = $agentSpId; consentType = 'AllPrincipals'; resourceId = $defenderSp.Id; scope = '{DefenderScope}' }} | ConvertTo-Json", ConfigConstants.DefenderApiToolInvocationScope); + logger.LogInformation(" $body = @{{ clientId = $agentSpId; consentType = 'AllPrincipals'; resourceId = $defenderSp.Id; scope = '{DefenderScope}' }} | ConvertTo-Json", ConfigConstants.DefenderApiRealtimeProtectionScope); logger.LogInformation(" Invoke-MgGraphRequest -Method POST -Uri 'https://graph.microsoft.com/v1.0/oauth2PermissionGrants' -Body $body -ContentType 'application/json'"); logger.LogInformation(""); logger.LogInformation(" # Power Platform API"); @@ -1352,7 +1352,7 @@ static string Build(string tenant, string client, string resourceUri, IEnumerabl urls.Add(("Messaging Bot API", Build(tenantId, blueprintClientId, ConfigConstants.MessagingBotApiIdentifierUri, new[] { ConfigConstants.MessagingBotApiAdminConsentScope }))); urls.Add(("Observability API", Build(tenantId, blueprintClientId, ConfigConstants.ObservabilityApiIdentifierUri, new[] { ConfigConstants.ObservabilityApiOtelWriteScope }))); - urls.Add(("Defender API", Build(tenantId, blueprintClientId, ConfigConstants.DefenderApiIdentifierUri, new[] { ConfigConstants.DefenderApiToolInvocationScope }))); + urls.Add(("Defender API", Build(tenantId, blueprintClientId, ConfigConstants.DefenderApiIdentifierUri, new[] { ConfigConstants.DefenderApiRealtimeProtectionScope }))); urls.Add(("Power Platform API", Build(tenantId, blueprintClientId, PowerPlatformConstants.PowerPlatformApiIdentifierUri, new[] { PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead }))); return urls; @@ -1408,7 +1408,7 @@ internal static string BuildCombinedConsentUrl( if (isM365) allScopes.Add($"{ConfigConstants.MessagingBotApiIdentifierUri}/{ConfigConstants.MessagingBotApiAdminConsentScope}"); allScopes.Add($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiOtelWriteScope}"); - allScopes.Add($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiToolInvocationScope}"); + allScopes.Add($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiRealtimeProtectionScope}"); allScopes.Add($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}"); return BuildAdminConsentUrl(tenantId, blueprintClientId, allScopes); } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs index a6a808c4..703a8a9c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs @@ -110,11 +110,12 @@ public static class ConfigConstants public const string ObservabilityApiOtelWriteScope = "Agent365.Observability.OtelWrite"; /// - /// Defender API scope for tool invocation inspection, enabling the Defender - /// security integration. Published on the resource as both a delegated scope (OBO) and an - /// application app role (S2S), so it is granted through both paths like OtelWrite. + /// Defender API app role and delegated scope enabling the Defender security integration. + /// Published on the resource as both a delegated scope (OBO) and an application app role + /// (S2S), so it is granted through both paths like OtelWrite. Must match the value published + /// on the resource SP — a mismatch fails the combined consent URL with AADSTS650053. /// - public const string DefenderApiToolInvocationScope = "AIAgentsRTP.ToolInvocation"; + public const string DefenderApiRealtimeProtectionScope = "RealtimeProtection.Process"; /// /// Delegated scope value exposed on the blueprint app registration to enable diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs index 38d4d71b..967f6475 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs @@ -299,14 +299,24 @@ public async Task DefenderApi_CarriesBothDelegatedScopeAndAppRole() // Assert var defender = SpecFor(specs, ConfigConstants.DefenderApiAppId); - defender.Scopes.Should().BeEquivalentTo(new[] { ConfigConstants.DefenderApiToolInvocationScope }, - because: "the Defender API delegated scope grants ToolInvocation for the OBO path"); - defender.AppRoleScopes.Should().BeEquivalentTo(new[] { ConfigConstants.DefenderApiToolInvocationScope }, - because: "the Defender API app role grants ToolInvocation for the s2s path — the Defender webhook rejects tokens without the roles claim, so losing either side breaks one auth mode"); + defender.Scopes.Should().BeEquivalentTo(new[] { ConfigConstants.DefenderApiRealtimeProtectionScope }, + because: "the Defender API delegated scope grants RealtimeProtection.Process for the OBO path"); + defender.AppRoleScopes.Should().BeEquivalentTo(new[] { ConfigConstants.DefenderApiRealtimeProtectionScope }, + because: "the Defender API app role grants RealtimeProtection.Process for the s2s path — the Defender webhook rejects tokens without the roles claim, so losing either side breaks one auth mode"); defender.SetInheritable.Should().BeTrue( because: "agent identities minted from the blueprint must inherit the Defender permission, exactly as they do for OtelWrite"); } + [Fact] + public void DefenderApi_ScopeValue_MatchesValuePublishedOnResource() + { + // The combined /v2.0/adminconsent URL validates every scope against the resource SP and + // rejects the ENTIRE url with AADSTS650053 on any unknown value — so a drift here breaks + // consent for Graph, MCP, Bot, Observability and Power Platform too, not just Defender. + ConfigConstants.DefenderApiRealtimeProtectionScope.Should().Be("RealtimeProtection.Process", + because: "this is the app role and delegated scope value published on the Defender resource SP; changing it without a matching resource-side change fails admin consent tenant-wide"); + } + [Fact] public async Task MessagingBotApi_UsesScopeConstantSoSpecAndConsentUrlAgree() { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs index c5b14b36..7bbd0a75 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs @@ -69,7 +69,7 @@ public void LogNonDwAdminConsentInstructions_OptionA_ShowsDefenderDelegatedPermi SetupHelpers.LogNonDwAdminConsentInstructions(logger, BlueprintId); var defenderLines = logger.Messages - .Where(m => m.Contains("Defender API") && m.Contains(ConfigConstants.DefenderApiToolInvocationScope)) + .Where(m => m.Contains("Defender API") && m.Contains(ConfigConstants.DefenderApiRealtimeProtectionScope)) .ToList(); defenderLines.Should().HaveCount(1, because: "the Defender API delegated scope must appear exactly once so the admin grants it alongside the other platform APIs"); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs index 13adc210..023c71d7 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs @@ -79,7 +79,7 @@ public void BuildAdminConsentUrls_DefenderApi_UsesHttpsIdentifierUriNotApiScheme var urls = SetupHelpers.BuildAdminConsentUrls(TenantId, BlueprintClientId, new[] { "Mail.Send" }, new[] { "scope" }); var defenderUrl = urls.First(u => u.ResourceName == "Defender API").ConsentUrl; - defenderUrl.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiToolInvocationScope}"), + defenderUrl.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiRealtimeProtectionScope}"), because: "the Defender resource publishes only the https identifier URI — api://{appId} is not in its servicePrincipalNames and consent fails with AADSTS500011"); defenderUrl.Should().NotContain(Uri.EscapeDataString($"api://{ConfigConstants.DefenderApiAppId}"), because: "the api:// form of the Defender resource is not a registered servicePrincipalName"); @@ -235,8 +235,8 @@ public void BuildCombinedConsentUrl_AlwaysIncludesAllFixedResources() because: "scope URIs are Uri.EscapeDataString-encoded in the query string — required by AAD for adminconsent"); url.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiOtelWriteScope}"), because: "OtelWrite is the published delegated scope on the Observability API used for admin consent"); - url.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiToolInvocationScope}"), - because: "ToolInvocation is the published delegated scope on the Defender API — without it the agent cannot call the Defender security webhook"); + url.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiRealtimeProtectionScope}"), + because: "RealtimeProtection.Process is the published delegated scope on the Defender API — without it the agent cannot call the Defender security webhook"); url.Should().Contain(Uri.EscapeDataString($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}")); } From 42cf82e054a7bccb06fb960b396437e86179f35b Mon Sep 17 00:00:00 2001 From: slreznit Date: Tue, 11 Aug 2026 17:12:58 +0300 Subject: [PATCH 3/7] Trim verbose doc comments on Defender constants Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Constants/ConfigConstants.cs | 12 ++++-------- .../SetupSubcommands/PermissionSpecsTests.cs | 5 ++--- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs index 703a8a9c..6ea1deab 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs @@ -81,14 +81,12 @@ public static class ConfigConstants public const string ObservabilityApiIdentifierUri = "api://9b975845-388f-4429-889e-eab1ef63949c"; /// - /// Defender API App ID. Hosts the security inspection endpoint used by the Defender integration. + /// Defender API App ID /// public const string DefenderApiAppId = "86a21212-634e-4553-b3d6-e477e4c9d9ec"; /// - /// Defender API identifier URI. Unlike the Observability API this resource - /// publishes an https identifier URI only — api://{appId} is not in its - /// servicePrincipalNames and consent fails with AADSTS500011. + /// Defender API identifier URI. /// public const string DefenderApiIdentifierUri = "https://rtp-a365.ai.defender.microsoft.com"; @@ -110,10 +108,8 @@ public static class ConfigConstants public const string ObservabilityApiOtelWriteScope = "Agent365.Observability.OtelWrite"; /// - /// Defender API app role and delegated scope enabling the Defender security integration. - /// Published on the resource as both a delegated scope (OBO) and an application app role - /// (S2S), so it is granted through both paths like OtelWrite. Must match the value published - /// on the resource SP — a mismatch fails the combined consent URL with AADSTS650053. + /// Defender API app role and delegated scope for the Defender security integration. + /// Must match the value published on the resource SP. /// public const string DefenderApiRealtimeProtectionScope = "RealtimeProtection.Process"; diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs index 967f6475..9f5727fc 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs @@ -310,9 +310,8 @@ public async Task DefenderApi_CarriesBothDelegatedScopeAndAppRole() [Fact] public void DefenderApi_ScopeValue_MatchesValuePublishedOnResource() { - // The combined /v2.0/adminconsent URL validates every scope against the resource SP and - // rejects the ENTIRE url with AADSTS650053 on any unknown value — so a drift here breaks - // consent for Graph, MCP, Bot, Observability and Power Platform too, not just Defender. + // A value the resource SP does not publish fails the combined consent URL for every + // resource in it (AADSTS650053), not just Defender. ConfigConstants.DefenderApiRealtimeProtectionScope.Should().Be("RealtimeProtection.Process", because: "this is the app role and delegated scope value published on the Defender resource SP; changing it without a matching resource-side change fails admin consent tenant-wide"); } From e4aa96976145ddebd93e767f5f93e9cb15303df6 Mon Sep 17 00:00:00 2001 From: slreznit Date: Mon, 5 Oct 2026 23:11:49 +0300 Subject: [PATCH 4/7] Update Defender permission and audience Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 ++-- .../Constants/ConfigConstants.cs | 4 ++-- .../Commands/SetupSubcommands/PermissionSpecsTests.cs | 6 +++--- .../Helpers/SetupHelpersConsentUrlTests.cs | 8 +++----- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eaf59aa..e7f497d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,10 +30,10 @@ Blueprint agents that export telemetry through the app-only S2S endpoint don't n #### Existing agents: grant Defender API permissions -Agents provisioned before this release need `RealtimeProtection.Process` granted as both a **delegated** and an **application** permission on the blueprint app for the Defender security integration. Requires Global Administrator. Follow the steps above, searching for `86a21212-634e-4553-b3d6-e477e4c9d9ec` in step 2 and selecting `RealtimeProtection.Process` in steps 3 and 4. Re-running `a365 setup all` grants it automatically. +Agents provisioned before this release need `RealtimeProtection.Evaluate.All` granted as both a **delegated** and an **application** permission on the blueprint app for the Defender security integration. Requires Global Administrator. Follow the steps above, searching for `86a21212-634e-4553-b3d6-e477e4c9d9ec` in step 2 and selecting `RealtimeProtection.Evaluate.All` in steps 3 and 4. Re-running `a365 setup all` grants it automatically. ### Added -- `RealtimeProtection.Process` on the Defender API is now granted automatically during `a365 setup` as both a delegated and an application permission, enabling the Microsoft Defender security integration without manual Entra steps. +- `RealtimeProtection.Evaluate.All` on the Defender API is now granted automatically during `a365 setup` as both a delegated and an application permission, enabling the Microsoft Defender security integration without manual Entra steps. - `a365 develop-mcp grant-agents-access --agent-blueprint-id --mcp-server-name ` reports which agent instances of a blueprint are missing the permission to call a BYO MCP server, and prompts you to select which ones to grant it to (#500). - When more than one Entra application shares the MCP server's name, `a365 develop-mcp grant-agents-access` now lists them all and asks which one to use instead of failing (#500). - `a365 develop-mcp grant-agents-access --help` now lists Microsoft's first-party agent blueprint names and IDs, and the same list is printed when `--agent-blueprint-id` is missing or not a GUID, so you can find the ID without looking it up elsewhere (#500). diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs index e1064de7..1a7618e8 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs @@ -116,7 +116,7 @@ public static class ConfigConstants /// /// Defender API identifier URI. /// - public const string DefenderApiIdentifierUri = "https://rtp-a365.ai.defender.microsoft.com"; + public const string DefenderApiIdentifierUri = "api://86a21212-634e-4553-b3d6-e477e4c9d9ec"; /// /// Single source of truth for the Messaging Bot API delegated scope. @@ -139,7 +139,7 @@ public static class ConfigConstants /// Defender API app role and delegated scope for the Defender security integration. /// Must match the value published on the resource SP. /// - public const string DefenderApiRealtimeProtectionScope = "RealtimeProtection.Process"; + public const string DefenderApiRealtimeProtectionScope = "RealtimeProtection.Evaluate.All"; /// /// Delegated scope value exposed on the blueprint app registration to enable diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs index 48d4dbba..8ca94b16 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/SetupSubcommands/PermissionSpecsTests.cs @@ -320,9 +320,9 @@ public async Task DefenderApi_CarriesBothDelegatedScopeAndAppRole() // Assert var defender = SpecFor(specs, ConfigConstants.DefenderApiAppId); defender.Scopes.Should().BeEquivalentTo(new[] { ConfigConstants.DefenderApiRealtimeProtectionScope }, - because: "the Defender API delegated scope grants RealtimeProtection.Process for the OBO path"); + because: "the Defender API delegated scope grants RealtimeProtection.Evaluate.All for the OBO path"); defender.AppRoleScopes.Should().BeEquivalentTo(new[] { ConfigConstants.DefenderApiRealtimeProtectionScope }, - because: "the Defender API app role grants RealtimeProtection.Process for the s2s path — the Defender webhook rejects tokens without the roles claim, so losing either side breaks one auth mode"); + because: "the Defender API app role grants RealtimeProtection.Evaluate.All for the s2s path - the Defender webhook rejects tokens without the roles claim, so losing either side breaks one auth mode"); defender.SetInheritable.Should().BeTrue( because: "agent identities minted from the blueprint must inherit the Defender permission, exactly as they do for OtelWrite"); } @@ -332,7 +332,7 @@ public void DefenderApi_ScopeValue_MatchesValuePublishedOnResource() { // A value the resource SP does not publish fails the combined consent URL for every // resource in it (AADSTS650053), not just Defender. - ConfigConstants.DefenderApiRealtimeProtectionScope.Should().Be("RealtimeProtection.Process", + ConfigConstants.DefenderApiRealtimeProtectionScope.Should().Be("RealtimeProtection.Evaluate.All", because: "this is the app role and delegated scope value published on the Defender resource SP; changing it without a matching resource-side change fails admin consent tenant-wide"); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs index 78227561..7afca5c1 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs @@ -74,15 +74,13 @@ public void BuildAdminConsentUrls_ObservabilityApi_UsesCorrectScopeConstant() } [Fact] - public void BuildAdminConsentUrls_DefenderApi_UsesHttpsIdentifierUriNotApiScheme() + public void BuildAdminConsentUrls_DefenderApi_UsesAppIdIdentifierUri() { var urls = SetupHelpers.BuildAdminConsentUrls(TenantId, BlueprintClientId, new[] { "Mail.Send" }, new[] { "scope" }); var defenderUrl = urls.First(u => u.ResourceName == "Defender API").ConsentUrl; defenderUrl.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiRealtimeProtectionScope}"), - because: "the Defender resource publishes only the https identifier URI — api://{appId} is not in its servicePrincipalNames and consent fails with AADSTS500011"); - defenderUrl.Should().NotContain(Uri.EscapeDataString($"api://{ConfigConstants.DefenderApiAppId}"), - because: "the api:// form of the Defender resource is not a registered servicePrincipalName"); + because: "the Defender resource publishes its delegated permission under the api://{appId} audience"); } [Fact] @@ -240,7 +238,7 @@ public void BuildCombinedConsentUrl_AlwaysIncludesAllFixedResources() url.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.ObservabilityApiIdentifierUri}/{ConfigConstants.ObservabilityApiOtelWriteScope}"), because: "OtelWrite is the published delegated scope on the Observability API used for admin consent"); url.Should().Contain(Uri.EscapeDataString($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiRealtimeProtectionScope}"), - because: "RealtimeProtection.Process is the published delegated scope on the Defender API — without it the agent cannot call the Defender security webhook"); + because: "RealtimeProtection.Evaluate.All is the published delegated scope on the Defender API - without it the agent cannot call the Defender security webhook"); url.Should().Contain(Uri.EscapeDataString($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}")); } From 50cd7bd97a4adc566a87840ae4c72f1913d900bc Mon Sep 17 00:00:00 2001 From: slreznit Date: Tue, 6 Oct 2026 00:13:30 +0300 Subject: [PATCH 5/7] Align Defender permissions with auth mode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + .../SetupSubcommands/AllSubcommand.cs | 9 +- .../BatchPermissionsOrchestrator.cs | 22 ++-- .../NonDwBlueprintSetupOrchestrator.cs | 26 ++-- .../ResourcePermissionSpec.cs | 7 ++ .../Commands/SetupSubcommands/SetupHelpers.cs | 111 ++++++++++++------ .../Commands/SetupSubcommands/SetupResults.cs | 2 +- .../Commands/AllSubcommandTests.cs | 88 +++++++++++++- .../BatchPermissionsOrchestratorTests.cs | 15 ++- .../Commands/SetupCommandTests.cs | 12 +- ...tupHelpersAdminConsentInstructionsTests.cs | 20 ++++ .../Helpers/SetupHelpersConsentUrlTests.cs | 26 ++++ 12 files changed, 267 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7f497d4..79d1d735 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,6 +139,7 @@ Agents provisioned before this release need `RealtimeProtection.Evaluate.All` gr ### Changed +- Defender permissions now follow the blueprint agent authentication mode: delegated for `obo`, application for `s2s`, and both for `both`; AI Teammate setup continues to request both permission types (#485). - `a365 setup all` no longer requests Observability API permissions for blueprint agents; registered agents that export telemetry through the app-only S2S endpoint need no admin consent (#501). - Hardened token storage: the CLI no longer writes access tokens to a plaintext file — they live only in the OS-protected MSAL cache (DPAPI/Keychain/owner-only file). Any legacy plaintext cache is removed automatically; sign-in prompts are unchanged. - `develop-mcp register-external-mcp-server` now sets `exit code 1` on failure paths (validation errors, tenant detection failure, Graph unavailable, Entra app creation failure, MCP-Platform AddMcpServer failure). Previously these paths logged an error and exited `0`, which made the command's success/failure status undetectable from scripts and CI. Successful dry-run and user-initiated cancellation at the y/N prompt continue to exit `0`. 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 49ace519..65987112 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs @@ -1020,7 +1020,14 @@ await PermissionsSubcommand.RemoveStaleCustomPermissionsAsync( // names so V2 audiences read as e.g. "mcp_MailTools" rather than "Agent 365 Tools". var specs = await SetupHelpers.BuildConfiguredPermissionSpecsAsync( ctx.Config, setInheritable: true, isM365: ctx.IsM365, scopesByAudience, serverNamesByAudience, - includeObservability: !ctx.SkipObservabilityPermissions); + includeObservability: !ctx.SkipObservabilityPermissions, + defenderPermissionMode: ctx.Results.IsNonDwBlueprintFlow + ? ctx.IsS2sMode + ? DefenderPermissionMode.Application + : ctx.IsBothMode + ? DefenderPermissionMode.Both + : DefenderPermissionMode.Delegated + : DefenderPermissionMode.Both); // Return the full scopesByAudience map alongside the V1-compat mcpScopes so V2 // callers (ApplyConsentUrlsIfNeeded) can route per-server audiences to the bare diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs index c4a23c67..4b417648 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs @@ -84,9 +84,11 @@ internal static class BatchPermissionsOrchestrator 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(); + // Drop specs that carry neither delegated scopes nor application roles. Application-only + // specs must remain so S2S mode can assign app roles without creating an OAuth2 grant. + var effectiveSpecs = specs + .Where(s => s.Scopes.Length > 0 || s.AppRoleScopes is { Length: > 0 }) + .ToList(); if (setupResults is not null) { setupResults.ObservabilityResourceAppId = effectiveSpecs @@ -97,12 +99,12 @@ internal static class BatchPermissionsOrchestrator 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); + logger.LogDebug("Skipping {Count} resource spec(s) with no delegated scopes or application roles.", skipped); } if (effectiveSpecs.Count == 0) { - logger.LogInformation("All permission specs have empty scope lists — skipping batch permissions configuration."); + logger.LogInformation("All permission specs have empty delegated scope and application role lists — skipping batch permissions configuration."); return (true, true, true, null); } @@ -415,12 +417,16 @@ private static async Task UpdateBlueprintPermissions continue; } + var permissionValues = spec.Scopes + .Concat(spec.AppRoleScopes ?? Array.Empty()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); logger.LogDebug( - " - Configuring inheritable permissions: {ResourceName} [{Scopes}]", - spec.ResourceName, string.Join(' ', spec.Scopes)); + " - Configuring inheritable permissions: {ResourceName} [{Permissions}]", + spec.ResourceName, string.Join(' ', permissionValues)); var (ok, alreadyExists, err) = await blueprintService.SetInheritablePermissionsAsync( - tenantId, blueprintAppId, spec.ResourceAppId, spec.Scopes, + tenantId, blueprintAppId, spec.ResourceAppId, permissionValues, requiredScopes: permScopes, ct); if (alreadyExists || ok) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs index 0b8809f9..e8d54ea3 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs @@ -36,6 +36,16 @@ internal static class NonDwBlueprintSetupOrchestrator public static void PrintDryRunPlan(Agent365Config config, ILogger logger, bool isBootstrap = false, string[]? rawArgs = null, bool skipRequirements = false, bool isM365 = false, bool agentRegistrationOnly = false, string? authMode = null, string? messagingEndpointOverride = null, bool skipObservabilityPermissions = false) { var sub = new string(' ', SetupHelpers.DryRunValCol); + var selectedAuthMode = authMode ?? config.AuthMode; + var effectiveMode = string.IsNullOrWhiteSpace(selectedAuthMode) + ? "obo" + : selectedAuthMode.Trim().ToLowerInvariant(); + var defenderPermissionMode = effectiveMode switch + { + "s2s" => DefenderPermissionMode.Application, + "both" => DefenderPermissionMode.Both, + _ => DefenderPermissionMode.Delegated, + }; var observabilityPermissionsEffectivelySkipped = skipObservabilityPermissions && !SetupHelpers.CustomPermissionsRequestObservability(config); // Dry-run S2S work comes only from fixed specs today; MCP and custom specs carry delegated scopes. @@ -43,7 +53,8 @@ public static void PrintDryRunPlan(Agent365Config config, ILogger logger, bool i setInheritable: true, isM365, config.Environment, - includeObservability: !skipObservabilityPermissions) + includeObservability: !skipObservabilityPermissions, + defenderPermissionMode) .Any(s => s.AppRoleScopes is { Length: > 0 }); // --messaging-endpoint flag (if supplied) wins over the init-only config value for the plan. var plannedEndpoint = !string.IsNullOrWhiteSpace(messagingEndpointOverride) @@ -128,10 +139,6 @@ public static void PrintDryRunPlan(Agent365Config config, ILogger logger, bool i // 3. Inheritable Permissions — the non-DW spec set is stamped on the blueprint so MAC and // dependent systems can see it. The same set is applied to the agent identity SP in step 5. - var selectedAuthMode = authMode ?? config.AuthMode; - var effectiveMode = string.IsNullOrWhiteSpace(selectedAuthMode) - ? "obo" - : selectedAuthMode.Trim().ToLowerInvariant(); logger.LogInformation(SetupHelpers.DryRunRow(3, "Inheritable Permissions") + "configure for {Resources} (Global Administrator required; consent URL printed if absent)", skipObservabilityPermissions ? "Defender API, Power Platform API, and custom permissions" @@ -283,6 +290,11 @@ await ctx.ClientAppValidator.GrantConsentForPermissionsAsync( public static async Task ExecuteAsync(SetupContext ctx) { ctx.Results.IsNonDwBlueprintFlow = true; + ctx.Results.EffectiveAuthMode = ctx.IsBothMode + ? Models.AuthMode.Both + : ctx.IsS2sMode + ? Models.AuthMode.S2s + : Models.AuthMode.Obo; ctx.Results.ObservabilityPermissionsSkipped = ctx.ObservabilityPermissionsEffectivelySkipped; ctx.Results.TenantId = ctx.Config.TenantId; // Bootstrap already printed the "Running..." banner before auth steps; skip here to avoid duplication. @@ -723,8 +735,8 @@ internal static async Task GrantAgentIdentityS2SPermissionsAsync( List specs) { var hasS2sSpecs = specs.Any(s => s.AppRoleScopes is { Length: > 0 }); - // Blueprint agents no longer request OtelWrite, the only app role setup requested, so this - // step usually has nothing to grant. Record that so the summary does not report a delegated grant. + // Record whether the selected auth mode produced any application permissions so the + // summary can distinguish "no S2S work" from a failed grant. ctx.Results.NoS2SAppRolesToGrant = !hasS2sSpecs; if (hasS2sSpecs && AgentIdentityInheritsBlueprintAppRoles(ctx.Results)) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/ResourcePermissionSpec.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/ResourcePermissionSpec.cs index 5ca5d19e..1c363c4c 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/ResourcePermissionSpec.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/ResourcePermissionSpec.cs @@ -3,6 +3,13 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; +internal enum DefenderPermissionMode +{ + Delegated, + Application, + Both, +} + /// /// Describes a single resource whose permissions should be configured on the agent blueprint. /// Used as input to . 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 33eef967..16076585 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs @@ -59,7 +59,8 @@ internal static ResourcePermissionSpec[] GetFixedApiPermissionSpecs( bool setInheritable, bool isM365, string? environment = null, - bool includeObservability = true) + bool includeObservability = true, + DefenderPermissionMode defenderPermissionMode = DefenderPermissionMode.Both) { var specs = new List(); if (isM365) @@ -87,12 +88,18 @@ internal static ResourcePermissionSpec[] GetFixedApiPermissionSpecs( setInheritable, AppRoleScopes: new[] { ConfigConstants.ObservabilityApiOtelWriteScope })); } + var defenderDelegatedScopes = defenderPermissionMode is DefenderPermissionMode.Delegated or DefenderPermissionMode.Both + ? new[] { ConfigConstants.DefenderApiRealtimeProtectionScope } + : Array.Empty(); + var defenderAppRoleScopes = defenderPermissionMode is DefenderPermissionMode.Application or DefenderPermissionMode.Both + ? new[] { ConfigConstants.DefenderApiRealtimeProtectionScope } + : null; specs.Add(new ResourcePermissionSpec( ConfigConstants.DefenderApiAppId, "Defender API", - new[] { ConfigConstants.DefenderApiRealtimeProtectionScope }, + defenderDelegatedScopes, setInheritable, - AppRoleScopes: new[] { ConfigConstants.DefenderApiRealtimeProtectionScope })); + AppRoleScopes: defenderAppRoleScopes)); specs.Add(new ResourcePermissionSpec( PowerPlatformConstants.PowerPlatformApiResourceAppId, "Power Platform API", @@ -147,7 +154,8 @@ internal static async Task> BuildConfiguredPermissi bool isM365 = true, Dictionary? scopesByAudience = null, Dictionary>? serverNamesByAudience = null, - bool includeObservability = true) + bool includeObservability = true, + DefenderPermissionMode defenderPermissionMode = DefenderPermissionMode.Both) { // Manifest read at most once, and only when scopesByAudience is not pre-supplied. // Callers that already have the manifest loaded (e.g. AllSubcommand.BuildPermissionSpecsAsync) @@ -186,7 +194,8 @@ internal static async Task> BuildConfiguredPermissi : "Agent 365 Tools", kvp.Value, SetInheritable: setInheritable))); - specs.AddRange(GetFixedApiPermissionSpecs(setInheritable, isM365, config.Environment, includeObservability)); + specs.AddRange(GetFixedApiPermissionSpecs( + setInheritable, isM365, config.Environment, includeObservability, defenderPermissionMode)); foreach (var customPerm in config.CustomBlueprintPermissions ?? new List()) { @@ -475,8 +484,8 @@ internal static async Task ResolveBootstrapEnvironmentAsync( /// /// Fixed permission specs for the non-DW admin consent flow. - /// Observability API and Defender API require both Application (app role for S2S) - /// and Delegated (oauth2 grant for OBO). Power Platform API requires Delegated only. + /// Observability API includes both permission types when requested. Defender follows the + /// selected auth mode. Power Platform API requires Delegated only. /// Extend this list or pass an override to /// when additional APIs are required (e.g. dynamic MCP scopes, custom permissions). /// @@ -484,20 +493,28 @@ internal static async Task ResolveBootstrapEnvironmentAsync( GetNonDwAdminConsentSpecs("prod"); internal static IReadOnlyList<(string ResourceName, string ResourceAppId, string Scope, string PermissionType)> GetNonDwAdminConsentSpecs( - string? environment) - => BuildNonDwAdminConsentSpecs(ConfigConstants.GetObservabilityApiAppId(environment)); + string? environment, + DefenderPermissionMode defenderPermissionMode = DefenderPermissionMode.Both) + => BuildNonDwAdminConsentSpecs(ConfigConstants.GetObservabilityApiAppId(environment), defenderPermissionMode); private static IReadOnlyList<(string ResourceName, string ResourceAppId, string Scope, string PermissionType)> BuildNonDwAdminConsentSpecs( - string observabilityAppId) + string observabilityAppId, + DefenderPermissionMode defenderPermissionMode = DefenderPermissionMode.Both) { - return - [ + var specs = new List<(string ResourceName, string ResourceAppId, string Scope, string PermissionType)> + { ("Observability API", observabilityAppId, ConfigConstants.ObservabilityApiOtelWriteScope, "Application"), ("Observability API", observabilityAppId, ConfigConstants.ObservabilityApiOtelWriteScope, "Delegated"), - ("Defender API", ConfigConstants.DefenderApiAppId, ConfigConstants.DefenderApiRealtimeProtectionScope, "Application"), - ("Defender API", ConfigConstants.DefenderApiAppId, ConfigConstants.DefenderApiRealtimeProtectionScope, "Delegated"), ("Power Platform API", PowerPlatformConstants.PowerPlatformApiResourceAppId, PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead, "Delegated"), - ]; + }; + + if (defenderPermissionMode is DefenderPermissionMode.Application or DefenderPermissionMode.Both) + specs.Insert(2, ("Defender API", ConfigConstants.DefenderApiAppId, ConfigConstants.DefenderApiRealtimeProtectionScope, "Application")); + if (defenderPermissionMode is DefenderPermissionMode.Delegated or DefenderPermissionMode.Both) + specs.Insert(defenderPermissionMode == DefenderPermissionMode.Both ? 3 : 2, + ("Defender API", ConfigConstants.DefenderApiAppId, ConfigConstants.DefenderApiRealtimeProtectionScope, "Delegated")); + + return specs; } /// @@ -675,9 +692,8 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger, str // (e.g. admin already granted tenant consent but the per-principal call still failed). var pendingDelegatedAction = agentIdDelegatedFailed && !pendingAdminAction; var pendingS2SAction = permissionGrantsPending && isS2SFlow; - // Blueprint agents no longer request OtelWrite, the only app role setup requested, so an - // s2s/both run usually has no S2S grant at all. Say so explicitly: otherwise the row falls - // through to the delegated wording, which for s2s-only shows a PENDING with no action item. + // Some configurations can have no application-role specs. Say so explicitly; otherwise + // an s2s-only row can fall through to delegated wording with no matching action item. var noS2SAppRolesToGrant = isNonDw && (isS2sOnlyMode || isBothMode) && results.NoS2SAppRolesToGrant && !isS2SFlow; if (results.PermissionGrantsSkipped && isNonDw) @@ -967,7 +983,13 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger, str if (isNonDw && string.IsNullOrWhiteSpace(consentUrl)) { logger.LogInformation(" {N}. Permission Grants — must be granted by {Roles} in the Entra portal:", actionCount, AuthenticationConstants.DelegatedGrantRequiredRoles); - var consentSpecs = BuildNonDwAdminConsentSpecs(observabilityResourceAppId); + var defenderPermissionMode = results.EffectiveAuthMode switch + { + Models.AuthMode.S2s => DefenderPermissionMode.Application, + Models.AuthMode.Both => DefenderPermissionMode.Both, + _ => DefenderPermissionMode.Delegated, + }; + var consentSpecs = BuildNonDwAdminConsentSpecs(observabilityResourceAppId, defenderPermissionMode); if (results.ObservabilityPermissionsSkipped) consentSpecs = consentSpecs.Where(s => !ConfigConstants.IsObservabilityApiAppId(s.ResourceAppId)).ToList(); LogNonDwAdminConsentInstructions( @@ -1259,10 +1281,11 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger, str /// 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. /// - /// Graph, Agent 365 Tools (MCP), and Power Platform API URLs are always generated; the Observability - /// API URL is generated unless is false. Messaging Bot API is included only - /// when is true — non-M365 tenants typically lack the Messaging Bot - /// resource SP and the consent endpoint returns AADSTS650053 otherwise. + /// Graph, Agent 365 Tools (MCP), and Power Platform API URLs are always generated. Defender is + /// generated when delegated Defender consent is enabled, and Observability is generated unless + /// is false. Messaging Bot API is included only when + /// is true — non-M365 tenants typically lack the Messaging Bot resource + /// SP and the consent endpoint returns AADSTS650053 otherwise. /// /// /// Display names of the resources for which URLs were saved. @@ -1273,7 +1296,8 @@ internal static List PopulateAdminConsentUrls( bool isM365 = true, IReadOnlyDictionary? mcpScopesByAudience = null, IReadOnlyDictionary>? mcpAudienceDisplayNames = null, - bool includeObservability = true) + bool includeObservability = true, + bool includeDefenderDelegated = true) { var graphBaseUrl = ConfigConstants.GetGraphBaseUrl(config.Environment, config.GraphBaseUrl); var graphResourceUri = graphBaseUrl; @@ -1283,7 +1307,7 @@ internal static List PopulateAdminConsentUrls( var urls = BuildAdminConsentUrls( config.TenantId, config.AgentBlueprintId!, config.AgentApplicationScopes, mcpScopes, isM365, mcpScopesByAudience, mcpAudienceDisplayNames, graphResourceUri, authorityHost, - mcpResourceAppId, observabilityResourceAppId, includeObservability); + mcpResourceAppId, observabilityResourceAppId, includeObservability, includeDefenderDelegated); // Clear an Observability consent URL saved by an earlier run so the admin is not asked for permissions this run skipped. if (!includeObservability) @@ -1469,7 +1493,8 @@ internal static string BuildFullyQualifiedScope( /// (mirrors ): Microsoft Graph (when /// non-empty), Agent 365 Tools (when /// non-empty), Messaging Bot API (when is true), Observability API - /// (unless is false), and Power Platform API. + /// (unless is false), Defender API (when delegated + /// Defender consent is enabled), and Power Platform API. /// /// Messaging Bot is gated on because non-M365 tenants typically /// lack the Messaging Bot resource SP, in which case the /v2.0/adminconsent endpoint returns @@ -1489,7 +1514,8 @@ internal static string BuildFullyQualifiedScope( string? authorityHost = null, string? sharedMcpResourceAppId = null, string? observabilityResourceAppId = null, - bool includeObservability = true) + bool includeObservability = true, + bool includeDefenderDelegated = true) { var urls = new List<(string, string)>(); @@ -1558,7 +1584,8 @@ string Build(string tenant, string client, string resourceUri, IEnumerable /// Builds a single combined /v2.0/adminconsent URL covering every resource stamped on the /// blueprint: Graph, Agent 365 Tools (MCP), Observability API (unless - /// is false), Power Platform API, and - /// Messaging Bot API (only when is true). + /// is false), Defender API when delegated Defender + /// consent is enabled, Power Platform API, and Messaging Bot API (only when + /// is true). /// /// Messaging Bot is gated on because non-M365 tenants typically /// lack the Messaging Bot resource SP, which would cause the entire combined consent grant @@ -1587,7 +1615,8 @@ internal static string BuildCombinedConsentUrl( string? authorityHost = null, string? sharedMcpResourceAppId = null, string? observabilityResourceAppId = null, - bool includeObservability = true) + bool includeObservability = true, + bool includeDefenderDelegated = true) { var allScopes = new List(); foreach (var s in graphScopes) @@ -1623,7 +1652,8 @@ internal static string BuildCombinedConsentUrl( if (includeObservability) allScopes.Add( $"{ConfigConstants.BuildObservabilityApiIdentifierUri(observabilityResourceAppId ?? ConfigConstants.ObservabilityApiAppId)}/{ConfigConstants.ObservabilityApiOtelWriteScope}"); - allScopes.Add($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiRealtimeProtectionScope}"); + if (includeDefenderDelegated) + allScopes.Add($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiRealtimeProtectionScope}"); allScopes.Add($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}"); return BuildAdminConsentUrl(tenantId, blueprintClientId, allScopes, authorityHost); } @@ -1633,11 +1663,11 @@ internal static string BuildCombinedConsentUrl( /// when the running account is not a Global Administrator. Called by both DW and non-DW setup paths /// after the batch permissions step. /// - /// Messaging Bot API URLs are included only when is true, and - /// Observability API URLs only when the context requests Observability permissions; the other - /// resources (Graph, MCP, Power Platform) are always included so a tenant admin - /// can complete the hand-off with a single URL. When Observability permissions are skipped, a - /// consent URL saved for them by an earlier run is cleared on every run, admin runs included. + /// Messaging Bot API URLs are included only when is true. + /// Observability and delegated Defender URLs follow the selected setup permissions; Graph, + /// MCP, and Power Platform are always included so a tenant admin can complete the hand-off + /// with a single URL. When Observability permissions are skipped, a consent URL saved for them + /// by an earlier run is cleared on every run, admin runs included. /// Otherwise this is a no-op if admin consent was already granted or the blueprint ID is absent. /// /// @@ -1658,7 +1688,10 @@ internal static void ApplyConsentUrlsIfNeeded( return; var includeObservability = !ctx.SkipObservabilityPermissions; - var consentResourceNames = PopulateAdminConsentUrls(ctx.Config, mcpResourceAppId, mcpScopes, isM365, mcpScopesByAudience, mcpAudienceDisplayNames, includeObservability); + var includeDefenderDelegated = !ctx.Results.IsNonDwBlueprintFlow || !ctx.IsS2sMode; + var consentResourceNames = PopulateAdminConsentUrls( + ctx.Config, mcpResourceAppId, mcpScopes, isM365, mcpScopesByAudience, + mcpAudienceDisplayNames, includeObservability, includeDefenderDelegated); ctx.Results.ConsentUrlsSavedToPath = ctx.GeneratedConfigPath; ctx.Results.ConsentResourceNames.AddRange(consentResourceNames); var graphBaseUrl = ConfigConstants.GetGraphBaseUrl(ctx.Config.Environment, ctx.Config.GraphBaseUrl); @@ -1668,7 +1701,7 @@ internal static void ApplyConsentUrlsIfNeeded( ctx.Results.CombinedConsentUrl = BuildCombinedConsentUrl( ctx.Config.TenantId!, ctx.Config.AgentBlueprintId!, graphScopes, mcpScopes, isM365, mcpScopesByAudience, graphResourceUri, authorityHost, - mcpResourceAppId, observabilityResourceAppId, includeObservability); + mcpResourceAppId, observabilityResourceAppId, includeObservability, includeDefenderDelegated); } /// 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 088761c2..2e2375e0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs @@ -65,7 +65,7 @@ public class SetupResults /// /// Outcome of S2S app role assignments targeting the blueprint service principal. Written by /// in the DW path and in the non-DW path when the - /// blueprint carries app-role scopes (e.g. Observability API). + /// blueprint carries app-role scopes (for example Defender API in s2s or both mode). /// public GrantOutcome BlueprintS2SOutcome { get; set; } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AllSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AllSubcommandTests.cs index 432272d6..e644b615 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AllSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AllSubcommandTests.cs @@ -358,11 +358,16 @@ public async Task ExecuteMessagingEndpointStepAsync_WhenOverrideProvidedAndConfi // Observability API permission wiring // ----------------------------------------------------------------------- - private SetupContext BuildPermissionsContext(bool skipObservabilityPermissions, List? customPermissions = null) + private SetupContext BuildPermissionsContext( + bool skipObservabilityPermissions, + List? customPermissions = null, + string? authMode = null, + bool isNonDwBlueprintFlow = true) { var executor = Substitute.For(Substitute.For>()); var graph = Substitute.For(); var blueprintService = Substitute.For(Substitute.For>(), graph); + var results = new SetupResults { IsNonDwBlueprintFlow = isNonDwBlueprintFlow }; // The blueprint has no inheritable permissions yet, so stale-permission cleanup has nothing to remove. blueprintService.ListInheritablePermissionsAsync( Arg.Any(), Arg.Any(), Arg.Any?>(), Arg.Any()) @@ -378,7 +383,7 @@ private SetupContext BuildPermissionsContext(bool skipObservabilityPermissions, DeploymentProjectPath = _tempDir, CustomBlueprintPermissions = customPermissions, }, - results: new SetupResults(), + results: results, logger: NullLogger.Instance, configFile: new FileInfo(Path.Combine(_tempDir, "a365.config.json")), generatedConfigPath: Path.Combine(_tempDir, "a365.generated.config.json"), @@ -398,6 +403,7 @@ private SetupContext BuildPermissionsContext(bool skipObservabilityPermissions, federatedCredentialService: Substitute.ForPartsOf( Substitute.For>(), graph), clientAppValidator: Substitute.For(), + authMode: authMode, skipObservabilityPermissions: skipObservabilityPermissions); } @@ -412,12 +418,63 @@ public async Task BuildPermissionSpecsAsync_StampsObservabilityApiUnlessSkipped( specs.Any(s => s.ResourceAppId == ConfigConstants.ObservabilityApiAppId).Should().Be(!skipObservabilityPermissions, because: "the spec list drives inheritable permissions, app role grants, and admin consent, so skipping Observability permissions must remove Observability API from it"); - specs.Any(s => s.AppRoleScopes is { Length: > 0 }).Should().Be(!skipObservabilityPermissions, - because: "OtelWrite is the only app role setup requests, so skipping it must leave no app role grant that needs a Global Administrator"); + specs.Should().Contain(s => s.ResourceAppId == ConfigConstants.DefenderApiAppId, + because: "skipping Observability permissions must not remove the Defender permission required by the agent auth mode"); specs.Should().Contain(s => s.ResourceAppId == PowerPlatformConstants.PowerPlatformApiResourceAppId, because: "skipping Observability API must not drop the other required resources"); } + [Theory] + [InlineData(null, true, false)] + [InlineData("obo", true, false)] + [InlineData("s2s", false, true)] + [InlineData("both", true, true)] + public async Task BuildPermissionSpecsAsync_NonDw_DefenderPermissionsMatchAuthMode( + string? authMode, + bool expectDelegated, + bool expectApplication) + { + var ctx = BuildPermissionsContext( + skipObservabilityPermissions: true, + authMode: authMode, + isNonDwBlueprintFlow: true); + + var (specs, _, _, _, _) = await AllSubcommand.BuildPermissionSpecsAsync(ctx); + + var defender = specs.Single(s => s.ResourceAppId == ConfigConstants.DefenderApiAppId); + defender.Scopes.Should().BeEquivalentTo( + expectDelegated ? [ConfigConstants.DefenderApiRealtimeProtectionScope] : [], + because: $"the '{authMode ?? "obo"}' auth mode must request delegated Defender consent only when OBO is enabled"); + if (expectApplication) + { + defender.AppRoleScopes.Should().BeEquivalentTo( + [ConfigConstants.DefenderApiRealtimeProtectionScope], + because: $"the '{authMode ?? "obo"}' auth mode enables S2S Defender evaluation"); + } + else + { + defender.AppRoleScopes.Should().BeNull( + because: $"the '{authMode ?? "obo"}' auth mode must not request a Defender application role"); + } + } + + [Fact] + public async Task BuildPermissionSpecsAsync_Dw_PreservesBothDefenderPermissionTypes() + { + var ctx = BuildPermissionsContext( + skipObservabilityPermissions: false, + authMode: "obo", + isNonDwBlueprintFlow: false); + + var (specs, _, _, _, _) = await AllSubcommand.BuildPermissionSpecsAsync(ctx); + + var defender = specs.Single(s => s.ResourceAppId == ConfigConstants.DefenderApiAppId); + defender.Scopes.Should().BeEquivalentTo([ConfigConstants.DefenderApiRealtimeProtectionScope], + because: "DW setup does not use blueprint-agent auth modes and must preserve its delegated Defender permission"); + defender.AppRoleScopes.Should().BeEquivalentTo([ConfigConstants.DefenderApiRealtimeProtectionScope], + because: "DW setup must preserve the existing Defender application permission"); + } + [Fact] public void ApplyConsentUrlsIfNeeded_WhenObservabilitySkipped_HandsOffOnlyTheRemainingResources() { @@ -426,14 +483,33 @@ public void ApplyConsentUrlsIfNeeded_WhenObservabilitySkipped_HandsOffOnlyTheRem SetupHelpers.ApplyConsentUrlsIfNeeded( ctx, McpConstants.WorkIQToolsProdAppId, ctx.Config.AgentApplicationScopes, new[] { "McpServers.Mail.All" }, isM365: false); - ctx.Results.ConsentResourceNames.Should().BeEquivalentTo(new[] { "Microsoft Graph", "Agent 365 Tools", "Power Platform API" }, - because: "a non-admin run must hand every stamped resource to an administrator, and Observability API is no longer stamped"); + ctx.Results.ConsentResourceNames.Should().BeEquivalentTo(new[] { "Microsoft Graph", "Agent 365 Tools", "Defender API", "Power Platform API" }, + because: "default OBO setup must hand off every delegated resource, including Defender, while omitting Observability"); ctx.Config.ResourceConsents.Should().NotContain(rc => rc.ResourceAppId == ConfigConstants.ObservabilityApiAppId, because: "no Observability API consent URL may be persisted when its permissions were skipped"); ctx.Results.CombinedConsentUrl.Should().NotContain(ConfigConstants.ObservabilityApiAppId, because: "the single hand-off URL must not request Observability API scopes that setup skipped"); } + [Fact] + public void ApplyConsentUrlsIfNeeded_S2s_OmitsDelegatedDefenderConsent() + { + var ctx = BuildPermissionsContext( + skipObservabilityPermissions: true, + authMode: "s2s", + isNonDwBlueprintFlow: true); + + SetupHelpers.ApplyConsentUrlsIfNeeded( + ctx, McpConstants.WorkIQToolsProdAppId, ctx.Config.AgentApplicationScopes, + new[] { "McpServers.Mail.All" }, isM365: false); + + ctx.Results.ConsentResourceNames.Should().NotContain("Defender API", + because: "S2S-only mode requests the Defender application role, not its delegated scope"); + ctx.Results.CombinedConsentUrl.Should().NotContain( + Uri.EscapeDataString($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiRealtimeProtectionScope}"), + because: "the S2S hand-off URL must not request delegated Defender admin consent"); + } + [Fact] public void ApplyConsentUrlsIfNeeded_AdminRun_ClearsObservabilityConsentUrlSavedByAnEarlierRun() { 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 index fa985611..184ef64c 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BatchPermissionsOrchestratorTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BatchPermissionsOrchestratorTests.cs @@ -312,12 +312,12 @@ private void ArrangeS2SPhase1AndAdminCheck() .Returns(Task.FromResult((ok: false, alreadyExists: false, error: (string?)"Insufficient privileges"))); } - private static ResourcePermissionSpec[] S2SSpec() => + private static ResourcePermissionSpec[] S2SSpec(bool includeDelegatedScope = true) => [ new ResourcePermissionSpec( ConfigConstants.ObservabilityApiAppId, "Observability API", - new[] { ConfigConstants.ObservabilityApiOtelWriteScope }, + includeDelegatedScope ? new[] { ConfigConstants.ObservabilityApiOtelWriteScope } : [], SetInheritable: false, AppRoleScopes: new[] { ConfigConstants.ObservabilityApiOtelWriteScope }) ]; @@ -501,8 +501,11 @@ private void ArrangeAzRestS2SCalls(bool blueprintAlreadyAssigned, int postExitCo /// DisplaySetupSummary surfaces the S2S hand-off block in the Action Required section — /// just like it does for a GA whose Graph API call returns 403. /// - [Fact] - public async Task ConfigureAllPermissions_NonAdmin_WithS2SSpecs_SetsBlueprintS2SOutcomeFailed() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task ConfigureAllPermissions_NonAdmin_WithS2SSpecs_SetsBlueprintS2SOutcomeFailed( + bool includeDelegatedScope) { // Arrange _graph.GraphGetAsync( @@ -534,13 +537,13 @@ await BatchPermissionsOrchestrator.ConfigureAllPermissionsAsync( _graph, _blueprintService, new Agent365Config { TenantId = S2STenantId, AgentBlueprintId = S2SBlueprintAppId }, blueprintAppId: S2SBlueprintAppId, tenantId: S2STenantId, - specs: S2SSpec(), _logger, setupResults, ct: default); + specs: S2SSpec(includeDelegatedScope), _logger, setupResults, ct: default); // Assert setupResults.BlueprintS2SOutcome.Should().Be(GrantOutcome.Failed, because: "a non-admin user cannot complete S2S app role assignment directly — the outcome must be marked Failed so DisplaySetupSummary surfaces the hand-off block"); setupResults.PendingBlueprintAppRoleSpecs.Should().ContainSingle(s => s.ResourceAppId == ConfigConstants.ObservabilityApiAppId, - because: "a non-admin run leaves every requested app role for the summary's hand-off"); + because: "a non-admin run leaves every requested app role for the summary's hand-off, including application-only specs"); } // ────────────────────────────────────────────────────────────────────────────────────── 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 2af4e503..2844adb3 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 @@ -731,14 +731,16 @@ public async Task SetupAll_BlueprintAgent_DefaultPlan_OmitsObservabilityApi() } /// - /// The S2S endpoint authorizes registered agents without OtelWrite whatever the auth mode (validated live), so - /// s2s and both — from the flag or from a365.config.json — must not request Observability API permissions either. + /// The S2S endpoint authorizes registered agents without OtelWrite, while Defender still requires + /// its application role for s2s and both modes. /// [Theory] [InlineData("--authmode s2s", null)] [InlineData("--authmode both", null)] [InlineData("", "both")] - public async Task SetupAll_BlueprintAgent_AppRoleAuthModes_OmitObservabilityApi(string args, string? configAuthMode) + public async Task SetupAll_BlueprintAgent_AppRoleAuthModes_OmitObservabilityAndKeepDefenderAppRole( + string args, + string? configAuthMode) { var config = new Agent365Config { @@ -771,7 +773,9 @@ public async Task SetupAll_BlueprintAgent_AppRoleAuthModes_OmitObservabilityApi( _mockLogger.Received().Log( LogLevel.Information, Arg.Any(), - Arg.Is(o => o.ToString()!.Contains("Blueprint Permission Grants") && o.ToString()!.Contains("no S2S app roles to grant")), + Arg.Is(o => o.ToString()!.Contains("Blueprint Permission Grants") && + o.ToString()!.Contains("S2S app roles") && + !o.ToString()!.Contains("no S2S app roles to grant")), Arg.Any(), Arg.Any>()); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs index 4835986f..57b2f85b 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs @@ -172,4 +172,24 @@ public void NonDwAdminConsentSpecs_PowerPlatformApi_IsDelegatedOnly() specs.Should().Contain(s => s.ResourceName == "Power Platform API" && s.PermissionType == "Delegated", because: "Power Platform API ConnectivityConnections.Read is a delegated scope"); } + + [Theory] + [InlineData("Delegated", true, false)] + [InlineData("Application", false, true)] + [InlineData("Both", true, true)] + public void GetNonDwAdminConsentSpecs_DefenderMatchesAuthMode( + string modeName, + bool expectDelegated, + bool expectApplication) + { + var mode = Enum.Parse(modeName); + var specs = SetupHelpers.GetNonDwAdminConsentSpecs("prod", mode); + + specs.Any(s => s.ResourceName == "Defender API" && s.PermissionType == "Delegated") + .Should().Be(expectDelegated, + because: $"{mode} mode must include the delegated Defender permission only when OBO is enabled"); + specs.Any(s => s.ResourceName == "Defender API" && s.PermissionType == "Application") + .Should().Be(expectApplication, + because: $"{mode} mode must include the Defender app role only when S2S is enabled"); + } } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs index 7afca5c1..b7d13c0e 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs @@ -83,6 +83,17 @@ public void BuildAdminConsentUrls_DefenderApi_UsesAppIdIdentifierUri() because: "the Defender resource publishes its delegated permission under the api://{appId} audience"); } + [Fact] + public void BuildAdminConsentUrls_WhenDefenderDelegatedExcluded_OmitsDefenderApi() + { + var urls = SetupHelpers.BuildAdminConsentUrls( + TenantId, BlueprintClientId, new[] { "Mail.Send" }, new[] { "scope" }, + includeDefenderDelegated: false); + + urls.Should().NotContain(url => url.ResourceName == "Defender API", + because: "S2S-only blueprint agents must not request delegated Defender admin consent"); + } + [Fact] public void BuildAdminConsentUrls_PowerPlatformApi_UsesCorrectScopeConstant() { @@ -262,6 +273,21 @@ public void BuildCombinedConsentUrl_WithGccObservabilityResource_UsesGccAudience because: "a GCC consent URL must not request the commercial Observability audience"); } + [Fact] + public void BuildCombinedConsentUrl_WhenDefenderDelegatedExcluded_OmitsDefenderScope() + { + var url = SetupHelpers.BuildCombinedConsentUrl( + TenantId, + BlueprintClientId, + Array.Empty(), + Array.Empty(), + includeDefenderDelegated: false); + + url.Should().NotContain( + Uri.EscapeDataString($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiRealtimeProtectionScope}"), + because: "S2S-only setup must not include a delegated Defender scope in the combined admin-consent URL"); + } + [Fact] public void BuildCombinedConsentUrl_ScopesJoinedWithEncodedSpaceNotAmpersand() { From d9e4e67ede5386cd7c52ce454651659a368d47e8 Mon Sep 17 00:00:00 2001 From: slreznit Date: Tue, 6 Oct 2026 00:20:02 +0300 Subject: [PATCH 6/7] Revert "Align Defender permissions with auth mode" This reverts commit 50cd7bd97a4adc566a87840ae4c72f1913d900bc. --- CHANGELOG.md | 1 - .../SetupSubcommands/AllSubcommand.cs | 9 +- .../BatchPermissionsOrchestrator.cs | 22 ++-- .../NonDwBlueprintSetupOrchestrator.cs | 26 ++-- .../ResourcePermissionSpec.cs | 7 -- .../Commands/SetupSubcommands/SetupHelpers.cs | 111 ++++++------------ .../Commands/SetupSubcommands/SetupResults.cs | 2 +- .../Commands/AllSubcommandTests.cs | 88 +------------- .../BatchPermissionsOrchestratorTests.cs | 15 +-- .../Commands/SetupCommandTests.cs | 12 +- ...tupHelpersAdminConsentInstructionsTests.cs | 20 ---- .../Helpers/SetupHelpersConsentUrlTests.cs | 26 ---- 12 files changed, 72 insertions(+), 267 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79d1d735..e7f497d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,7 +139,6 @@ Agents provisioned before this release need `RealtimeProtection.Evaluate.All` gr ### Changed -- Defender permissions now follow the blueprint agent authentication mode: delegated for `obo`, application for `s2s`, and both for `both`; AI Teammate setup continues to request both permission types (#485). - `a365 setup all` no longer requests Observability API permissions for blueprint agents; registered agents that export telemetry through the app-only S2S endpoint need no admin consent (#501). - Hardened token storage: the CLI no longer writes access tokens to a plaintext file — they live only in the OS-protected MSAL cache (DPAPI/Keychain/owner-only file). Any legacy plaintext cache is removed automatically; sign-in prompts are unchanged. - `develop-mcp register-external-mcp-server` now sets `exit code 1` on failure paths (validation errors, tenant detection failure, Graph unavailable, Entra app creation failure, MCP-Platform AddMcpServer failure). Previously these paths logged an error and exited `0`, which made the command's success/failure status undetectable from scripts and CI. Successful dry-run and user-initiated cancellation at the y/N prompt continue to exit `0`. 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 65987112..49ace519 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/AllSubcommand.cs @@ -1020,14 +1020,7 @@ await PermissionsSubcommand.RemoveStaleCustomPermissionsAsync( // names so V2 audiences read as e.g. "mcp_MailTools" rather than "Agent 365 Tools". var specs = await SetupHelpers.BuildConfiguredPermissionSpecsAsync( ctx.Config, setInheritable: true, isM365: ctx.IsM365, scopesByAudience, serverNamesByAudience, - includeObservability: !ctx.SkipObservabilityPermissions, - defenderPermissionMode: ctx.Results.IsNonDwBlueprintFlow - ? ctx.IsS2sMode - ? DefenderPermissionMode.Application - : ctx.IsBothMode - ? DefenderPermissionMode.Both - : DefenderPermissionMode.Delegated - : DefenderPermissionMode.Both); + includeObservability: !ctx.SkipObservabilityPermissions); // Return the full scopesByAudience map alongside the V1-compat mcpScopes so V2 // callers (ApplyConsentUrlsIfNeeded) can route per-server audiences to the bare diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs index 4b417648..c4a23c67 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BatchPermissionsOrchestrator.cs @@ -84,11 +84,9 @@ internal static class BatchPermissionsOrchestrator return (true, true, true, null); } - // Drop specs that carry neither delegated scopes nor application roles. Application-only - // specs must remain so S2S mode can assign app roles without creating an OAuth2 grant. - var effectiveSpecs = specs - .Where(s => s.Scopes.Length > 0 || s.AppRoleScopes is { Length: > 0 }) - .ToList(); + // 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 (setupResults is not null) { setupResults.ObservabilityResourceAppId = effectiveSpecs @@ -99,12 +97,12 @@ internal static class BatchPermissionsOrchestrator if (effectiveSpecs.Count < specs.Count) { var skipped = specs.Count - effectiveSpecs.Count; - logger.LogDebug("Skipping {Count} resource spec(s) with no delegated scopes or application roles.", skipped); + 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 delegated scope and application role lists — skipping batch permissions configuration."); + logger.LogInformation("All permission specs have empty scope lists — skipping batch permissions configuration."); return (true, true, true, null); } @@ -417,16 +415,12 @@ private static async Task UpdateBlueprintPermissions continue; } - var permissionValues = spec.Scopes - .Concat(spec.AppRoleScopes ?? Array.Empty()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); logger.LogDebug( - " - Configuring inheritable permissions: {ResourceName} [{Permissions}]", - spec.ResourceName, string.Join(' ', permissionValues)); + " - Configuring inheritable permissions: {ResourceName} [{Scopes}]", + spec.ResourceName, string.Join(' ', spec.Scopes)); var (ok, alreadyExists, err) = await blueprintService.SetInheritablePermissionsAsync( - tenantId, blueprintAppId, spec.ResourceAppId, permissionValues, + tenantId, blueprintAppId, spec.ResourceAppId, spec.Scopes, requiredScopes: permScopes, ct); if (alreadyExists || ok) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs index e8d54ea3..0b8809f9 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/NonDwBlueprintSetupOrchestrator.cs @@ -36,16 +36,6 @@ internal static class NonDwBlueprintSetupOrchestrator public static void PrintDryRunPlan(Agent365Config config, ILogger logger, bool isBootstrap = false, string[]? rawArgs = null, bool skipRequirements = false, bool isM365 = false, bool agentRegistrationOnly = false, string? authMode = null, string? messagingEndpointOverride = null, bool skipObservabilityPermissions = false) { var sub = new string(' ', SetupHelpers.DryRunValCol); - var selectedAuthMode = authMode ?? config.AuthMode; - var effectiveMode = string.IsNullOrWhiteSpace(selectedAuthMode) - ? "obo" - : selectedAuthMode.Trim().ToLowerInvariant(); - var defenderPermissionMode = effectiveMode switch - { - "s2s" => DefenderPermissionMode.Application, - "both" => DefenderPermissionMode.Both, - _ => DefenderPermissionMode.Delegated, - }; var observabilityPermissionsEffectivelySkipped = skipObservabilityPermissions && !SetupHelpers.CustomPermissionsRequestObservability(config); // Dry-run S2S work comes only from fixed specs today; MCP and custom specs carry delegated scopes. @@ -53,8 +43,7 @@ public static void PrintDryRunPlan(Agent365Config config, ILogger logger, bool i setInheritable: true, isM365, config.Environment, - includeObservability: !skipObservabilityPermissions, - defenderPermissionMode) + includeObservability: !skipObservabilityPermissions) .Any(s => s.AppRoleScopes is { Length: > 0 }); // --messaging-endpoint flag (if supplied) wins over the init-only config value for the plan. var plannedEndpoint = !string.IsNullOrWhiteSpace(messagingEndpointOverride) @@ -139,6 +128,10 @@ public static void PrintDryRunPlan(Agent365Config config, ILogger logger, bool i // 3. Inheritable Permissions — the non-DW spec set is stamped on the blueprint so MAC and // dependent systems can see it. The same set is applied to the agent identity SP in step 5. + var selectedAuthMode = authMode ?? config.AuthMode; + var effectiveMode = string.IsNullOrWhiteSpace(selectedAuthMode) + ? "obo" + : selectedAuthMode.Trim().ToLowerInvariant(); logger.LogInformation(SetupHelpers.DryRunRow(3, "Inheritable Permissions") + "configure for {Resources} (Global Administrator required; consent URL printed if absent)", skipObservabilityPermissions ? "Defender API, Power Platform API, and custom permissions" @@ -290,11 +283,6 @@ await ctx.ClientAppValidator.GrantConsentForPermissionsAsync( public static async Task ExecuteAsync(SetupContext ctx) { ctx.Results.IsNonDwBlueprintFlow = true; - ctx.Results.EffectiveAuthMode = ctx.IsBothMode - ? Models.AuthMode.Both - : ctx.IsS2sMode - ? Models.AuthMode.S2s - : Models.AuthMode.Obo; ctx.Results.ObservabilityPermissionsSkipped = ctx.ObservabilityPermissionsEffectivelySkipped; ctx.Results.TenantId = ctx.Config.TenantId; // Bootstrap already printed the "Running..." banner before auth steps; skip here to avoid duplication. @@ -735,8 +723,8 @@ internal static async Task GrantAgentIdentityS2SPermissionsAsync( List specs) { var hasS2sSpecs = specs.Any(s => s.AppRoleScopes is { Length: > 0 }); - // Record whether the selected auth mode produced any application permissions so the - // summary can distinguish "no S2S work" from a failed grant. + // Blueprint agents no longer request OtelWrite, the only app role setup requested, so this + // step usually has nothing to grant. Record that so the summary does not report a delegated grant. ctx.Results.NoS2SAppRolesToGrant = !hasS2sSpecs; if (hasS2sSpecs && AgentIdentityInheritsBlueprintAppRoles(ctx.Results)) { diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/ResourcePermissionSpec.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/ResourcePermissionSpec.cs index 1c363c4c..5ca5d19e 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/ResourcePermissionSpec.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/ResourcePermissionSpec.cs @@ -3,13 +3,6 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Commands.SetupSubcommands; -internal enum DefenderPermissionMode -{ - Delegated, - Application, - Both, -} - /// /// Describes a single resource whose permissions should be configured on the agent blueprint. /// Used as input to . 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 16076585..33eef967 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupHelpers.cs @@ -59,8 +59,7 @@ internal static ResourcePermissionSpec[] GetFixedApiPermissionSpecs( bool setInheritable, bool isM365, string? environment = null, - bool includeObservability = true, - DefenderPermissionMode defenderPermissionMode = DefenderPermissionMode.Both) + bool includeObservability = true) { var specs = new List(); if (isM365) @@ -88,18 +87,12 @@ internal static ResourcePermissionSpec[] GetFixedApiPermissionSpecs( setInheritable, AppRoleScopes: new[] { ConfigConstants.ObservabilityApiOtelWriteScope })); } - var defenderDelegatedScopes = defenderPermissionMode is DefenderPermissionMode.Delegated or DefenderPermissionMode.Both - ? new[] { ConfigConstants.DefenderApiRealtimeProtectionScope } - : Array.Empty(); - var defenderAppRoleScopes = defenderPermissionMode is DefenderPermissionMode.Application or DefenderPermissionMode.Both - ? new[] { ConfigConstants.DefenderApiRealtimeProtectionScope } - : null; specs.Add(new ResourcePermissionSpec( ConfigConstants.DefenderApiAppId, "Defender API", - defenderDelegatedScopes, + new[] { ConfigConstants.DefenderApiRealtimeProtectionScope }, setInheritable, - AppRoleScopes: defenderAppRoleScopes)); + AppRoleScopes: new[] { ConfigConstants.DefenderApiRealtimeProtectionScope })); specs.Add(new ResourcePermissionSpec( PowerPlatformConstants.PowerPlatformApiResourceAppId, "Power Platform API", @@ -154,8 +147,7 @@ internal static async Task> BuildConfiguredPermissi bool isM365 = true, Dictionary? scopesByAudience = null, Dictionary>? serverNamesByAudience = null, - bool includeObservability = true, - DefenderPermissionMode defenderPermissionMode = DefenderPermissionMode.Both) + bool includeObservability = true) { // Manifest read at most once, and only when scopesByAudience is not pre-supplied. // Callers that already have the manifest loaded (e.g. AllSubcommand.BuildPermissionSpecsAsync) @@ -194,8 +186,7 @@ internal static async Task> BuildConfiguredPermissi : "Agent 365 Tools", kvp.Value, SetInheritable: setInheritable))); - specs.AddRange(GetFixedApiPermissionSpecs( - setInheritable, isM365, config.Environment, includeObservability, defenderPermissionMode)); + specs.AddRange(GetFixedApiPermissionSpecs(setInheritable, isM365, config.Environment, includeObservability)); foreach (var customPerm in config.CustomBlueprintPermissions ?? new List()) { @@ -484,8 +475,8 @@ internal static async Task ResolveBootstrapEnvironmentAsync( /// /// Fixed permission specs for the non-DW admin consent flow. - /// Observability API includes both permission types when requested. Defender follows the - /// selected auth mode. Power Platform API requires Delegated only. + /// Observability API and Defender API require both Application (app role for S2S) + /// and Delegated (oauth2 grant for OBO). Power Platform API requires Delegated only. /// Extend this list or pass an override to /// when additional APIs are required (e.g. dynamic MCP scopes, custom permissions). /// @@ -493,28 +484,20 @@ internal static async Task ResolveBootstrapEnvironmentAsync( GetNonDwAdminConsentSpecs("prod"); internal static IReadOnlyList<(string ResourceName, string ResourceAppId, string Scope, string PermissionType)> GetNonDwAdminConsentSpecs( - string? environment, - DefenderPermissionMode defenderPermissionMode = DefenderPermissionMode.Both) - => BuildNonDwAdminConsentSpecs(ConfigConstants.GetObservabilityApiAppId(environment), defenderPermissionMode); + string? environment) + => BuildNonDwAdminConsentSpecs(ConfigConstants.GetObservabilityApiAppId(environment)); private static IReadOnlyList<(string ResourceName, string ResourceAppId, string Scope, string PermissionType)> BuildNonDwAdminConsentSpecs( - string observabilityAppId, - DefenderPermissionMode defenderPermissionMode = DefenderPermissionMode.Both) + string observabilityAppId) { - var specs = new List<(string ResourceName, string ResourceAppId, string Scope, string PermissionType)> - { + return + [ ("Observability API", observabilityAppId, ConfigConstants.ObservabilityApiOtelWriteScope, "Application"), ("Observability API", observabilityAppId, ConfigConstants.ObservabilityApiOtelWriteScope, "Delegated"), + ("Defender API", ConfigConstants.DefenderApiAppId, ConfigConstants.DefenderApiRealtimeProtectionScope, "Application"), + ("Defender API", ConfigConstants.DefenderApiAppId, ConfigConstants.DefenderApiRealtimeProtectionScope, "Delegated"), ("Power Platform API", PowerPlatformConstants.PowerPlatformApiResourceAppId, PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead, "Delegated"), - }; - - if (defenderPermissionMode is DefenderPermissionMode.Application or DefenderPermissionMode.Both) - specs.Insert(2, ("Defender API", ConfigConstants.DefenderApiAppId, ConfigConstants.DefenderApiRealtimeProtectionScope, "Application")); - if (defenderPermissionMode is DefenderPermissionMode.Delegated or DefenderPermissionMode.Both) - specs.Insert(defenderPermissionMode == DefenderPermissionMode.Both ? 3 : 2, - ("Defender API", ConfigConstants.DefenderApiAppId, ConfigConstants.DefenderApiRealtimeProtectionScope, "Delegated")); - - return specs; + ]; } /// @@ -692,8 +675,9 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger, str // (e.g. admin already granted tenant consent but the per-principal call still failed). var pendingDelegatedAction = agentIdDelegatedFailed && !pendingAdminAction; var pendingS2SAction = permissionGrantsPending && isS2SFlow; - // Some configurations can have no application-role specs. Say so explicitly; otherwise - // an s2s-only row can fall through to delegated wording with no matching action item. + // Blueprint agents no longer request OtelWrite, the only app role setup requested, so an + // s2s/both run usually has no S2S grant at all. Say so explicitly: otherwise the row falls + // through to the delegated wording, which for s2s-only shows a PENDING with no action item. var noS2SAppRolesToGrant = isNonDw && (isS2sOnlyMode || isBothMode) && results.NoS2SAppRolesToGrant && !isS2SFlow; if (results.PermissionGrantsSkipped && isNonDw) @@ -983,13 +967,7 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger, str if (isNonDw && string.IsNullOrWhiteSpace(consentUrl)) { logger.LogInformation(" {N}. Permission Grants — must be granted by {Roles} in the Entra portal:", actionCount, AuthenticationConstants.DelegatedGrantRequiredRoles); - var defenderPermissionMode = results.EffectiveAuthMode switch - { - Models.AuthMode.S2s => DefenderPermissionMode.Application, - Models.AuthMode.Both => DefenderPermissionMode.Both, - _ => DefenderPermissionMode.Delegated, - }; - var consentSpecs = BuildNonDwAdminConsentSpecs(observabilityResourceAppId, defenderPermissionMode); + var consentSpecs = BuildNonDwAdminConsentSpecs(observabilityResourceAppId); if (results.ObservabilityPermissionsSkipped) consentSpecs = consentSpecs.Where(s => !ConfigConstants.IsObservabilityApiAppId(s.ResourceAppId)).ToList(); LogNonDwAdminConsentInstructions( @@ -1281,11 +1259,10 @@ public static void DisplaySetupSummary(SetupResults results, ILogger logger, str /// 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. /// - /// Graph, Agent 365 Tools (MCP), and Power Platform API URLs are always generated. Defender is - /// generated when delegated Defender consent is enabled, and Observability is generated unless - /// is false. Messaging Bot API is included only when - /// is true — non-M365 tenants typically lack the Messaging Bot resource - /// SP and the consent endpoint returns AADSTS650053 otherwise. + /// Graph, Agent 365 Tools (MCP), and Power Platform API URLs are always generated; the Observability + /// API URL is generated unless is false. Messaging Bot API is included only + /// when is true — non-M365 tenants typically lack the Messaging Bot + /// resource SP and the consent endpoint returns AADSTS650053 otherwise. /// /// /// Display names of the resources for which URLs were saved. @@ -1296,8 +1273,7 @@ internal static List PopulateAdminConsentUrls( bool isM365 = true, IReadOnlyDictionary? mcpScopesByAudience = null, IReadOnlyDictionary>? mcpAudienceDisplayNames = null, - bool includeObservability = true, - bool includeDefenderDelegated = true) + bool includeObservability = true) { var graphBaseUrl = ConfigConstants.GetGraphBaseUrl(config.Environment, config.GraphBaseUrl); var graphResourceUri = graphBaseUrl; @@ -1307,7 +1283,7 @@ internal static List PopulateAdminConsentUrls( var urls = BuildAdminConsentUrls( config.TenantId, config.AgentBlueprintId!, config.AgentApplicationScopes, mcpScopes, isM365, mcpScopesByAudience, mcpAudienceDisplayNames, graphResourceUri, authorityHost, - mcpResourceAppId, observabilityResourceAppId, includeObservability, includeDefenderDelegated); + mcpResourceAppId, observabilityResourceAppId, includeObservability); // Clear an Observability consent URL saved by an earlier run so the admin is not asked for permissions this run skipped. if (!includeObservability) @@ -1493,8 +1469,7 @@ internal static string BuildFullyQualifiedScope( /// (mirrors ): Microsoft Graph (when /// non-empty), Agent 365 Tools (when /// non-empty), Messaging Bot API (when is true), Observability API - /// (unless is false), Defender API (when delegated - /// Defender consent is enabled), and Power Platform API. + /// (unless is false), and Power Platform API. /// /// Messaging Bot is gated on because non-M365 tenants typically /// lack the Messaging Bot resource SP, in which case the /v2.0/adminconsent endpoint returns @@ -1514,8 +1489,7 @@ internal static string BuildFullyQualifiedScope( string? authorityHost = null, string? sharedMcpResourceAppId = null, string? observabilityResourceAppId = null, - bool includeObservability = true, - bool includeDefenderDelegated = true) + bool includeObservability = true) { var urls = new List<(string, string)>(); @@ -1584,8 +1558,7 @@ string Build(string tenant, string client, string resourceUri, IEnumerable /// Builds a single combined /v2.0/adminconsent URL covering every resource stamped on the /// blueprint: Graph, Agent 365 Tools (MCP), Observability API (unless - /// is false), Defender API when delegated Defender - /// consent is enabled, Power Platform API, and Messaging Bot API (only when - /// is true). + /// is false), Power Platform API, and + /// Messaging Bot API (only when is true). /// /// Messaging Bot is gated on because non-M365 tenants typically /// lack the Messaging Bot resource SP, which would cause the entire combined consent grant @@ -1615,8 +1587,7 @@ internal static string BuildCombinedConsentUrl( string? authorityHost = null, string? sharedMcpResourceAppId = null, string? observabilityResourceAppId = null, - bool includeObservability = true, - bool includeDefenderDelegated = true) + bool includeObservability = true) { var allScopes = new List(); foreach (var s in graphScopes) @@ -1652,8 +1623,7 @@ internal static string BuildCombinedConsentUrl( if (includeObservability) allScopes.Add( $"{ConfigConstants.BuildObservabilityApiIdentifierUri(observabilityResourceAppId ?? ConfigConstants.ObservabilityApiAppId)}/{ConfigConstants.ObservabilityApiOtelWriteScope}"); - if (includeDefenderDelegated) - allScopes.Add($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiRealtimeProtectionScope}"); + allScopes.Add($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiRealtimeProtectionScope}"); allScopes.Add($"{PowerPlatformConstants.PowerPlatformApiIdentifierUri}/{PowerPlatformConstants.PermissionNames.ConnectivityConnectionsRead}"); return BuildAdminConsentUrl(tenantId, blueprintClientId, allScopes, authorityHost); } @@ -1663,11 +1633,11 @@ internal static string BuildCombinedConsentUrl( /// when the running account is not a Global Administrator. Called by both DW and non-DW setup paths /// after the batch permissions step. /// - /// Messaging Bot API URLs are included only when is true. - /// Observability and delegated Defender URLs follow the selected setup permissions; Graph, - /// MCP, and Power Platform are always included so a tenant admin can complete the hand-off - /// with a single URL. When Observability permissions are skipped, a consent URL saved for them - /// by an earlier run is cleared on every run, admin runs included. + /// Messaging Bot API URLs are included only when is true, and + /// Observability API URLs only when the context requests Observability permissions; the other + /// resources (Graph, MCP, Power Platform) are always included so a tenant admin + /// can complete the hand-off with a single URL. When Observability permissions are skipped, a + /// consent URL saved for them by an earlier run is cleared on every run, admin runs included. /// Otherwise this is a no-op if admin consent was already granted or the blueprint ID is absent. /// /// @@ -1688,10 +1658,7 @@ internal static void ApplyConsentUrlsIfNeeded( return; var includeObservability = !ctx.SkipObservabilityPermissions; - var includeDefenderDelegated = !ctx.Results.IsNonDwBlueprintFlow || !ctx.IsS2sMode; - var consentResourceNames = PopulateAdminConsentUrls( - ctx.Config, mcpResourceAppId, mcpScopes, isM365, mcpScopesByAudience, - mcpAudienceDisplayNames, includeObservability, includeDefenderDelegated); + var consentResourceNames = PopulateAdminConsentUrls(ctx.Config, mcpResourceAppId, mcpScopes, isM365, mcpScopesByAudience, mcpAudienceDisplayNames, includeObservability); ctx.Results.ConsentUrlsSavedToPath = ctx.GeneratedConfigPath; ctx.Results.ConsentResourceNames.AddRange(consentResourceNames); var graphBaseUrl = ConfigConstants.GetGraphBaseUrl(ctx.Config.Environment, ctx.Config.GraphBaseUrl); @@ -1701,7 +1668,7 @@ internal static void ApplyConsentUrlsIfNeeded( ctx.Results.CombinedConsentUrl = BuildCombinedConsentUrl( ctx.Config.TenantId!, ctx.Config.AgentBlueprintId!, graphScopes, mcpScopes, isM365, mcpScopesByAudience, graphResourceUri, authorityHost, - mcpResourceAppId, observabilityResourceAppId, includeObservability, includeDefenderDelegated); + mcpResourceAppId, observabilityResourceAppId, includeObservability); } /// 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 2e2375e0..088761c2 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/SetupResults.cs @@ -65,7 +65,7 @@ public class SetupResults /// /// Outcome of S2S app role assignments targeting the blueprint service principal. Written by /// in the DW path and in the non-DW path when the - /// blueprint carries app-role scopes (for example Defender API in s2s or both mode). + /// blueprint carries app-role scopes (e.g. Observability API). /// public GrantOutcome BlueprintS2SOutcome { get; set; } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AllSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AllSubcommandTests.cs index e644b615..432272d6 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AllSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AllSubcommandTests.cs @@ -358,16 +358,11 @@ public async Task ExecuteMessagingEndpointStepAsync_WhenOverrideProvidedAndConfi // Observability API permission wiring // ----------------------------------------------------------------------- - private SetupContext BuildPermissionsContext( - bool skipObservabilityPermissions, - List? customPermissions = null, - string? authMode = null, - bool isNonDwBlueprintFlow = true) + private SetupContext BuildPermissionsContext(bool skipObservabilityPermissions, List? customPermissions = null) { var executor = Substitute.For(Substitute.For>()); var graph = Substitute.For(); var blueprintService = Substitute.For(Substitute.For>(), graph); - var results = new SetupResults { IsNonDwBlueprintFlow = isNonDwBlueprintFlow }; // The blueprint has no inheritable permissions yet, so stale-permission cleanup has nothing to remove. blueprintService.ListInheritablePermissionsAsync( Arg.Any(), Arg.Any(), Arg.Any?>(), Arg.Any()) @@ -383,7 +378,7 @@ private SetupContext BuildPermissionsContext( DeploymentProjectPath = _tempDir, CustomBlueprintPermissions = customPermissions, }, - results: results, + results: new SetupResults(), logger: NullLogger.Instance, configFile: new FileInfo(Path.Combine(_tempDir, "a365.config.json")), generatedConfigPath: Path.Combine(_tempDir, "a365.generated.config.json"), @@ -403,7 +398,6 @@ private SetupContext BuildPermissionsContext( federatedCredentialService: Substitute.ForPartsOf( Substitute.For>(), graph), clientAppValidator: Substitute.For(), - authMode: authMode, skipObservabilityPermissions: skipObservabilityPermissions); } @@ -418,63 +412,12 @@ public async Task BuildPermissionSpecsAsync_StampsObservabilityApiUnlessSkipped( specs.Any(s => s.ResourceAppId == ConfigConstants.ObservabilityApiAppId).Should().Be(!skipObservabilityPermissions, because: "the spec list drives inheritable permissions, app role grants, and admin consent, so skipping Observability permissions must remove Observability API from it"); - specs.Should().Contain(s => s.ResourceAppId == ConfigConstants.DefenderApiAppId, - because: "skipping Observability permissions must not remove the Defender permission required by the agent auth mode"); + specs.Any(s => s.AppRoleScopes is { Length: > 0 }).Should().Be(!skipObservabilityPermissions, + because: "OtelWrite is the only app role setup requests, so skipping it must leave no app role grant that needs a Global Administrator"); specs.Should().Contain(s => s.ResourceAppId == PowerPlatformConstants.PowerPlatformApiResourceAppId, because: "skipping Observability API must not drop the other required resources"); } - [Theory] - [InlineData(null, true, false)] - [InlineData("obo", true, false)] - [InlineData("s2s", false, true)] - [InlineData("both", true, true)] - public async Task BuildPermissionSpecsAsync_NonDw_DefenderPermissionsMatchAuthMode( - string? authMode, - bool expectDelegated, - bool expectApplication) - { - var ctx = BuildPermissionsContext( - skipObservabilityPermissions: true, - authMode: authMode, - isNonDwBlueprintFlow: true); - - var (specs, _, _, _, _) = await AllSubcommand.BuildPermissionSpecsAsync(ctx); - - var defender = specs.Single(s => s.ResourceAppId == ConfigConstants.DefenderApiAppId); - defender.Scopes.Should().BeEquivalentTo( - expectDelegated ? [ConfigConstants.DefenderApiRealtimeProtectionScope] : [], - because: $"the '{authMode ?? "obo"}' auth mode must request delegated Defender consent only when OBO is enabled"); - if (expectApplication) - { - defender.AppRoleScopes.Should().BeEquivalentTo( - [ConfigConstants.DefenderApiRealtimeProtectionScope], - because: $"the '{authMode ?? "obo"}' auth mode enables S2S Defender evaluation"); - } - else - { - defender.AppRoleScopes.Should().BeNull( - because: $"the '{authMode ?? "obo"}' auth mode must not request a Defender application role"); - } - } - - [Fact] - public async Task BuildPermissionSpecsAsync_Dw_PreservesBothDefenderPermissionTypes() - { - var ctx = BuildPermissionsContext( - skipObservabilityPermissions: false, - authMode: "obo", - isNonDwBlueprintFlow: false); - - var (specs, _, _, _, _) = await AllSubcommand.BuildPermissionSpecsAsync(ctx); - - var defender = specs.Single(s => s.ResourceAppId == ConfigConstants.DefenderApiAppId); - defender.Scopes.Should().BeEquivalentTo([ConfigConstants.DefenderApiRealtimeProtectionScope], - because: "DW setup does not use blueprint-agent auth modes and must preserve its delegated Defender permission"); - defender.AppRoleScopes.Should().BeEquivalentTo([ConfigConstants.DefenderApiRealtimeProtectionScope], - because: "DW setup must preserve the existing Defender application permission"); - } - [Fact] public void ApplyConsentUrlsIfNeeded_WhenObservabilitySkipped_HandsOffOnlyTheRemainingResources() { @@ -483,33 +426,14 @@ public void ApplyConsentUrlsIfNeeded_WhenObservabilitySkipped_HandsOffOnlyTheRem SetupHelpers.ApplyConsentUrlsIfNeeded( ctx, McpConstants.WorkIQToolsProdAppId, ctx.Config.AgentApplicationScopes, new[] { "McpServers.Mail.All" }, isM365: false); - ctx.Results.ConsentResourceNames.Should().BeEquivalentTo(new[] { "Microsoft Graph", "Agent 365 Tools", "Defender API", "Power Platform API" }, - because: "default OBO setup must hand off every delegated resource, including Defender, while omitting Observability"); + ctx.Results.ConsentResourceNames.Should().BeEquivalentTo(new[] { "Microsoft Graph", "Agent 365 Tools", "Power Platform API" }, + because: "a non-admin run must hand every stamped resource to an administrator, and Observability API is no longer stamped"); ctx.Config.ResourceConsents.Should().NotContain(rc => rc.ResourceAppId == ConfigConstants.ObservabilityApiAppId, because: "no Observability API consent URL may be persisted when its permissions were skipped"); ctx.Results.CombinedConsentUrl.Should().NotContain(ConfigConstants.ObservabilityApiAppId, because: "the single hand-off URL must not request Observability API scopes that setup skipped"); } - [Fact] - public void ApplyConsentUrlsIfNeeded_S2s_OmitsDelegatedDefenderConsent() - { - var ctx = BuildPermissionsContext( - skipObservabilityPermissions: true, - authMode: "s2s", - isNonDwBlueprintFlow: true); - - SetupHelpers.ApplyConsentUrlsIfNeeded( - ctx, McpConstants.WorkIQToolsProdAppId, ctx.Config.AgentApplicationScopes, - new[] { "McpServers.Mail.All" }, isM365: false); - - ctx.Results.ConsentResourceNames.Should().NotContain("Defender API", - because: "S2S-only mode requests the Defender application role, not its delegated scope"); - ctx.Results.CombinedConsentUrl.Should().NotContain( - Uri.EscapeDataString($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiRealtimeProtectionScope}"), - because: "the S2S hand-off URL must not request delegated Defender admin consent"); - } - [Fact] public void ApplyConsentUrlsIfNeeded_AdminRun_ClearsObservabilityConsentUrlSavedByAnEarlierRun() { 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 index 184ef64c..fa985611 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BatchPermissionsOrchestratorTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/BatchPermissionsOrchestratorTests.cs @@ -312,12 +312,12 @@ private void ArrangeS2SPhase1AndAdminCheck() .Returns(Task.FromResult((ok: false, alreadyExists: false, error: (string?)"Insufficient privileges"))); } - private static ResourcePermissionSpec[] S2SSpec(bool includeDelegatedScope = true) => + private static ResourcePermissionSpec[] S2SSpec() => [ new ResourcePermissionSpec( ConfigConstants.ObservabilityApiAppId, "Observability API", - includeDelegatedScope ? new[] { ConfigConstants.ObservabilityApiOtelWriteScope } : [], + new[] { ConfigConstants.ObservabilityApiOtelWriteScope }, SetInheritable: false, AppRoleScopes: new[] { ConfigConstants.ObservabilityApiOtelWriteScope }) ]; @@ -501,11 +501,8 @@ private void ArrangeAzRestS2SCalls(bool blueprintAlreadyAssigned, int postExitCo /// DisplaySetupSummary surfaces the S2S hand-off block in the Action Required section — /// just like it does for a GA whose Graph API call returns 403. /// - [Theory] - [InlineData(true)] - [InlineData(false)] - public async Task ConfigureAllPermissions_NonAdmin_WithS2SSpecs_SetsBlueprintS2SOutcomeFailed( - bool includeDelegatedScope) + [Fact] + public async Task ConfigureAllPermissions_NonAdmin_WithS2SSpecs_SetsBlueprintS2SOutcomeFailed() { // Arrange _graph.GraphGetAsync( @@ -537,13 +534,13 @@ await BatchPermissionsOrchestrator.ConfigureAllPermissionsAsync( _graph, _blueprintService, new Agent365Config { TenantId = S2STenantId, AgentBlueprintId = S2SBlueprintAppId }, blueprintAppId: S2SBlueprintAppId, tenantId: S2STenantId, - specs: S2SSpec(includeDelegatedScope), _logger, setupResults, ct: default); + specs: S2SSpec(), _logger, setupResults, ct: default); // Assert setupResults.BlueprintS2SOutcome.Should().Be(GrantOutcome.Failed, because: "a non-admin user cannot complete S2S app role assignment directly — the outcome must be marked Failed so DisplaySetupSummary surfaces the hand-off block"); setupResults.PendingBlueprintAppRoleSpecs.Should().ContainSingle(s => s.ResourceAppId == ConfigConstants.ObservabilityApiAppId, - because: "a non-admin run leaves every requested app role for the summary's hand-off, including application-only specs"); + because: "a non-admin run leaves every requested app role for the summary's hand-off"); } // ────────────────────────────────────────────────────────────────────────────────────── 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 2844adb3..2af4e503 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 @@ -731,16 +731,14 @@ public async Task SetupAll_BlueprintAgent_DefaultPlan_OmitsObservabilityApi() } /// - /// The S2S endpoint authorizes registered agents without OtelWrite, while Defender still requires - /// its application role for s2s and both modes. + /// The S2S endpoint authorizes registered agents without OtelWrite whatever the auth mode (validated live), so + /// s2s and both — from the flag or from a365.config.json — must not request Observability API permissions either. /// [Theory] [InlineData("--authmode s2s", null)] [InlineData("--authmode both", null)] [InlineData("", "both")] - public async Task SetupAll_BlueprintAgent_AppRoleAuthModes_OmitObservabilityAndKeepDefenderAppRole( - string args, - string? configAuthMode) + public async Task SetupAll_BlueprintAgent_AppRoleAuthModes_OmitObservabilityApi(string args, string? configAuthMode) { var config = new Agent365Config { @@ -773,9 +771,7 @@ public async Task SetupAll_BlueprintAgent_AppRoleAuthModes_OmitObservabilityAndK _mockLogger.Received().Log( LogLevel.Information, Arg.Any(), - Arg.Is(o => o.ToString()!.Contains("Blueprint Permission Grants") && - o.ToString()!.Contains("S2S app roles") && - !o.ToString()!.Contains("no S2S app roles to grant")), + Arg.Is(o => o.ToString()!.Contains("Blueprint Permission Grants") && o.ToString()!.Contains("no S2S app roles to grant")), Arg.Any(), Arg.Any>()); } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs index 57b2f85b..4835986f 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersAdminConsentInstructionsTests.cs @@ -172,24 +172,4 @@ public void NonDwAdminConsentSpecs_PowerPlatformApi_IsDelegatedOnly() specs.Should().Contain(s => s.ResourceName == "Power Platform API" && s.PermissionType == "Delegated", because: "Power Platform API ConnectivityConnections.Read is a delegated scope"); } - - [Theory] - [InlineData("Delegated", true, false)] - [InlineData("Application", false, true)] - [InlineData("Both", true, true)] - public void GetNonDwAdminConsentSpecs_DefenderMatchesAuthMode( - string modeName, - bool expectDelegated, - bool expectApplication) - { - var mode = Enum.Parse(modeName); - var specs = SetupHelpers.GetNonDwAdminConsentSpecs("prod", mode); - - specs.Any(s => s.ResourceName == "Defender API" && s.PermissionType == "Delegated") - .Should().Be(expectDelegated, - because: $"{mode} mode must include the delegated Defender permission only when OBO is enabled"); - specs.Any(s => s.ResourceName == "Defender API" && s.PermissionType == "Application") - .Should().Be(expectApplication, - because: $"{mode} mode must include the Defender app role only when S2S is enabled"); - } } diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs index b7d13c0e..7afca5c1 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Helpers/SetupHelpersConsentUrlTests.cs @@ -83,17 +83,6 @@ public void BuildAdminConsentUrls_DefenderApi_UsesAppIdIdentifierUri() because: "the Defender resource publishes its delegated permission under the api://{appId} audience"); } - [Fact] - public void BuildAdminConsentUrls_WhenDefenderDelegatedExcluded_OmitsDefenderApi() - { - var urls = SetupHelpers.BuildAdminConsentUrls( - TenantId, BlueprintClientId, new[] { "Mail.Send" }, new[] { "scope" }, - includeDefenderDelegated: false); - - urls.Should().NotContain(url => url.ResourceName == "Defender API", - because: "S2S-only blueprint agents must not request delegated Defender admin consent"); - } - [Fact] public void BuildAdminConsentUrls_PowerPlatformApi_UsesCorrectScopeConstant() { @@ -273,21 +262,6 @@ public void BuildCombinedConsentUrl_WithGccObservabilityResource_UsesGccAudience because: "a GCC consent URL must not request the commercial Observability audience"); } - [Fact] - public void BuildCombinedConsentUrl_WhenDefenderDelegatedExcluded_OmitsDefenderScope() - { - var url = SetupHelpers.BuildCombinedConsentUrl( - TenantId, - BlueprintClientId, - Array.Empty(), - Array.Empty(), - includeDefenderDelegated: false); - - url.Should().NotContain( - Uri.EscapeDataString($"{ConfigConstants.DefenderApiIdentifierUri}/{ConfigConstants.DefenderApiRealtimeProtectionScope}"), - because: "S2S-only setup must not include a delegated Defender scope in the combined admin-consent URL"); - } - [Fact] public void BuildCombinedConsentUrl_ScopesJoinedWithEncodedSpaceNotAmpersand() { From 5b62a86fc536cc3e10f87b710c1e6b8cc51a3845 Mon Sep 17 00:00:00 2001 From: slreznit Date: Tue, 6 Oct 2026 00:24:16 +0300 Subject: [PATCH 7/7] Update Defender permission expectations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Commands/AllSubcommandTests.cs | 13 +++++++++---- .../Commands/SetupCommandTests.cs | 12 ++++++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AllSubcommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AllSubcommandTests.cs index 432272d6..c7450b9c 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AllSubcommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/AllSubcommandTests.cs @@ -412,8 +412,13 @@ public async Task BuildPermissionSpecsAsync_StampsObservabilityApiUnlessSkipped( specs.Any(s => s.ResourceAppId == ConfigConstants.ObservabilityApiAppId).Should().Be(!skipObservabilityPermissions, because: "the spec list drives inheritable permissions, app role grants, and admin consent, so skipping Observability permissions must remove Observability API from it"); - specs.Any(s => s.AppRoleScopes is { Length: > 0 }).Should().Be(!skipObservabilityPermissions, - because: "OtelWrite is the only app role setup requests, so skipping it must leave no app role grant that needs a Global Administrator"); + specs.Any(s => s.ResourceAppId == ConfigConstants.ObservabilityApiAppId && + s.AppRoleScopes is { Length: > 0 }).Should().Be(!skipObservabilityPermissions, + because: "skipping Observability permissions must remove the OtelWrite app role without affecting application roles required by other resources"); + specs.Should().Contain(s => s.ResourceAppId == ConfigConstants.DefenderApiAppId && + s.AppRoleScopes != null && + s.AppRoleScopes.Contains(ConfigConstants.DefenderApiRealtimeProtectionScope), + because: "Defender independently requires RealtimeProtection.Evaluate.All as an application permission"); specs.Should().Contain(s => s.ResourceAppId == PowerPlatformConstants.PowerPlatformApiResourceAppId, because: "skipping Observability API must not drop the other required resources"); } @@ -426,8 +431,8 @@ public void ApplyConsentUrlsIfNeeded_WhenObservabilitySkipped_HandsOffOnlyTheRem SetupHelpers.ApplyConsentUrlsIfNeeded( ctx, McpConstants.WorkIQToolsProdAppId, ctx.Config.AgentApplicationScopes, new[] { "McpServers.Mail.All" }, isM365: false); - ctx.Results.ConsentResourceNames.Should().BeEquivalentTo(new[] { "Microsoft Graph", "Agent 365 Tools", "Power Platform API" }, - because: "a non-admin run must hand every stamped resource to an administrator, and Observability API is no longer stamped"); + ctx.Results.ConsentResourceNames.Should().BeEquivalentTo(new[] { "Microsoft Graph", "Agent 365 Tools", "Defender API", "Power Platform API" }, + because: "a non-admin run must hand every stamped resource to an administrator, including Defender, while Observability is no longer stamped"); ctx.Config.ResourceConsents.Should().NotContain(rc => rc.ResourceAppId == ConfigConstants.ObservabilityApiAppId, because: "no Observability API consent URL may be persisted when its permissions were skipped"); ctx.Results.CombinedConsentUrl.Should().NotContain(ConfigConstants.ObservabilityApiAppId, 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 2af4e503..6c95747d 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 @@ -731,14 +731,16 @@ public async Task SetupAll_BlueprintAgent_DefaultPlan_OmitsObservabilityApi() } /// - /// The S2S endpoint authorizes registered agents without OtelWrite whatever the auth mode (validated live), so - /// s2s and both — from the flag or from a365.config.json — must not request Observability API permissions either. + /// The S2S endpoint authorizes registered agents without OtelWrite, while Defender still + /// contributes an application role for s2s and both modes. /// [Theory] [InlineData("--authmode s2s", null)] [InlineData("--authmode both", null)] [InlineData("", "both")] - public async Task SetupAll_BlueprintAgent_AppRoleAuthModes_OmitObservabilityApi(string args, string? configAuthMode) + public async Task SetupAll_BlueprintAgent_AppRoleAuthModes_OmitObservabilityAndKeepDefenderAppRole( + string args, + string? configAuthMode) { var config = new Agent365Config { @@ -771,7 +773,9 @@ public async Task SetupAll_BlueprintAgent_AppRoleAuthModes_OmitObservabilityApi( _mockLogger.Received().Log( LogLevel.Information, Arg.Any(), - Arg.Is(o => o.ToString()!.Contains("Blueprint Permission Grants") && o.ToString()!.Contains("no S2S app roles to grant")), + Arg.Is(o => o.ToString()!.Contains("Blueprint Permission Grants") && + o.ToString()!.Contains("S2S app roles") && + !o.ToString()!.Contains("no S2S app roles to grant")), Arg.Any(), Arg.Any>()); }