diff --git a/CHANGELOG.md b/CHANGELOG.md index f2b2eb14..4ee34c3a 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 `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 +- `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/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..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,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.DefenderApiRealtimeProtectionScope); 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..0f0e69a8 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.DefenderApiRealtimeProtectionScope }, + setInheritable, + AppRoleScopes: new[] { ConfigConstants.DefenderApiRealtimeProtectionScope })); 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.DefenderApiRealtimeProtectionScope, "Application"), + ("Defender API", ConfigConstants.DefenderApiAppId, ConfigConstants.DefenderApiRealtimeProtectionScope, "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.DefenderApiRealtimeProtectionScope), + ]; + /// /// 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.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"); 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.DefenderApiRealtimeProtectionScope }))); 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.DefenderApiRealtimeProtectionScope}"); 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..6ea1deab 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Constants/ConfigConstants.cs @@ -80,6 +80,16 @@ public static class ConfigConstants /// public const string ObservabilityApiIdentifierUri = "api://9b975845-388f-4429-889e-eab1ef63949c"; + /// + /// Defender API App ID + /// + public const string DefenderApiAppId = "86a21212-634e-4553-b3d6-e477e4c9d9ec"; + + /// + /// Defender API identifier URI. + /// + 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 +107,12 @@ public static class ConfigConstants /// public const string ObservabilityApiOtelWriteScope = "Agent365.Observability.OtelWrite"; + /// + /// 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"; + /// /// 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..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 @@ -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,34 @@ 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.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() + { + // 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"); + } + [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..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 @@ -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.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"); + 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..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 @@ -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.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"); + } + [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.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}")); } @@ -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]