diff --git a/CHANGELOG.md b/CHANGELOG.md index 59aa369b..ba229c5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ 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. ### Added +- `develop-mcp publish` now creates the A365 proxy Entra app and sends its credentials to the platform, so custom (non-Dataverse) MCP servers get a Power Platform connector created at publish time. - Setup and bootstrap now use Microsoft's first-party Agent 365 CLI application when it is present in your tenant, validating it without changing Microsoft's app registration, and fall back to a tenant-owned "Agent 365 CLI" app when it is not (#489). - 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. diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommandExecutor.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommandExecutor.cs index c60e3ed0..c0d6b5d0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommandExecutor.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/PublishCommandExecutor.cs @@ -73,7 +73,11 @@ private sealed record ResolvedInput internal sealed record EntraAppSet( string? PublicClientsClientId, string? PublicClientsObjectId, - string PublicClientsAppName); + string PublicClientsAppName, + string A365AppClientId, + string A365AppSecret, + string A365AppObjectId, + string A365AppName); internal async Task ExecuteAsync(RawPublishArgs args, CancellationToken ct = default) { @@ -84,7 +88,7 @@ internal async Task ExecuteAsync(RawPublishArgs args, CancellationToken ct if (input.DryRun) { - _logger.LogInformation("[DRY RUN] Would create Entra app '{PublicClients}' in tenant", $"{input.ServerName}-PublicClients"); + _logger.LogInformation("[DRY RUN] Would create Entra apps '{PublicClients}' and '{A365Proxy}' in tenant", $"{input.ServerName}-PublicClients", $"{input.ServerName}-A365Proxy"); _logger.LogInformation("[DRY RUN] Would call publish endpoint and back-fill PPMI scope on the created app"); return true; } @@ -131,6 +135,8 @@ internal async Task ExecuteAsync(RawPublishArgs args, CancellationToken ct DisplayName = input.DisplayName, PublicClientsAppId = apps.PublicClientsClientId, PublisherName = input.PublisherName, + A365ProxyClientId = apps.A365AppClientId, + A365ProxyClientSecret = apps.A365AppSecret, }; PublishMcpServerResponse? publishResponse; @@ -318,13 +324,24 @@ private void DisplayPublishSummary(ResolvedInput input) { var provisioner = new EntraAppProvisioner(_logger, _graphApiService!, _retryHelper); + // Confidential A365 proxy app + secret. Required for custom (non-Dataverse) servers: the + // platform creates the Power Platform connector only when its credentials are supplied. + var a365ProxyApp = await provisioner.CreateProxyAppAsync( + input.ServerName, tenantId, suffix: "A365Proxy", roleDisplay: "A365 Proxy", + serviceTreeId: null, ct: ct); + if (a365ProxyApp is null) return null; + var publicClients = await provisioner.CreatePublicClientsAppAsync( input.ServerName, tenantId, serviceTreeId: null, warnings, ct); return new EntraAppSet( PublicClientsClientId: publicClients.ClientId, PublicClientsObjectId: publicClients.ObjectId, - PublicClientsAppName: publicClients.AppName); + PublicClientsAppName: publicClients.AppName, + A365AppClientId: a365ProxyApp.ClientId, + A365AppSecret: a365ProxyApp.Secret, + A365AppObjectId: a365ProxyApp.ObjectId, + A365AppName: a365ProxyApp.AppName); } // Best-effort compensating delete for the Entra apps created in CreateEntraAppsAsync, run when @@ -335,7 +352,7 @@ internal async Task RollbackEntraAppsAsync(EntraAppSet apps, string tenantId, Ca { if (_graphApiService is null) { - _logger.LogWarning("Graph API service is unavailable; cannot roll back Entra app '{PublicClients}'. Delete it manually in the Azure portal.", apps.PublicClientsAppName); + _logger.LogWarning("Graph API service is unavailable; cannot roll back Entra apps '{PublicClients}' and '{A365Proxy}'. Delete them manually in the Azure portal.", apps.PublicClientsAppName, apps.A365AppName); return; } @@ -346,6 +363,11 @@ internal async Task RollbackEntraAppsAsync(EntraAppSet apps, string tenantId, Ca await DeleteOneAsync(apps.PublicClientsObjectId, apps.PublicClientsClientId, apps.PublicClientsAppName, ct); } + if (!string.IsNullOrWhiteSpace(apps.A365AppObjectId)) + { + await DeleteOneAsync(apps.A365AppObjectId, apps.A365AppClientId, apps.A365AppName, ct); + } + async Task DeleteOneAsync(string objectId, string? clientId, string appName, CancellationToken cancellationToken) { try @@ -418,6 +440,12 @@ private async Task ConfigureEntraAppsAsync( if (resourceScopeId.HasValue) { + // The platform wires the A365 proxy connector with the proxy app as its OAuth client and + // McpServerAppId as the resource, so the proxy app must hold this required-resource-access + // grant or Entra rejects the token request (AADSTS650057). Grant it on both the proxy app + // and the Public Clients app, mirroring register. + tasks.Add(AddRequiredResourceAccessAsync(tenantId, apps.A365AppObjectId, apps.A365AppName, resourceAppId!, resourceScopeId.Value, concurrentWarnings, ct)); + if (apps.PublicClientsObjectId != null) { tasks.Add(AddRequiredResourceAccessAsync(tenantId, apps.PublicClientsObjectId, apps.PublicClientsAppName, resourceAppId!, resourceScopeId.Value, concurrentWarnings, ct)); @@ -430,12 +458,60 @@ private async Task ConfigureEntraAppsAsync( concurrentWarnings.Add(msg); } + // Custom (non-Dataverse) servers get a Power Platform connector whose redirect URI the + // platform returns here. Write it onto the A365 proxy app so the connector's OAuth flow works. + var a365RedirectUri = response.A365ProxyRedirectUri; + if (!string.IsNullOrWhiteSpace(a365RedirectUri)) + { + tasks.Add(UpdateA365RedirectUrisAsync(tenantId, apps, a365RedirectUri, concurrentWarnings, ct)); + } + else + { + var msg = "A365 Proxy redirect URI was not returned by publish. Redirect URI configuration skipped."; + _logger.LogWarning(msg); + concurrentWarnings.Add(msg); + } + await Task.WhenAll(tasks); foreach (var w in concurrentWarnings) warnings.Add(w); } + private async Task UpdateA365RedirectUrisAsync( + string tenantId, EntraAppSet apps, string a365RedirectUri, + System.Collections.Concurrent.ConcurrentBag concurrentWarnings, + CancellationToken ct = default) + { + try + { + var a365TcUri = DevelopMcpCommand.AddTcPrefix(a365RedirectUri); + var a365NonTcUri = DevelopMcpCommand.RemoveTcPrefix(a365RedirectUri); + var a365Uris = DevelopMcpCommand.BuildRedirectUriList(a365RedirectUri, a365TcUri, a365NonTcUri); + _logger.LogDebug("Updating redirect URIs on '{AppName}' ({ObjectId})", apps.A365AppName, apps.A365AppObjectId); + var success = await _retryHelper.ExecuteWithRetryAsync( + async retryCt => await _graphApiService!.UpdateAppRedirectUrisAsync(tenantId, apps.A365AppObjectId, a365Uris, retryCt), + result => !result, + cancellationToken: ct); + if (!success) + { + var msg = $"Failed to update redirect URIs on A365 Proxy app '{apps.A365AppName}' after retries."; + _logger.LogError(msg); + concurrentWarnings.Add(msg); + } + else + { + _logger.LogInformation("Updated redirect URIs on '{AppName}'", apps.A365AppName); + } + } + catch (Exception ex) + { + var msg = $"Failed to update redirect URIs on A365 Proxy app: {ex.Message}"; + _logger.LogError(msg); + concurrentWarnings.Add(msg); + } + } + private async Task AddRequiredResourceAccessAsync( string tenantId, string appObjectId, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerRequest.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerRequest.cs index ceb92080..b767f6ce 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerRequest.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerRequest.cs @@ -37,4 +37,20 @@ public class PublishMcpServerRequest /// [JsonPropertyName("publisherName")] public string? PublisherName { get; set; } + + /// + /// A365 proxy (confidential) Entra app client id created CLI-side. The platform's v2 publish + /// path creates the Power Platform connector for custom (non-Dataverse) servers only when both + /// this and are supplied; otherwise connector creation is + /// skipped (A365ProxyConnectorCreation=SkippedNoCredentials). + /// + [JsonPropertyName("a365ProxyClientId")] + public string? A365ProxyClientId { get; set; } + + /// + /// Client secret for the A365 proxy Entra app. Paired with so the + /// platform can create the Power Platform connector for custom servers. + /// + [JsonPropertyName("a365ProxyClientSecret")] + public string? A365ProxyClientSecret { get; set; } } diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerResponse.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerResponse.cs index 2ea1e2b9..e9e05166 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerResponse.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishMcpServerResponse.cs @@ -47,6 +47,22 @@ public class PublishMcpServerResponse [JsonPropertyName("PublicClientsAppId")] public string? PublicClientsAppId { get; set; } + /// + /// Redirect URI the platform assigns to the A365 proxy connector for custom servers. When + /// present, the CLI writes the tc/non-tc redirect URI list onto the A365 proxy Entra app it + /// created. Emitted PascalCase by the platform, same as . + /// + [JsonPropertyName("A365ProxyRedirectUri")] + public string? A365ProxyRedirectUri { get; set; } + + /// + /// Id of the Power Platform connector the platform created for the custom server, when proxy + /// credentials were supplied. Surfaced for logging/parity; empty when connector creation was + /// skipped. + /// + [JsonPropertyName("A365ProxyConnectorId")] + public string? A365ProxyConnectorId { get; set; } + /// /// Whether the operation was successful. /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs index 578b0839..d65b5c93 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs @@ -196,7 +196,7 @@ private static void RedactSecretFields(System.Text.Json.Nodes.JsonObject obj) { var secretKeys = new HashSet(StringComparer.OrdinalIgnoreCase) { - "clientApp1Secret", "clientApp2Secret", "clientSecret" + "clientApp1Secret", "clientApp2Secret", "clientSecret", "a365ProxyClientSecret" }; foreach (var key in obj.Select(p => p.Key).ToList()) diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandRegressionTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandRegressionTests.cs index 5d9a079d..5915ce78 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandRegressionTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandRegressionTests.cs @@ -155,6 +155,12 @@ public async Task PublishCommand_ForwardsParsedParametersToToolingService() TestTenantId, TestPublicClientsObjectId, Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); + // Publish now also creates the confidential A365 proxy app + secret (required so the platform + // creates the Power Platform connector for custom servers). Stub the secret so proxy creation succeeds. + graphApiService.AddAppPasswordAsync( + TestTenantId, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult("a365-proxy-secret")); + // Mock Graph for ConfigureEntraAppsAsync → required-resource-access grant on Public Clients. graphApiService.GetOAuth2PermissionScopeIdAsync( TestTenantId, Arg.Any(), Arg.Any(), Arg.Any()) @@ -224,6 +230,14 @@ public async Task PublishCommand_ForwardsParsedParametersToToolingService() because: "the just-created Public Clients Entra app's clientId must be carried to the " + "platform so it can be echoed back and the CLI can grant the PPMI scope on it " + "post-publish."); + capturedRequest.A365ProxyClientId.Should().NotBeNullOrEmpty( + because: "the confidential A365 proxy app's clientId must be forwarded so the platform " + + "creates the Power Platform connector for custom servers instead of logging " + + "SkippedNoCredentials."); + capturedRequest.A365ProxyClientSecret.Should().Be( + "a365-proxy-secret", + because: "the proxy app's secret must be forwarded alongside its clientId; the platform " + + "requires both to create the connector."); } /// @@ -256,6 +270,9 @@ public async Task PublishCommand_ExplicitEmptyPublisherName_SkipsPromptAndForwar graphApiService.UpdateAppPublicClientRedirectUrisAsync( TestTenantId, TestPublicClientsObjectId, Arg.Any(), Arg.Any()) .Returns(Task.FromResult(true)); + graphApiService.AddAppPasswordAsync( + TestTenantId, Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult("a365-proxy-secret")); graphApiService.GetOAuth2PermissionScopeIdAsync( TestTenantId, Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(Guid.NewGuid())); diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandExecutorDryRunTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandExecutorDryRunTests.cs index 1989ea12..6816c19b 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandExecutorDryRunTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandExecutorDryRunTests.cs @@ -13,12 +13,12 @@ namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; /// /// Tests for dry-run output. The dry-run log must mirror the /// real Entra app naming scheme (derived from ServerName) so users can predict what will be -/// created — the {ServerName}-PublicClients app. +/// created — the {ServerName}-PublicClients and {ServerName}-A365Proxy apps. /// public class PublishCommandExecutorDryRunTests { /// - /// The dry-run log must (a) name only the Public Clients app — derived from ServerName, + /// The dry-run log must (a) name the Public Clients app — derived from ServerName, /// not Alias — (b) describe a PPMI-scope-only back-fill (no redirect-URI back-fill), and /// (c) skip the platform publish call entirely. /// diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandExecutorEntraAppTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandExecutorEntraAppTests.cs new file mode 100644 index 00000000..7e212c08 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/PublishCommandExecutorEntraAppTests.cs @@ -0,0 +1,258 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Commands; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Commands; + +/// +/// Covers the Entra-app orchestration performs for custom +/// (non-Dataverse) MCP servers: the confidential A365 proxy app must be created and its credentials +/// forwarded to the platform so the Power Platform connector is created at publish time, and both +/// created apps must be rolled back on failure. These invariants are what let the platform stop +/// logging A365ProxyConnectorCreation=SkippedNoCredentials. +/// +/// Tests substitute the concrete (all Entra calls are virtual) and let +/// the real run against it, mirroring the production wiring. +/// +public class PublishCommandExecutorEntraAppTests +{ + private const string TenantId = "00000000-0000-0000-0000-000000000001"; + private const string EnvironmentId = "00000000-0000-0000-0000-000000000000"; + private const string ServerName = "mcp_TestServer"; + + private static RawPublishArgs MakeArgs() => new( + EnvironmentId: EnvironmentId, + ServerName: ServerName, + Alias: "myAlias", + DisplayName: "Test Display", + PublisherName: "Contoso", + Yes: true, + DryRun: false); + + private static PublishCommandExecutor MakeExecutor( + ILogger logger, IAgent365ToolingService tooling, GraphApiService graph) + { + var retry = new RetryHelper(logger, maxRetries: 1, baseDelaySeconds: 0); + return new TestablePublishCommandExecutor(logger, tooling, graph, retry, TenantId); + } + + /// + /// Stubs a successful two-app creation: the A365 proxy app (with a secret) and the Public + /// Clients app. Returns the proxy client id / secret / object id the tests assert on. + /// + private static (string ProxyClientId, string ProxySecret, string ProxyObjectId) ArrangeSuccessfulAppCreation(GraphApiService graph) + { + const string proxyObjectId = "proxy-object-id"; + const string proxyClientId = "proxy-client-id"; + const string proxySecret = "proxy-secret"; + + graph.CreateEntraAppAsync(Arg.Any(), Arg.Is(n => n.EndsWith("-A365Proxy")), Arg.Any(), Arg.Any()) + .Returns((proxyObjectId, proxyClientId)); + graph.CreateEntraAppAsync(Arg.Any(), Arg.Is(n => n.EndsWith("-PublicClients")), Arg.Any(), Arg.Any()) + .Returns(("pc-object-id", "pc-client-id")); + graph.AddAppPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(proxySecret); + graph.UpdateAppPublicClientRedirectUrisAsync(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(true); + graph.UpdateAppRedirectUrisAsync(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(true); + graph.DeleteEntraAppAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + + return (proxyClientId, proxySecret, proxyObjectId); + } + + [Fact] + public async Task ExecuteAsync_WhenProxyAppCreationFails_AbortsPublish_WithoutCallingPlatform() + { + var logger = Substitute.For(); + var tooling = Substitute.For(); + var graph = Substitute.For(); + + // Proxy app creation fails; public-clients creation is never reached because the proxy app + // is mandatory for custom servers. + graph.CreateEntraAppAsync(Arg.Any(), Arg.Is(n => n.EndsWith("-A365Proxy")), Arg.Any(), Arg.Any()) + .Returns(((string, string)?)null); + + var executor = MakeExecutor(logger, tooling, graph); + + var result = await executor.ExecuteAsync(MakeArgs(), CancellationToken.None); + + result.Should().BeFalse("proxy app creation failure must fail the publish for custom servers"); + await tooling.DidNotReceiveWithAnyArgs().PublishServerAsync(default!, default!, default!, default); + } + + [Fact] + public async Task ExecuteAsync_ForwardsProxyCredentials_ToPlatformPublishRequest() + { + var logger = Substitute.For(); + var tooling = Substitute.For(); + var graph = Substitute.For(); + + var (proxyClientId, proxySecret, _) = ArrangeSuccessfulAppCreation(graph); + + PublishMcpServerRequest? capturedRequest = null; + tooling.PublishServerAsync(EnvironmentId, ServerName, Arg.Do(r => capturedRequest = r), Arg.Any()) + .Returns(new PublishMcpServerResponse { Status = "Success" }); + + var executor = MakeExecutor(logger, tooling, graph); + + var result = await executor.ExecuteAsync(MakeArgs(), CancellationToken.None); + + result.Should().BeTrue(); + capturedRequest.Should().NotBeNull(); + capturedRequest!.A365ProxyClientId.Should().Be(proxyClientId, + because: "the platform creates the Power Platform connector only when the proxy app's client id is supplied"); + capturedRequest.A365ProxyClientSecret.Should().Be(proxySecret, + because: "the platform needs the proxy app's secret to create the connector; without it it logs SkippedNoCredentials"); + } + + [Fact] + public async Task ExecuteAsync_WhenProxyRedirectUriReturned_UpdatesProxyAppRedirectUris() + { + var logger = Substitute.For(); + var tooling = Substitute.For(); + var graph = Substitute.For(); + + var (_, _, proxyObjectId) = ArrangeSuccessfulAppCreation(graph); + + tooling.PublishServerAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new PublishMcpServerResponse + { + Status = "Success", + A365ProxyRedirectUri = "https://global.consent.azure-apim.net/redirect", + }); + + var executor = MakeExecutor(logger, tooling, graph); + + var result = await executor.ExecuteAsync(MakeArgs(), CancellationToken.None); + + result.Should().BeTrue(); + await graph.Received(1).UpdateAppRedirectUrisAsync( + TenantId, proxyObjectId, Arg.Any>(), Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_WhenProxyRedirectUriMissing_WarnsAndSkipsRedirectUpdate() + { + var logger = Substitute.For(); + var tooling = Substitute.For(); + var graph = Substitute.For(); + + ArrangeSuccessfulAppCreation(graph); + + tooling.PublishServerAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new PublishMcpServerResponse { Status = "Success" }); + + var executor = MakeExecutor(logger, tooling, graph); + + var result = await executor.ExecuteAsync(MakeArgs(), CancellationToken.None); + + result.Should().BeTrue(); + await graph.DidNotReceive().UpdateAppRedirectUrisAsync( + Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()); + logger.Received().Log( + LogLevel.Warning, + Arg.Any(), + Arg.Is(o => o.ToString()!.Contains("A365 Proxy redirect URI was not returned")), + Arg.Any(), + Arg.Any>()); + } + + /// + /// The A365 proxy app is the OAuth client of the platform-created connector, with McpServerAppId + /// as its resource. Without a required-resource-access grant for that resource on the proxy app, + /// Entra rejects the connector's token request (AADSTS650057). The grant must therefore land on + /// BOTH the proxy app and the Public Clients app. + /// + [Fact] + public async Task ExecuteAsync_GrantsMcpServerResourceAccess_OnBothProxyAndPublicClientsApps() + { + var logger = Substitute.For(); + var tooling = Substitute.For(); + var graph = Substitute.For(); + + var (_, _, proxyObjectId) = ArrangeSuccessfulAppCreation(graph); + + const string mcpServerAppId = "1a2a0eb6-0000-0000-0000-000000000000"; + const string mcpServerScope = "Tools.ListInvoke.All"; + var scopeId = Guid.NewGuid(); + + graph.GetOAuth2PermissionScopeIdAsync(TenantId, mcpServerAppId, mcpServerScope, Arg.Any()) + .Returns(scopeId); + graph.AddRequiredResourceAccessAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(true); + + tooling.PublishServerAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new PublishMcpServerResponse + { + Status = "Success", + McpServerAppId = mcpServerAppId, + McpServerScope = mcpServerScope, + }); + + var executor = MakeExecutor(logger, tooling, graph); + + var result = await executor.ExecuteAsync(MakeArgs(), CancellationToken.None); + + result.Should().BeTrue(); + await graph.Received(1).AddRequiredResourceAccessAsync( + TenantId, proxyObjectId, mcpServerAppId, scopeId, Arg.Any()); + await graph.Received(1).AddRequiredResourceAccessAsync( + TenantId, "pc-object-id", mcpServerAppId, scopeId, Arg.Any()); + await graph.Received(2).AddRequiredResourceAccessAsync( + Arg.Any(), Arg.Any(), mcpServerAppId, scopeId, Arg.Any()); + } + + [Fact] + public async Task RollbackEntraAppsAsync_DeletesBothPublicClientsAndProxyApps() + { + var logger = Substitute.For(); + var tooling = Substitute.For(); + var graph = Substitute.For(); + graph.DeleteEntraAppAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(true); + + var executor = MakeExecutor(logger, tooling, graph); + + var apps = new PublishCommandExecutor.EntraAppSet( + PublicClientsClientId: "pc-client-id", + PublicClientsObjectId: "pc-object-id", + PublicClientsAppName: $"{ServerName}-PublicClients", + A365AppClientId: "proxy-client-id", + A365AppSecret: "proxy-secret", + A365AppObjectId: "proxy-object-id", + A365AppName: $"{ServerName}-A365Proxy"); + + await executor.RollbackEntraAppsAsync(apps, TenantId, CancellationToken.None); + + await graph.Received(1).DeleteEntraAppAsync(TenantId, "pc-object-id", Arg.Any()); + await graph.Received(1).DeleteEntraAppAsync(TenantId, "proxy-object-id", Arg.Any()); + } + + /// + /// Overrides only the tenant-detection seam (which shells out to Azure CLI) so the rest of the + /// executor runs unchanged against the substituted Graph and tooling services. + /// + private sealed class TestablePublishCommandExecutor : PublishCommandExecutor + { + private readonly string _tenantId; + + public TestablePublishCommandExecutor( + ILogger logger, IAgent365ToolingService toolingService, GraphApiService graphApiService, + RetryHelper retryHelper, string tenantId) + : base(logger, toolingService, graphApiService, retryHelper) + { + _tenantId = tenantId; + } + + protected override Task DetectTenantIdAsync() => Task.FromResult(_tenantId); + } +} diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ToolingServicePureFunctionTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ToolingServicePureFunctionTests.cs index 86539da0..33ba7c99 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ToolingServicePureFunctionTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ToolingServicePureFunctionTests.cs @@ -119,6 +119,19 @@ public void RedactSecretsFromPayload_RedactsClientSecret() result.Should().Contain("myid"); } + [Fact] + public void RedactSecretsFromPayload_RedactsA365ProxyClientSecret() + { + // The publish request serializes the newly created A365 proxy Entra app secret as + // a365ProxyClientSecret; it must be redacted so verbose request logging never writes the + // live client secret in plaintext. + var payload = """{"a365ProxyClientId":"proxy-id","a365ProxyClientSecret":"proxysecret"}"""; + var result = Agent365ToolingService.RedactSecretsFromPayload(payload); + result.Should().NotContain("proxysecret", because: "the A365 proxy client secret must never be logged in plaintext"); + result.Should().Contain("***REDACTED***"); + result.Should().Contain("proxy-id", because: "the non-secret proxy client id is safe to log and aids diagnostics"); + } + [Fact] public void RedactSecretsFromPayload_PreservesNonSecretFields() {