From d357089560d254c1c6b038526e021ae00014121c Mon Sep 17 00:00:00 2001 From: slreznit Date: Sun, 9 Aug 2026 23:32:25 +0300 Subject: [PATCH 1/3] 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/3] 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/3] 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"); }