diff --git a/.claude/commands b/.claude/commands index 7f2a13f8..b5ec936c 160000 --- a/.claude/commands +++ b/.claude/commands @@ -1 +1 @@ -Subproject commit 7f2a13f8403490e0417baf3e7497122f35d1bf58 +Subproject commit b5ec936c2286266be0a4fb6f554f1b217e28af0b diff --git a/internal b/internal index 13af4805..d07d299f 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 13af4805385efbc72c4318ad69b48eded18de07b +Subproject commit d07d299f36f9841810a394f742fd58ec0fad94e1 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs index 43876e21..2f9d5408 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs @@ -107,7 +107,8 @@ public class AuthFunctionSideEffectTests Mock.Of>()), new Mock( _metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of>()) { CallBase = false }.Object, - _signalR); + _signalR, + adminConfigService.Object); // Default: all fire-and-forget calls succeed _tenantConfigMock diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs index 2812d77b..2c2dbbce 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/InfrastructureWireParityTests.cs @@ -49,6 +49,7 @@ public class InfrastructureWireParityTests homedApp = "primary", bootstrapTokenEnabled = true, unrestrictedModeEnabled = false, + mcpClientRegistrationEnabled = true, whatsNewSeenPlatformUtc = platformSeen, whatsNewSeenAgentUtc = agentSeen }, @@ -69,6 +70,7 @@ public class InfrastructureWireParityTests HomedApp = "primary", BootstrapTokenEnabled = true, UnrestrictedModeEnabled = false, + McpClientRegistrationEnabled = true, WhatsNewSeenPlatformUtc = platformSeen, WhatsNewSeenAgentUtc = agentSeen }); @@ -105,6 +107,7 @@ public class InfrastructureWireParityTests homedApp = "legacy", bootstrapTokenEnabled = false, unrestrictedModeEnabled = false, + mcpClientRegistrationEnabled = false, whatsNewSeenPlatformUtc, whatsNewSeenAgentUtc }, @@ -125,6 +128,7 @@ public class InfrastructureWireParityTests HomedApp = "legacy", BootstrapTokenEnabled = false, UnrestrictedModeEnabled = false, + McpClientRegistrationEnabled = false, WhatsNewSeenPlatformUtc = null, WhatsNewSeenAgentUtc = null }); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/McpClientRegistrationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/McpClientRegistrationTests.cs new file mode 100644 index 00000000..0c7223d0 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/McpClientRegistrationTests.cs @@ -0,0 +1,245 @@ +using System.Net; +using AutopilotMonitor.Functions.DataAccess.TableStorage; +using AutopilotMonitor.Functions.Functions.Admin; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Self-hosted MCP client registrations: storage round trip, wire parity, callback validation (the MCP proxy +/// redirects authorization codes to the stored URL), the per-tenant cap, the operator switch (create refused, +/// lookup 404, delete still possible) and the audit trail. +/// +public class McpClientRegistrationTests +{ + private const string TenantA = "11111111-1111-1111-1111-111111111111"; + private const string TenantB = "22222222-2222-2222-2222-222222222222"; + private const string Callback = "https://chat.contoso.example/api/mcp/autopilot-monitor/oauth/callback"; + private static readonly DateTime Now = new(2026, 9, 24, 12, 0, 0, DateTimeKind.Utc); + + // ── Storage and wire ──────────────────────────────────────────────────────── + + [Fact] + public void Roundtrip_FullRow_SurvivesBuildAndMap() + { + var row = new McpClientRegistration + { + RegistrationId = "0123456789abcdef0123456789abcdef", + TenantId = TenantA.ToUpperInvariant(), + Name = "Team chat", + RedirectUri = Callback, + CreatedBy = "admin@contoso.example", + CreatedAt = Now, + }; + + var entity = TableMcpClientRegistrationRepository.Build(row); + var back = TableMcpClientRegistrationRepository.Map(entity); + + Assert.Equal(TableMcpClientRegistrationRepository.PartitionKey, entity.PartitionKey); + Assert.Equal(row.RegistrationId, entity.RowKey); + Assert.Equal(row.RegistrationId, back.RegistrationId); + Assert.Equal(TenantA, back.TenantId); // lowercase: the offboarding wipe matches it + Assert.Equal(row.Name, back.Name); + Assert.Equal(row.RedirectUri, back.RedirectUri); + Assert.Equal(row.CreatedBy, back.CreatedBy); + Assert.Equal(Now, back.CreatedAt); + } + + [Fact] + public void TenantFilter_IsPartitionPlusLowercaseTenant() + => Assert.Equal($"PartitionKey eq 'registrations' and TenantId eq '{TenantA}'", + TableMcpClientRegistrationRepository.BuildTenantFilter(TenantA.ToUpperInvariant())); + + [Fact] + public void ListResponse_WireShape() + { + var item = McpClientRegistrationFunction.ToItem(new McpClientRegistration + { + RegistrationId = "abc", TenantId = TenantA, Name = "Team chat", RedirectUri = Callback, + CreatedBy = "admin@contoso.example", CreatedAt = Now, + }); + ApiResponseWireParityTests.AssertWireIdentical( + new + { + enabled = true, + maxRegistrations = 3, + serverUrl = "https://mcp.autopilotmonitor.com/mcp", + registrations = new[] + { + new { registrationId = "abc", clientId = "amc_abc", name = "Team chat", redirectUri = Callback, createdBy = "admin@contoso.example", createdUtc = Now }, + }, + }, + new McpClientRegistrationListResponse + { + Enabled = true, MaxRegistrations = 3, ServerUrl = "https://mcp.autopilotmonitor.com/mcp", + Registrations = new[] { item }, + }); + } + + [Fact] + public void CreateAndLookupResponses_WireShape() + { + var item = new McpClientRegistrationItem + { + RegistrationId = "abc", ClientId = "amc_abc", Name = "Team chat", RedirectUri = Callback, + CreatedBy = "admin@contoso.example", CreatedUtc = Now, + }; + ApiResponseWireParityTests.AssertWireIdentical( + new { registration = new { registrationId = "abc", clientId = "amc_abc", name = "Team chat", redirectUri = Callback, createdBy = "admin@contoso.example", createdUtc = Now } }, + new CreateMcpClientRegistrationResponse { Registration = item }); + ApiResponseWireParityTests.AssertWireIdentical( + new { registrationId = "abc", tenantId = TenantA, redirectUri = Callback, name = "Team chat" }, + new McpClientRegistrationLookupResponse { RegistrationId = "abc", TenantId = TenantA, RedirectUri = Callback, Name = "Team chat" }); + } + + // ── Validation ────────────────────────────────────────────────────────────── + + [Theory] + [InlineData(Callback)] + [InlineData("http://localhost:3080/api/mcp/autopilot-monitor/oauth/callback")] + [InlineData("http://127.0.0.1:3080/cb")] + [InlineData("https://chat.contoso.example:8443/cb/")] + public void AcceptableCallbacks(string uri) => Assert.Null(McpClientRegistrationService.ValidateRedirectUri(uri)); + + [Theory] + [InlineData("")] + [InlineData("chat.contoso.example/cb")] + [InlineData("http://chat.contoso.example/cb")] // http off loopback + [InlineData("https://chat.contoso.example/cb?x=1")] // query + [InlineData("https://chat.contoso.example/cb#frag")] // fragment + [InlineData("https://*.contoso.example/cb")] // wildcard + [InlineData("https://user:pw@chat.contoso.example/cb")] + [InlineData("javascript:alert(1)")] + [InlineData("ftp://chat.contoso.example/cb")] + public void RejectedCallbacks(string uri) => Assert.NotNull(McpClientRegistrationService.ValidateRedirectUri(uri)); + + [Fact] + public void OverlongCallback_IsRejected() + => Assert.NotNull(McpClientRegistrationService.ValidateRedirectUri("https://chat.contoso.example/" + new string('a', 1100))); + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("tab\there")] + public void RejectedNames(string name) => Assert.NotNull(McpClientRegistrationService.ValidateName(name)); + + [Fact] + public void OverlongName_IsRejected() => Assert.NotNull(McpClientRegistrationService.ValidateName(new string('n', 65))); + + // ── Service: switch, cap, audit, tenant isolation ─────────────────────────── + + private sealed class Harness + { + public required McpClientRegistrationService Sut { get; init; } + public required Mock Repo { get; init; } + public required Mock Audit { get; init; } + } + + private static Harness Build(bool enabled = true) + { + var rows = new List(); + var repo = new Mock(); + repo.Setup(r => r.GetForTenantAsync(It.IsAny())) + .ReturnsAsync((string t) => rows.Where(r => r.TenantId == t.ToLowerInvariant()).ToList()); + repo.Setup(r => r.GetAsync(It.IsAny())).ReturnsAsync((string id) => rows.FirstOrDefault(r => r.RegistrationId == id)); + repo.Setup(r => r.CreateAsync(It.IsAny())).Callback(rows.Add).ReturnsAsync(true); + repo.Setup(r => r.DeleteAsync(It.IsAny())).ReturnsAsync((string id) => rows.RemoveAll(r => r.RegistrationId == id) > 0); + var audit = new Mock(); + audit.Setup(a => a.LogAuditEntryAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny?>())) + .ReturnsAsync(true); + var cache = new MemoryCache(new MemoryCacheOptions()); + var adminConfig = new Mock(Mock.Of(), NullLogger.Instance, cache) { CallBase = false }; + adminConfig.Setup(x => x.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { McpClientRegistrationEnabled = enabled }); + var sut = new McpClientRegistrationService(repo.Object, audit.Object, adminConfig.Object, NullLogger.Instance); + return new Harness { Sut = sut, Repo = repo, Audit = audit }; + } + + [Fact] + public async Task Create_StoresARegistration_WithARandomHexId_AndAudits() + { + var h = Build(); + + var result = await h.Sut.CreateAsync(TenantA.ToUpperInvariant(), " Team chat ", Callback, "admin@contoso.example"); + + Assert.Equal(HttpStatusCode.Created, result.Status); + var reg = result.Registration!; + Assert.True(McpClientRegistrationService.IsRegistrationId(reg.RegistrationId)); + Assert.Equal(TenantA, reg.TenantId); + Assert.Equal("Team chat", reg.Name); + Assert.Equal($"amc_{reg.RegistrationId}", McpClientRegistrationService.ClientIdOf(reg.RegistrationId)); + h.Audit.Verify(a => a.LogAuditEntryAsync(TenantA, "CREATE", "McpClientRegistration", reg.RegistrationId, "admin@contoso.example", + It.Is?>(d => d != null && d["RedirectUri"] == Callback)), Times.Once); + } + + [Fact] + public async Task Create_IsRefused_WhileTheSwitchIsOff() + { + var h = Build(enabled: false); + + var result = await h.Sut.CreateAsync(TenantA, "Team chat", Callback, "admin@contoso.example"); + + Assert.Equal(HttpStatusCode.Forbidden, result.Status); + h.Repo.Verify(r => r.CreateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Create_EnforcesTheCap_AndRefusesADuplicateCallback() + { + var h = Build(); + for (var i = 0; i < 3; i++) + Assert.Equal(HttpStatusCode.Created, (await h.Sut.CreateAsync(TenantA, $"chat {i}", $"https://chat{i}.contoso.example/cb", "a")).Status); + + var fourth = await h.Sut.CreateAsync(TenantA, "chat 4", "https://chat4.contoso.example/cb", "a"); + var otherTenant = await h.Sut.CreateAsync(TenantB, "chat", "https://chat0.contoso.example/cb", "b"); + + Assert.Equal(HttpStatusCode.Conflict, fourth.Status); + Assert.Equal(HttpStatusCode.Created, otherTenant.Status); // the cap and the duplicate check are per tenant + + var dup = Build(); + await dup.Sut.CreateAsync(TenantA, "one", Callback, "a"); + Assert.Equal(HttpStatusCode.Conflict, (await dup.Sut.CreateAsync(TenantA, "two", Callback.ToUpperInvariant(), "a")).Status); + } + + [Fact] + public async Task Create_ValidatesBeforeStoring() + { + var h = Build(); + Assert.Equal(HttpStatusCode.BadRequest, (await h.Sut.CreateAsync(TenantA, "x", "http://chat.contoso.example/cb", "a")).Status); + Assert.Equal(HttpStatusCode.BadRequest, (await h.Sut.CreateAsync(TenantA, "", Callback, "a")).Status); + h.Repo.Verify(r => r.CreateAsync(It.IsAny()), Times.Never); + } + + [Fact] + public async Task Delete_OnlyTheOwnTenantsRegistration_AlsoWhileTheSwitchIsOff() + { + var h = Build(); + var reg = (await h.Sut.CreateAsync(TenantA, "Team chat", Callback, "a")).Registration!; + + Assert.False(await h.Sut.DeleteAsync(TenantB, reg.RegistrationId, "b")); + Assert.False(await h.Sut.DeleteAsync(TenantA, "not-an-id", "a")); + Assert.True(await h.Sut.DeleteAsync(TenantA.ToUpperInvariant(), reg.RegistrationId, "a")); + h.Audit.Verify(a => a.LogAuditEntryAsync(TenantA, "DELETE", "McpClientRegistration", reg.RegistrationId, "a", It.IsAny?>()), Times.Once); + } + + [Fact] + public async Task Lookup_AnswersOnlyWhileEnabled_AndOnlyForWellFormedIds() + { + var on = Build(); + var reg = (await on.Sut.CreateAsync(TenantA, "Team chat", Callback, "a")).Registration!; + + Assert.NotNull(await on.Sut.LookupAsync(reg.RegistrationId)); + Assert.Null(await on.Sut.LookupAsync(reg.RegistrationId.ToUpperInvariant())); + Assert.Null(await on.Sut.LookupAsync("../../etc")); + + var off = Build(enabled: false); + off.Repo.Setup(r => r.GetAsync(reg.RegistrationId)).ReturnsAsync(reg); + Assert.Null(await off.Sut.LookupAsync(reg.RegistrationId)); + off.Repo.Verify(r => r.GetAsync(It.IsAny()), Times.Never); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs index c099b97c..d2b0791a 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs @@ -43,6 +43,7 @@ namespace AutopilotMonitor.Functions.DataAccess services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index 5092f407..f7b7dab7 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -951,6 +951,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage // MCP access control { "McpAccessPolicy", config.McpAccessPolicy ?? "WhitelistOnly" }, { "EnforceClientAppBinding", config.EnforceClientAppBinding }, + { "McpClientRegistrationEnabled", config.McpClientRegistrationEnabled }, // Agent endpoint migration (config-channel re-home) { "AgentMigrateApiBaseUrl", config.AgentMigrateApiBaseUrl ?? string.Empty }, { "AgentMigrateTenantOverridesJson", config.AgentMigrateTenantOverridesJson ?? string.Empty } @@ -1021,6 +1022,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage // MCP access control McpAccessPolicy = entity.GetString("McpAccessPolicy") ?? "WhitelistOnly", EnforceClientAppBinding = entity.GetBoolean("EnforceClientAppBinding") ?? false, + McpClientRegistrationEnabled = entity.GetBoolean("McpClientRegistrationEnabled") ?? false, // Agent endpoint migration (config-channel re-home) AgentMigrateApiBaseUrl = entity.GetString("AgentMigrateApiBaseUrl") ?? string.Empty, AgentMigrateTenantOverridesJson = entity.GetString("AgentMigrateTenantOverridesJson") ?? string.Empty diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMcpClientRegistrationRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMcpClientRegistrationRepository.cs new file mode 100644 index 00000000..6bed03aa --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMcpClientRegistrationRepository.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using Azure; +using Azure.Data.Tables; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.DataAccess.TableStorage +{ + /// + /// Table-storage implementation of . + /// PK = for every tenant, RK = registration id; the tenant view is a + /// property filter, the MCP server's lookup a point read. + /// + public class TableMcpClientRegistrationRepository : IMcpClientRegistrationRepository + { + internal const string PartitionKey = "registrations"; + + private readonly TableClient _table; + private readonly ILogger _logger; + + public TableMcpClientRegistrationRepository(TableStorageService storage, ILogger logger) + { + _table = storage.GetTableClient(Constants.TableNames.McpClientRegistrations); + _logger = logger; + } + + public async Task CreateAsync(McpClientRegistration registration) + { + try + { + await _table.AddEntityAsync(Build(registration)); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to store MCP client registration {RegistrationId}", registration.RegistrationId); + return false; + } + } + + public async Task GetAsync(string registrationId) + { + try + { + var response = await _table.GetEntityIfExistsAsync(PartitionKey, registrationId); + return response.HasValue && response.Value != null ? Map(response.Value) : null; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + } + + public async Task> GetForTenantAsync(string tenantId) + { + var results = new List(); + try + { + await foreach (var entity in _table.QueryAsync(filter: BuildTenantFilter(tenantId))) + results.Add(Map(entity)); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + _logger.LogDebug("McpClientRegistrations table does not exist yet, returning empty list"); + } + return results; + } + + public async Task DeleteAsync(string registrationId) + { + try + { + await _table.DeleteEntityAsync(PartitionKey, registrationId, ETag.All); + return true; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return false; + } + } + + internal static string BuildTenantFilter(string tenantId) + => $"PartitionKey eq '{PartitionKey}' and TenantId eq '{tenantId.ToLowerInvariant().Replace("'", "''")}'"; + + /// Model → entity (every field in both directions; the offboarding wipe matches the lowercase TenantId). + internal static TableEntity Build(McpClientRegistration r) => new(PartitionKey, r.RegistrationId) + { + ["RegistrationId"] = r.RegistrationId, + ["TenantId"] = r.TenantId.ToLowerInvariant(), + ["Name"] = r.Name, + ["RedirectUri"] = r.RedirectUri, + ["CreatedBy"] = r.CreatedBy, + ["CreatedDate"] = new DateTimeOffset(DateTime.SpecifyKind(r.CreatedAt, DateTimeKind.Utc)), + }; + + internal static McpClientRegistration Map(TableEntity e) => new() + { + RegistrationId = e.GetString("RegistrationId") ?? e.RowKey, + TenantId = e.GetString("TenantId") ?? string.Empty, + Name = e.GetString("Name") ?? string.Empty, + RedirectUri = e.GetString("RedirectUri") ?? string.Empty, + CreatedBy = e.GetString("CreatedBy") ?? string.Empty, + CreatedAt = e.GetDateTimeOffset("CreatedDate")?.UtcDateTime ?? DateTime.MinValue, + }; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/McpClientRegistrationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/McpClientRegistrationFunction.cs new file mode 100644 index 00000000..bfffeff3 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/McpClientRegistrationFunction.cs @@ -0,0 +1,144 @@ +using System.Net; +using AutopilotMonitor.Functions.Helpers; +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Functions.Admin; + +/// +/// Self-hosted MCP client registrations: Tenant Admins (or a Global Admin) manage the exact callbacks of +/// clients their organization runs itself; the MCP server's OAuth proxy resolves a client id +/// amc_<id> through the anonymous lookup. The lookup sits under /api/auth because the +/// Function App requires client certificates outside a fixed list of path prefixes. +/// +public class McpClientRegistrationFunction +{ + /// Per source IP and minute. Every lookup comes from the MCP server's egress IP, which caches answers for a minute. + internal const int LookupRateLimitPerMinute = 120; + + private readonly ILogger _logger; + private readonly McpClientRegistrationService _service; + private readonly RateLimitService _rateLimitService; + + public McpClientRegistrationFunction( + ILogger logger, + McpClientRegistrationService service, + RateLimitService rateLimitService) + { + _logger = logger; + _service = service; + _rateLimitService = rateLimitService; + } + + /// GET /api/tenants/{tenantId}/mcp-client-registrations + [Function("GetMcpClientRegistrations")] + [Authorize] + public async Task List( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "tenants/{tenantId}/mcp-client-registrations")] HttpRequestData req, + string tenantId, + FunctionContext context) + { + var requestCtx = context.GetRequestContext(); + var registrations = await _service.ListAsync(requestCtx.TargetTenantId); + return await req.OkAsync(new McpClientRegistrationListResponse + { + Enabled = await _service.IsEnabledAsync(), + MaxRegistrations = McpClientRegistrationService.MaxRegistrationsPerTenant, + ServerUrl = $"{Constants.McpServerBaseUrl}/mcp", + Registrations = registrations.Select(ToItem).ToList(), + }); + } + + /// POST /api/tenants/{tenantId}/mcp-client-registrations + [Function("CreateMcpClientRegistration")] + [Authorize] + public async Task Create( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "tenants/{tenantId}/mcp-client-registrations")] HttpRequestData req, + string tenantId, + FunctionContext context) + { + var requestCtx = context.GetRequestContext(); + var read = await req.ReadAsync(); + if (read.Error != null) return read.Error; + + var result = await _service.CreateAsync( + requestCtx.TargetTenantId, read.Value!.Name, read.Value.RedirectUri, requestCtx.UserPrincipalName); + if (result.Registration == null) + { + var code = result.Status switch + { + HttpStatusCode.Forbidden => Constants.ApiErrorCodes.Forbidden, + HttpStatusCode.Conflict => Constants.ApiErrorCodes.Conflict, + HttpStatusCode.InternalServerError => Constants.ApiErrorCodes.InternalError, + _ => Constants.ApiErrorCodes.BadRequest, + }; + return await req.ErrorAsync(result.Status, code, result.Error!); + } + return await req.CreatedAsync(new CreateMcpClientRegistrationResponse { Registration = ToItem(result.Registration) }); + } + + /// DELETE /api/tenants/{tenantId}/mcp-client-registrations/{registrationId} + [Function("DeleteMcpClientRegistration")] + [Authorize] + public async Task Delete( + [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "tenants/{tenantId}/mcp-client-registrations/{registrationId}")] HttpRequestData req, + string tenantId, + string registrationId, + FunctionContext context) + { + var requestCtx = context.GetRequestContext(); + var deleted = await _service.DeleteAsync(requestCtx.TargetTenantId, registrationId, requestCtx.UserPrincipalName); + return deleted + ? await req.OkAsync(new MessageResponse { Message = "Registration deleted" }) + : await req.NotFoundAsync("Registration not found"); + } + + /// + /// GET /api/auth/mcp/client-registrations/{registrationId} — anonymous; the MCP server's OAuth proxy + /// resolves a tenant-bound client id before any user token exists. 404 for an unknown id and while the + /// operator switch is off; the id is 128 random bits, the answer names nothing secret. + /// + [Function("LookupMcpClientRegistration")] + public async Task Lookup( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "auth/mcp/client-registrations/{registrationId}")] HttpRequestData req, + string registrationId) + { + var clientIp = ClientIpExtractor.GetTrustedClientIp(req); + var rateLimit = _rateLimitService.CheckRateLimit($"mcp-client-lookup:{clientIp}", LookupRateLimitPerMinute); + if (!rateLimit.IsAllowed) + { + return await req.ErrorAsync(HttpStatusCode.TooManyRequests, Constants.ApiErrorCodes.RateLimited, + "Rate limit exceeded. Try again later.", + retryAfterSeconds: rateLimit.RetryAfter is { } retryAfter ? (int)retryAfter.TotalSeconds : null); + } + + var registration = await _service.LookupAsync(registrationId); + if (registration == null) + return await req.NotFoundAsync("Registration not found"); + + return await req.OkAsync(new McpClientRegistrationLookupResponse + { + RegistrationId = registration.RegistrationId, + TenantId = registration.TenantId, + RedirectUri = registration.RedirectUri, + Name = registration.Name, + }); + } + + internal static McpClientRegistrationItem ToItem(McpClientRegistration r) => new() + { + RegistrationId = r.RegistrationId, + ClientId = McpClientRegistrationService.ClientIdOf(r.RegistrationId), + Name = r.Name, + RedirectUri = r.RedirectUri, + CreatedBy = r.CreatedBy, + CreatedUtc = r.CreatedAt, + }; +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs index aa07bd63..00462a72 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs @@ -35,6 +35,7 @@ public class AuthFunction private readonly EntraAppRegistry _appRegistry; private readonly AdminIdentityResolver _identityResolver; private readonly ISignalRNotificationService _signalRService; + private readonly AdminConfigurationService _adminConfigService; public AuthFunction( ILogger logger, @@ -50,9 +51,11 @@ public class AuthFunction Services.Activation.ITenantAutoApproveEnqueuer tenantAutoApproveEnqueuer, EntraAppRegistry appRegistry, AdminIdentityResolver identityResolver, - ISignalRNotificationService signalRService) + ISignalRNotificationService signalRService, + AdminConfigurationService adminConfigService) { _logger = logger; + _adminConfigService = adminConfigService; _identityResolver = identityResolver; _signalRService = signalRService; _globalAdminService = globalAdminService; @@ -169,7 +172,8 @@ public class AuthFunction delegatedTenantIds, homedApp: _appRegistry.ResolveForTenant(tenantConfig).IsLegacy ? "legacy" : "primary", whatsNewSeenPlatformUtc: whatsNewSeen.PlatformUtc, - whatsNewSeenAgentUtc: whatsNewSeen.AgentUtc); + whatsNewSeenAgentUtc: whatsNewSeen.AgentUtc, + mcpClientRegistrationEnabled: (await _adminConfigService.GetConfigurationAsync())?.McpClientRegistrationEnabled ?? false); if (!decision.IsSuccess) { @@ -555,7 +559,8 @@ public class AuthFunction IReadOnlyCollection? delegatedTenantIds = null, string homedApp = "primary", DateTime? whatsNewSeenPlatformUtc = null, - DateTime? whatsNewSeenAgentUtc = null) + DateTime? whatsNewSeenAgentUtc = null, + bool mcpClientRegistrationEnabled = false) { // A delegated ("MSP") admin manages a subset of OTHER tenants. They are explicitly authorized, so — // like a Global Admin / Reader — they bypass the private-preview gate even when their own home tenant @@ -644,6 +649,7 @@ public class AuthFunction UnrestrictedModeEnabled = FeatureEntitlementCatalog.Get(TenantEntitlementService.Resolve(tenantConfig, DateTime.UtcNow)).UnrestrictedModeAvailable && tenantConfig.UnrestrictedModeEnabled, + McpClientRegistrationEnabled = mcpClientRegistrationEnabled, WhatsNewSeenPlatformUtc = whatsNewSeenPlatformUtc, WhatsNewSeenAgentUtc = whatsNewSeenAgentUtc }, needsAutoAdmin); diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 9d565bd1..489aed13 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -270,6 +270,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Shared activation path (whitelist add + auto-promote + welcome email) — used by the // Global Admin approve endpoint and the tenant auto-approve queue worker. diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index e4ddf6af..1d0c346e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -377,6 +377,10 @@ public static class EndpointAccessPolicyCatalog new("GET", "tenants/{tenantId}/admins", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam), new("POST", "tenants/{tenantId}/admins", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam), new("DELETE", "tenants/{tenantId}/admins/{adminUpn}", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam), + // Self-hosted MCP client registrations (the OAuth callback a tenant's own client uses). + new("GET", "tenants/{tenantId}/mcp-client-registrations", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam), + new("POST", "tenants/{tenantId}/mcp-client-registrations", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam), + new("DELETE", "tenants/{tenantId}/mcp-client-registrations/{registrationId}", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam), new("PATCH", "tenants/{tenantId}/admins/{adminUpn}/disable", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam), new("PATCH", "tenants/{tenantId}/admins/{adminUpn}/enable", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam), new("PATCH", "tenants/{tenantId}/admins/{adminUpn}/permissions", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam), @@ -415,6 +419,9 @@ public static class EndpointAccessPolicyCatalog // The MCP front door: the only roleless-tier route an application principal may reach — the // handler resolves membership / delegation itself (McpUserService.IsAllowedAsync). new("GET", "auth/mcp", EndpointPolicy.AuthenticatedUser, applicationAllowed: true), + // The MCP server's OAuth proxy resolves a tenant-bound client id before any user token exists: + // anonymous, per-IP rate-limited in the function, 404 while the operator switch is off. + new("GET", "auth/mcp/client-registrations/{registrationId}", EndpointPolicy.PublicAnonymous), // ── MCP Usage (self-service) ────────────────────────────────── new("GET", "metrics/mcp-usage/me", EndpointPolicy.AuthenticatedUser), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/McpClientRegistrationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/McpClientRegistrationService.cs new file mode 100644 index 00000000..3623c460 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/McpClientRegistrationService.cs @@ -0,0 +1,155 @@ +using System.Net; +using System.Text.RegularExpressions; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.Services; + +/// +/// Self-hosted MCP client registrations (see ): validation, +/// the per-tenant cap, the operator switch and the audit trail. The MCP server's OAuth proxy resolves a +/// client id amc_<id> through and binds the flow to the registering tenant. +/// +public class McpClientRegistrationService +{ + public const int MaxRegistrationsPerTenant = 3; + public const int MaxNameLength = 64; + /// Mirrors the MCP proxy's redirect_uri length limit (oauth-limits.ts). + public const int MaxRedirectUriLength = 1024; + public const string ClientIdPrefix = "amc_"; + private const string AuditEntity = "McpClientRegistration"; + + private static readonly Regex RegistrationIdPattern = new("^[0-9a-f]{32}$", RegexOptions.CultureInvariant); + + private readonly IMcpClientRegistrationRepository _repo; + private readonly IMaintenanceRepository _maintenanceRepo; + private readonly AdminConfigurationService _adminConfigService; + private readonly ILogger _logger; + + public McpClientRegistrationService( + IMcpClientRegistrationRepository repo, + IMaintenanceRepository maintenanceRepo, + AdminConfigurationService adminConfigService, + ILogger logger) + { + _repo = repo; + _maintenanceRepo = maintenanceRepo; + _adminConfigService = adminConfigService; + _logger = logger; + } + + /// The operator switch (5-minute cached admin configuration). + public virtual async Task IsEnabledAsync() + => (await _adminConfigService.GetConfigurationAsync()).McpClientRegistrationEnabled; + + public static string ClientIdOf(string registrationId) => ClientIdPrefix + registrationId; + + /// Null when the name is acceptable, else the reason. + public static string? ValidateName(string? name) + { + var trimmed = name?.Trim() ?? string.Empty; + if (trimmed.Length == 0) return "A name is required."; + if (trimmed.Length > MaxNameLength) return $"The name may have at most {MaxNameLength} characters."; + if (trimmed.Any(char.IsControl)) return "The name contains control characters."; + return null; + } + + /// + /// Null when the callback is acceptable, else the reason: an absolute https URL, or http on a loopback host, + /// with no query, fragment, user info or wildcard — the MCP proxy redirects authorization codes to it. + /// + public static string? ValidateRedirectUri(string? redirectUri) + { + var value = redirectUri?.Trim() ?? string.Empty; + if (value.Length == 0) return "A callback URL is required."; + if (value.Length > MaxRedirectUriLength) return $"The callback URL may have at most {MaxRedirectUriLength} characters."; + if (value.Contains('*')) return "The callback URL must be exact; wildcards are not allowed."; + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri)) return "The callback URL is not a valid absolute URL."; + var loopback = uri.Host is "localhost" or "127.0.0.1" or "[::1]"; + if (uri.Scheme != Uri.UriSchemeHttps && !(uri.Scheme == Uri.UriSchemeHttp && loopback)) + return "The callback URL must use https (http is allowed only for localhost)."; + if (!string.IsNullOrEmpty(uri.Query)) return "The callback URL must not contain a query."; + if (!string.IsNullOrEmpty(uri.Fragment) || value.Contains('#')) return "The callback URL must not contain a fragment."; + if (!string.IsNullOrEmpty(uri.UserInfo)) return "The callback URL must not contain user information."; + return null; + } + + public static bool IsRegistrationId(string? registrationId) + => registrationId != null && RegistrationIdPattern.IsMatch(registrationId); + + public virtual async Task> ListAsync(string tenantId) + => (await _repo.GetForTenantAsync(tenantId)).OrderBy(r => r.CreatedAt).ToList(); + + /// Creates a registration for ; the result names the HTTP status to answer. + public virtual async Task CreateAsync(string tenantId, string? name, string? redirectUri, string actor) + { + if (!await IsEnabledAsync()) + return McpClientRegistrationResult.Fail(HttpStatusCode.Forbidden, "Self-hosted MCP client registration is not enabled on this platform."); + + var nameError = ValidateName(name); + if (nameError != null) return McpClientRegistrationResult.Fail(HttpStatusCode.BadRequest, nameError); + var uriError = ValidateRedirectUri(redirectUri); + if (uriError != null) return McpClientRegistrationResult.Fail(HttpStatusCode.BadRequest, uriError); + + var tenant = tenantId.ToLowerInvariant(); + var callback = redirectUri!.Trim(); + var existing = await _repo.GetForTenantAsync(tenant); + if (existing.Count >= MaxRegistrationsPerTenant) + return McpClientRegistrationResult.Fail(HttpStatusCode.Conflict, $"A tenant can register at most {MaxRegistrationsPerTenant} self-hosted clients. Delete one first."); + if (existing.Any(r => string.Equals(r.RedirectUri, callback, StringComparison.OrdinalIgnoreCase))) + return McpClientRegistrationResult.Fail(HttpStatusCode.Conflict, "This callback URL is already registered."); + + var registration = new McpClientRegistration + { + RegistrationId = Guid.NewGuid().ToString("N"), + TenantId = tenant, + Name = name!.Trim(), + RedirectUri = callback, + CreatedBy = actor, + CreatedAt = DateTime.UtcNow, + }; + if (!await _repo.CreateAsync(registration)) + return McpClientRegistrationResult.Fail(HttpStatusCode.InternalServerError, "The registration could not be stored."); + + await _maintenanceRepo.LogAuditEntryAsync(tenant, "CREATE", AuditEntity, registration.RegistrationId, actor, + new Dictionary { { "Name", registration.Name }, { "RedirectUri", registration.RedirectUri } }); + _logger.LogWarning("MCP client registration {RegistrationId} created for tenant {TenantId} by {Actor}", + registration.RegistrationId, tenant, actor); + return McpClientRegistrationResult.Ok(registration); + } + + /// + /// Deletes one of 's registrations — also while the switch is off, so a tenant can + /// always clean up. False when the id is unknown or belongs to another tenant. + /// + public virtual async Task DeleteAsync(string tenantId, string registrationId, string actor) + { + if (!IsRegistrationId(registrationId)) return false; + var registration = await _repo.GetAsync(registrationId); + if (registration == null || !string.Equals(registration.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) + return false; + if (!await _repo.DeleteAsync(registrationId)) return false; + + await _maintenanceRepo.LogAuditEntryAsync(registration.TenantId, "DELETE", AuditEntity, registrationId, actor, + new Dictionary { { "Name", registration.Name }, { "RedirectUri", registration.RedirectUri } }); + _logger.LogWarning("MCP client registration {RegistrationId} deleted for tenant {TenantId} by {Actor}", + registrationId, registration.TenantId, actor); + return true; + } + + /// The MCP proxy's lookup: the registration, or null when unknown or while the switch is off. + public virtual async Task LookupAsync(string registrationId) + { + if (!IsRegistrationId(registrationId)) return null; + if (!await IsEnabledAsync()) return null; + return await _repo.GetAsync(registrationId); + } +} + +/// Outcome of . +public sealed record McpClientRegistrationResult(McpClientRegistration? Registration, HttpStatusCode Status, string? Error) +{ + public static McpClientRegistrationResult Ok(McpClientRegistration registration) => new(registration, HttpStatusCode.Created, null); + public static McpClientRegistrationResult Fail(HttpStatusCode status, string error) => new(null, status, error); +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs index db5581b6..a14d6de9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs @@ -142,6 +142,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding (Constants.TableNames.BootstrapSessions, "CodeLookup"), (Constants.TableNames.SessionReports, "reports"), (Constants.TableNames.RuleSubmissions, "submissions"), + (Constants.TableNames.McpClientRegistrations, "registrations"), }; // Plan §6.3.2 — Variant C (TenantId property only). diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/oauth-tenant-client.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/oauth-tenant-client.test.ts new file mode 100644 index 00000000..90ccc214 --- /dev/null +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/oauth-tenant-client.test.ts @@ -0,0 +1,213 @@ +/** + * Tenant-bound clients (portal registrations): a self-hosted client presents client_id + * `amc_`. The registration is its allowlist entry, the flow runs at the registering + * tenant's Entra authority, and the token endpoint discards a token for any other tenant. The backend + * lookup and Entra are stubbed; the router runs for real on an ephemeral server. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import express from 'express'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +process.env.AUTOPILOT_ENTRA_CLIENT_ID ??= '00000000-0000-0000-0000-000000000000'; +process.env.MCP_OAUTH_RATE_LIMIT_PER_MINUTE = '100000'; +process.env.MCP_OAUTH_TOKEN_RATE_LIMIT_PER_MINUTE = '100000'; +const { createOAuthRouter, signState, clearTenantClientCache, tenantClientRegistrationId } = await import('../oauth.js'); +const { API_BASE_URL } = await import('../config.js'); + +const REG_ID = '0123456789abcdef0123456789abcdef'; +const CLIENT_ID = `amc_${REG_ID}`; +const TENANT = '11111111-1111-1111-1111-111111111111'; +const OTHER_TENANT = '22222222-2222-2222-2222-222222222222'; +const CALLBACK = 'https://chat.contoso.example/api/mcp/autopilot-monitor/oauth/callback'; + +let server: Server; +let baseUrl: string; +const realFetch = globalThis.fetch; + +beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + app.use(createOAuthRouter()); + await new Promise((resolve) => { server = app.listen(0, '127.0.0.1', () => resolve()); }); + baseUrl = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); +}); + +afterEach(() => { + vi.restoreAllMocks(); + clearTenantClientCache(); +}); + +function jwt(claims: Record): string { + const enc = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url'); + return `${enc({ alg: 'RS256', typ: 'JWT' })}.${enc(claims)}.sig`; +} + +type Outbound = { url: string; body?: string }; + +/** + * Stubs the two outbound calls the proxy makes — the backend registration lookup and the Entra token + * endpoint — and lets requests to the local test server through. + */ +function stubOutbound(opts: { lookup: 'found' | 'missing' | 'error'; tokenTid?: string }) { + const outbound: Outbound[] = []; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (url.startsWith(baseUrl)) return realFetch(input, init); + outbound.push({ url, body: typeof init?.body === 'string' ? init.body : undefined }); + if (url.startsWith(`${API_BASE_URL}/api/auth/mcp/client-registrations/`)) { + if (opts.lookup === 'error') return new Response('boom', { status: 500 }); + if (opts.lookup === 'missing') return new Response(JSON.stringify({ error: 'not found', code: 'NotFound' }), { status: 404 }); + return new Response(JSON.stringify({ registrationId: REG_ID, tenantId: TENANT, redirectUri: CALLBACK, name: 'Team chat' }), { status: 200 }); + } + if (url.includes('/oauth2/v2.0/token')) { + return new Response(JSON.stringify({ + token_type: 'Bearer', expires_in: 3600, refresh_token: 'rt', + access_token: jwt({ tid: opts.tokenTid ?? TENANT, aud: 'api://x', upn: 'alice@contoso.example' }), + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + throw new Error(`unexpected outbound call ${url}`); + }); + return outbound; +} + +function authorizeUrl(clientId: string, redirectUri: string): string { + const q = new URLSearchParams({ + response_type: 'code', client_id: clientId, redirect_uri: redirectUri, state: 's1', + code_challenge: 'x'.repeat(43), code_challenge_method: 'S256', + }); + return `${baseUrl}/oauth/authorize?${q}`; +} + +async function token(form: Record) { + const res = await fetch(`${baseUrl}/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(form).toString(), + }); + return { status: res.status, json: (await res.json()) as Record }; +} + +describe('tenant client ids', () => { + it('recognises only amc_ plus 32 lowercase hex', () => { + expect(tenantClientRegistrationId(CLIENT_ID)).toBe(REG_ID); + expect(tenantClientRegistrationId(`amc_${REG_ID.toUpperCase()}`)).toBeNull(); + expect(tenantClientRegistrationId(`amc_${REG_ID}0`)).toBeNull(); + expect(tenantClientRegistrationId('eyJhbGciOi.sig')).toBeNull(); + expect(tenantClientRegistrationId(undefined)).toBeNull(); + }); +}); + +describe('/oauth/authorize with a tenant client', () => { + it('admits the registered callback although its host is on no allowlist, at the tenant authority', async () => { + const outbound = stubOutbound({ lookup: 'found' }); + const res = await fetch(authorizeUrl(CLIENT_ID, CALLBACK), { redirect: 'manual' }); + + expect(res.status).toBe(302); + const location = new URL(res.headers.get('location')!); + expect(location.pathname).toBe(`/${TENANT}/oauth2/v2.0/authorize`); + expect(location.pathname).not.toContain('organizations'); + expect(outbound.filter((o) => o.url.includes('/client-registrations/'))).toHaveLength(1); + }); + + it('refuses any other callback for the same client', async () => { + stubOutbound({ lookup: 'found' }); + const res = await fetch(authorizeUrl(CLIENT_ID, 'https://chat.contoso.example/elsewhere'), { redirect: 'manual' }); + expect(res.status).toBe(400); + }); + + it('refuses an unknown or disabled registration', async () => { + stubOutbound({ lookup: 'missing' }); + const res = await fetch(authorizeUrl(CLIENT_ID, CALLBACK), { redirect: 'manual' }); + expect(res.status).toBe(400); + expect(((await res.json()) as Record).error).toBe('invalid_client'); + }); + + it('fails closed with 503 when the registration cannot be checked', async () => { + stubOutbound({ lookup: 'error' }); + const res = await fetch(authorizeUrl(CLIENT_ID, CALLBACK), { redirect: 'manual' }); + expect(res.status).toBe(503); + }); + + it('caches the lookup, so a flow costs one backend call', async () => { + const outbound = stubOutbound({ lookup: 'found' }); + await fetch(authorizeUrl(CLIENT_ID, CALLBACK), { redirect: 'manual' }); + await fetch(authorizeUrl(CLIENT_ID, CALLBACK), { redirect: 'manual' }); + expect(outbound.filter((o) => o.url.includes('/client-registrations/'))).toHaveLength(1); + }); + + it('keeps the vendor allowlist for dynamically registered clients', async () => { + stubOutbound({ lookup: 'found' }); + const reg = await fetch(`${baseUrl}/oauth/register`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_name: 'x', redirect_uris: [CALLBACK] }), + }); + expect(reg.status).toBe(400); + }); +}); + +describe('/oauth/callback with a tenant client', () => { + it('returns the code to the registered callback', async () => { + stubOutbound({ lookup: 'found' }); + const state = signState({ originalState: 's1', redirectUri: CALLBACK, clientId: CLIENT_ID }); + const res = await fetch(`${baseUrl}/oauth/callback?code=c1&state=${encodeURIComponent(state)}`, { redirect: 'manual' }); + + expect(res.status).toBe(302); + const location = new URL(res.headers.get('location')!); + expect(`${location.origin}${location.pathname}`).toBe(CALLBACK); + expect(location.searchParams.get('code')).toBe('c1'); + }); + + it('stops the flow when the registration was deleted after authorize', async () => { + stubOutbound({ lookup: 'missing' }); + const state = signState({ originalState: 's1', redirectUri: CALLBACK, clientId: CLIENT_ID }); + const res = await fetch(`${baseUrl}/oauth/callback?code=c1&state=${encodeURIComponent(state)}`, { redirect: 'manual' }); + expect(res.status).toBe(400); + }); +}); + +describe('/oauth/token with a tenant client', () => { + it('exchanges at the tenant authority and returns a token of that tenant', async () => { + const outbound = stubOutbound({ lookup: 'found' }); + const r = await token({ grant_type: 'authorization_code', code: 'c1', code_verifier: 'v'.repeat(43), client_id: CLIENT_ID, redirect_uri: CALLBACK }); + + expect(r.status).toBe(200); + expect(typeof r.json.access_token).toBe('string'); + const entra = outbound.find((o) => o.url.includes('/oauth2/v2.0/token'))!; + expect(new URL(entra.url).pathname).toBe(`/${TENANT}/oauth2/v2.0/token`); + }); + + it('discards a token of another tenant', async () => { + stubOutbound({ lookup: 'found', tokenTid: OTHER_TENANT }); + const r = await token({ grant_type: 'refresh_token', refresh_token: 'rt', client_id: CLIENT_ID }); + + expect(r.status).toBe(400); + expect(r.json.error).toBe('invalid_grant'); + expect(r.json.access_token).toBeUndefined(); + expect(r.json.refresh_token).toBeUndefined(); + }); + + it('refuses a deleted registration before calling Entra', async () => { + const outbound = stubOutbound({ lookup: 'missing' }); + const r = await token({ grant_type: 'refresh_token', refresh_token: 'rt', client_id: CLIENT_ID }); + + expect(r.status).toBe(400); + expect(r.json.error).toBe('invalid_client'); + expect(outbound.some((o) => o.url.includes('/oauth2/v2.0/token'))).toBe(false); + }); + + it('leaves other clients on the organizations authority', async () => { + const outbound = stubOutbound({ lookup: 'found' }); + const r = await token({ grant_type: 'refresh_token', refresh_token: 'rt', client_id: 'some-dcr-client' }); + + expect(r.status).toBe(200); + const entra = outbound.find((o) => o.url.includes('/oauth2/v2.0/token'))!; + expect(new URL(entra.url).pathname).toBe('/organizations/oauth2/v2.0/token'); + expect(outbound.some((o) => o.url.includes('/client-registrations/'))).toBe(false); + }); +}); diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index f912bd6c..3c06a92b 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -142,6 +142,8 @@ export interface AdminConfiguration { mcpAccessPolicy: string; /** Client-app binding of delegated tokens. A user token obtained by an application other than the platform's own registrations (a self-hosted AI client, an in-house tool) is always measured. When true it is also enforced: the token is accepted only while that application is an enabled member of the caller's tenant, and the caller then has at most Viewer rights, no platform role and at most DelegatedReader on managed tenants. Default false: measure only. */ enforceClientAppBinding: boolean; + /** Self-hosted MCP client registrations. When true, Tenant Admins can register the exact OAuth callback of a client their organization runs itself and connect it with client id amc_; the MCP server binds that flow to the registering tenant. When false the portal section is hidden and every existing registration stops resolving (kill switch). Default false. */ + mcpClientRegistrationEnabled: boolean; /** NVD API key for higher rate limits (50 req/30s vs 5 req/30s without key). Free registration at https://nvd.nist.gov/developers/request-an-api-key null = operate without API key (slower, still functional). */ nvdApiKey: string; /** JSON-serialized list of OpsAlertRule objects defining which event types trigger notifications. Provider-agnostic — rules apply to all enabled providers. */ @@ -766,6 +768,8 @@ export interface AuthMeResponse { homedApp: string; bootstrapTokenEnabled: boolean; unrestrictedModeEnabled: boolean; + /** Whether Tenant Admins can register self-hosted MCP clients (platform switch); drives the settings section. */ + mcpClientRegistrationEnabled: boolean; /** When this user last viewed the Platform tab of What's new; null = never and the key is omitted (client treats as first visit). */ whatsNewSeenPlatformUtc?: string; /** Same for the Agent tab; null = never and the key is omitted. */ @@ -1154,6 +1158,19 @@ export interface CreateDelegationInvitationResponse { expiresUtc: string; } +/** Body of POST tenants/{tenantId}/mcp-client-registrations. */ +export interface CreateMcpClientRegistrationRequest { + /** Display name, 1-64 characters. */ + name: string; + /** The client's exact OAuth callback URL: https, or http on localhost / 127.0.0.1; no query, fragment or wildcard. */ + redirectUri: string; +} + +/** Response of POST tenants/{tenantId}/mcp-client-registrations: the stored registration. */ +export interface CreateMcpClientRegistrationResponse { + registration: McpClientRegistrationItem; +} + /** Body of POST global/tenant-groups. */ export interface CreateTenantGroupRequest { name: string; @@ -2812,6 +2829,36 @@ export interface ManagedTenantListResponse { tenants: ManagedTenantItem[]; } +/** One self-hosted MCP client registration as the portal lists it. */ +export interface McpClientRegistrationItem { + registrationId: string; + /** The OAuth client id the self-hosted client is configured with: amc_ + registration id. */ + clientId: string; + name: string; + redirectUri: string; + createdBy: string; + createdUtc: string; +} + +/** Response of GET tenants/{tenantId}/mcp-client-registrations. */ +export interface McpClientRegistrationListResponse { + /** Whether the platform currently accepts self-hosted client registrations (operator switch). */ + enabled: boolean; + /** How many registrations a tenant may hold. */ + maxRegistrations: number; + /** The MCP server URL the self-hosted client connects to. */ + serverUrl: string; + registrations: McpClientRegistrationItem[]; +} + +/** Response of the anonymous GET mcp/client-registrations/{registrationId} — the MCP server's OAuth proxy resolves a client id amc_ through it. Carries nothing secret: the tenant id and the callback the registration binds the flow to. */ +export interface McpClientRegistrationLookupResponse { + registrationId: string; + tenantId: string; + redirectUri: string; + name: string; +} + /** Response of GET health/mcp: the standalone MCP-server reachability probe. */ export interface McpHealthCheckResponse { timestamp: string; diff --git a/src/McpServer/autopilot-monitor-mcp/src/oauth.ts b/src/McpServer/autopilot-monitor-mcp/src/oauth.ts index d452579f..9d5d0b3c 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/oauth.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/oauth.ts @@ -23,7 +23,9 @@ import { Router } from 'express'; import crypto from 'node:crypto'; import { oauthRateLimit, oauthTokenRateLimit } from './access-guard.js'; -import { ENTRA_LOGIN_BASE_URL, getPublicBaseUrl } from './config.js'; +import { extractTokenClaims } from './auth.js'; +import { API_BASE_URL, ENTRA_LOGIN_BASE_URL, getPublicBaseUrl } from './config.js'; +import type { McpClientRegistrationLookupResponse } from './generated/wire-types.generated.js'; import { ClientMetadataError, isClientIdMetadataUrl, resolveClientMetadata } from './cimd.js'; import { MAX_CLIENT_NAME_LENGTH, MAX_REDIRECT_URIS_PER_CLIENT, MAX_REDIRECT_URI_LENGTH } from './oauth-limits.js'; @@ -518,7 +520,21 @@ export function redirectUriMatches(registered: string[], requested: string): boo async function resolveRegisteredClient( clientId: string, where: string, -): Promise<{ ok: true; redirectUris: string[]; name: string } | { ok: false; error: string }> { +): Promise<{ ok: true; redirectUris: string[]; name: string; tenantId?: string } | { ok: false; error: string }> { + const registrationId = tenantClientRegistrationId(clientId); + if (registrationId) { + const client = await lookupTenantClient(registrationId); + if (client === 'unavailable') { + console.error(`[oauth/${where}] tenant client ${registrationId}: registration lookup unavailable`); + return { ok: false, error: 'temporarily_unavailable' }; + } + if (!client) { + console.error(`[oauth/${where}] tenant client ${registrationId}: unknown, disabled or feature off`); + return { ok: false, error: 'invalid_client' }; + } + console.error(`[oauth/${where}] tenant client ${registrationId} (tid=${client.tenantId}, ${sanitizeForLog(client.name)})`); + return { ok: true, redirectUris: [client.redirectUri], name: client.name, tenantId: client.tenantId }; + } if (isClientIdMetadataUrl(clientId)) { try { const md = await resolveClientMetadata(clientId); @@ -541,6 +557,86 @@ async function resolveRegisteredClient( return { ok: true, redirectUris: registered.redirectUris, name: registered.name }; } +// ---- Tenant-bound clients (portal registrations) --------------------------- +// +// A Tenant Admin registers the exact callback of a self-hosted client (LibreChat and the like) in the +// portal and receives client_id `amc_`. The registration is that client's allowlist +// entry — the global vendor allowlist keeps governing dynamic registration — and it binds the flow to +// the registering tenant: authorize and token go to that tenant's Entra authority, and the token +// endpoint discards a token whose tid is not the registration's. Registrations live in the backend; +// this server reads one through an anonymous lookup by its unguessable id (no user token exists +// before the exchange) and caches the answer briefly, so a deletion or the operator switch takes +// effect within TENANT_CLIENT_CACHE_MS. + +const TENANT_CLIENT_ID = /^amc_([0-9a-f]{32})$/; +const TENANT_CLIENT_CACHE_MS = 60_000; +const TENANT_CLIENT_CACHE_MAX = 500; +const TENANT_CLIENT_LOOKUP_TIMEOUT_MS = 5_000; +const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +interface TenantClient { + tenantId: string; + redirectUri: string; + name: string; +} + +const tenantClientCache = new Map(); + +/** The registration id of a tenant-bound client_id, or null for every other client identity. */ +export function tenantClientRegistrationId(clientId: string | null | undefined): string | null { + const match = typeof clientId === 'string' ? TENANT_CLIENT_ID.exec(clientId) : null; + return match ? match[1] : null; +} + +/** Test seam: forget cached registrations. */ +export function clearTenantClientCache(): void { + tenantClientCache.clear(); +} + +/** + * The registration behind a tenant-bound client_id: the client, null when the backend answers 404 + * (unknown, disabled, or the feature switched off — cached like a hit), or 'unavailable' when the + * backend could not be asked (never cached, the caller fails closed). + */ +async function lookupTenantClient(registrationId: string): Promise { + const now = Date.now(); + const cached = tenantClientCache.get(registrationId); + if (cached && cached.expiresAt > now) return cached.value; + + let res: Response; + try { + res = await fetch(`${API_BASE_URL}/api/auth/mcp/client-registrations/${registrationId}`, { + signal: AbortSignal.timeout(TENANT_CLIENT_LOOKUP_TIMEOUT_MS), + }); + } catch { + return 'unavailable'; + } + + let value: TenantClient | null = null; + if (res.ok) { + const body = (await res.json().catch(() => null)) as McpClientRegistrationLookupResponse | null; + const tenantId = typeof body?.tenantId === 'string' ? body.tenantId.toLowerCase() : ''; + if (!body || !GUID.test(tenantId) || typeof body.redirectUri !== 'string' || body.registrationId !== registrationId) { + return 'unavailable'; + } + value = { tenantId, redirectUri: body.redirectUri, name: body.name || 'Self-hosted client' }; + } else if (res.status !== 404) { + return 'unavailable'; + } + + if (tenantClientCache.size >= TENANT_CLIENT_CACHE_MAX) { + const oldest = tenantClientCache.keys().next().value; + if (oldest !== undefined) tenantClientCache.delete(oldest); + } + tenantClientCache.set(registrationId, { value, expiresAt: now + TENANT_CLIENT_CACHE_MS }); + return value; +} + +/** The registering tenant's authority on the configured Entra host (sovereign clouds included). */ +function tenantAuthority(tenantId: string): string { + return AUTHORITY.replace(/\/[^/]+$/, `/${tenantId}`); +} + export function createOAuthRouter(): Router { const router = Router(); @@ -727,14 +823,6 @@ export function createOAuthRouter(): Router { res.status(400).json({ error: 'invalid_request', error_description: 'redirect_uri is required' }); return; } - if (!isAllowedRedirectUri(redirect_uri)) { - console.error(`[oauth/authorize] Rejected redirect_uri (not in allowlist): ${sanitizeForLog(redirect_uri)}`); - res.status(400).json({ - error: 'invalid_request', - error_description: 'redirect_uri host is not in the allowlist', - }); - return; - } // client_id is REQUIRED. Now that it is a stateless, replica-portable // signed token, there is no cold-start reason to tolerate its absence — // and tolerating it would let a caller bypass the per-client redirect_uri @@ -754,13 +842,26 @@ export function createOAuthRouter(): Router { // No registry lookup, correct across cold starts and replicas. A forged / // unfetchable / malformed client fails closed; a valid one must still carry // the exact redirect_uri. + // Every client except a tenant registration is held to the global host allowlist — checked before + // the client is resolved, so a hostile redirect never triggers a metadata-document fetch. A tenant + // registration IS the allowlist entry for its exact callback, matched below. + if (tenantClientRegistrationId(client_id) === null && !isAllowedRedirectUri(redirect_uri)) { + console.error(`[oauth/authorize] Rejected redirect_uri (not in allowlist): ${sanitizeForLog(redirect_uri)}`); + res.status(400).json({ + error: 'invalid_request', + error_description: 'redirect_uri host is not in the allowlist', + }); + return; + } const registered = await resolveRegisteredClient(client_id, 'authorize'); if (!registered.ok) { - res.status(400).json({ + res.status(registered.error === 'temporarily_unavailable' ? 503 : 400).json({ error: registered.error, error_description: registered.error === 'invalid_client' ? 'client_id is invalid — use a Client ID Metadata Document URL or register via /oauth/register first' - : 'client_id metadata document is malformed', + : registered.error === 'temporarily_unavailable' + ? 'client registration could not be checked — retry shortly' + : 'client_id metadata document is malformed', }); return; } @@ -808,7 +909,10 @@ export function createOAuthRouter(): Router { prompt: 'select_account', }); - res.redirect(`${AUTHORITY}/oauth2/v2.0/authorize?${entraParams}`); + // A tenant-bound client signs in at its tenant's authority: an account of another organization + // cannot complete the sign-in at all (guests are the tenant's own decision). + const authority = registered.tenantId ? tenantAuthority(registered.tenantId) : AUTHORITY; + res.redirect(`${authority}/oauth2/v2.0/authorize?${entraParams}`); }); // --- Callback: receive code from Entra ID, forward to Claude Code --- @@ -844,10 +948,15 @@ export function createOAuthRouter(): Router { const redirectUri = decoded.redirectUri; const clientId = decoded.clientId; - // Re-validate the redirectUri at callback time too. Belt-and-suspenders: - // /oauth/authorize already gated the URI, but the state value transits the - // user-agent — re-running the host allowlist here closes any replay gap. - if (!isAllowedRedirectUri(redirectUri)) { + // Stateless re-check of the client (signed token, metadata document or tenant registration — the + // latter two normally cache hits within the 10-min state window). The redirectUri is already pinned + // inside the HMAC-verified state, so this is defense-in-depth: a valid client must still list the + // redirectUri, and a registration deleted since authorize stops the flow here. + const registered = clientId ? await resolveRegisteredClient(clientId, 'callback') : null; + // Re-validate the redirectUri at callback time too: the state value transits the user-agent — + // re-running the host allowlist closes any replay gap. A tenant registration is its own allowlist. + const tenantBound = registered?.ok === true && !!registered.tenantId; + if (!tenantBound && !isAllowedRedirectUri(redirectUri)) { console.error(`[oauth/callback] Rejected redirectUri from state (not in allowlist): ${sanitizeForLog(redirectUri)}`); res.status(400).json({ error: 'invalid_request', @@ -855,13 +964,7 @@ export function createOAuthRouter(): Router { }); return; } - if (clientId) { - // Stateless re-check (signed token or metadata document, the latter - // normally a cache hit within the 10-min state window) — correct across - // cold starts/replicas. The redirectUri is already pinned inside the - // HMAC-verified state, so this only adds defense-in-depth: a valid - // client must still list the redirectUri. A forged client_id fails closed. - const registered = await resolveRegisteredClient(clientId, 'callback'); + if (registered) { if (!registered.ok || !redirectUriMatches(registered.redirectUris, redirectUri)) { console.error(`[oauth/callback] client_id invalid or redirectUri not registered: ${sanitizeForLog(redirectUri)}`); res.status(400).json({ @@ -935,6 +1038,24 @@ export function createOAuthRouter(): Router { return; } + // A tenant-bound client exchanges at its tenant's authority, and only while its registration exists. + const registrationId = tenantClientRegistrationId(params.client_id); + let boundTenantId: string | null = null; + if (registrationId) { + const client = await lookupTenantClient(registrationId); + if (client === 'unavailable') { + res.status(503).json({ error: 'temporarily_unavailable', error_description: 'client registration could not be checked — retry shortly' }); + return; + } + if (!client) { + console.error(`[oauth/token] tenant client ${registrationId}: unknown, disabled or feature off`); + res.status(400).json({ error: 'invalid_client', error_description: 'client registration is unknown or disabled' }); + return; + } + boundTenantId = client.tenantId; + } + const authority = boundTenantId ? tenantAuthority(boundTenantId) : AUTHORITY; + // Build form body for Entra ID token endpoint const body = new URLSearchParams(); @@ -950,7 +1071,7 @@ export function createOAuthRouter(): Router { body.set('scope', SCOPES); try { - const tokenResponse = await fetch(`${AUTHORITY}/oauth2/v2.0/token`, { + const tokenResponse = await fetch(`${authority}/oauth2/v2.0/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), @@ -974,6 +1095,17 @@ export function createOAuthRouter(): Router { `correlation_id=${sanitizeForLog(err.correlation_id)}`, ); } + if (tokenResponse.status === 200 && boundTenantId) { + // The authority already confines the sign-in; this is the second half of the binding. The token + // comes straight from Entra over TLS, so its claims can be read without a signature check here. + const accessToken = typeof data?.access_token === 'string' ? data.access_token : ''; + const tid = extractTokenClaims(accessToken)?.tid?.toLowerCase(); + if (tid !== boundTenantId) { + console.error(`[oauth/token] tenant client ${registrationId}: token tid=${sanitizeForLog(tid ?? '(none)')} is not the registration's tenant — discarded`); + res.status(400).json({ error: 'invalid_grant', error_description: 'the token was issued for another organization than this client registration' }); + return; + } + } res.status(tokenResponse.status).json(tokenResponse.status === 200 ? data : sanitizeTokenErrorBody(data)); } catch (err) { // AbortSignal.timeout() rejects with a DOMException named 'TimeoutError' diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 89de90ef..de953d1a 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -1342,6 +1342,11 @@ namespace AutopilotMonitor.Shared // partition stays small enough that a second key layout would cost more than it saves. public const string RuleSubmissions = "RuleSubmissions"; + // Self-hosted MCP client registrations (Tenant Admin, portal). PK = "registrations" for every + // tenant, RK = registration id (32 hex); the MCP proxy's anonymous lookup is a point read, the + // tenant list a TenantId property filter (at most three rows per tenant). + public const string McpClientRegistrations = "McpClientRegistrations"; + // Bootstrap sessions (OOBE pre-enrollment agent deployment) public const string BootstrapSessions = "BootstrapSessions"; @@ -1521,6 +1526,7 @@ namespace AutopilotMonitor.Shared BlockedVersions, SessionReports, RuleSubmissions, + McpClientRegistrations, BootstrapSessions, GlobalNotifications, TenantNotifications, diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMcpClientRegistrationRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMcpClientRegistrationRepository.cs new file mode 100644 index 00000000..2b487ae4 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMcpClientRegistrationRepository.cs @@ -0,0 +1,48 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Self-hosted MCP client registrations: a Tenant Admin registers the exact OAuth callback of a client + /// the organization runs itself (a self-hosted chat front end) and hands it client id + /// amc_<RegistrationId>. The MCP server's OAuth proxy treats the registration as that + /// client's redirect allowlist entry and binds the flow to . + /// One partition for every tenant (a registration is a rare, deliberate act), RK = registration id, so + /// the MCP server's anonymous lookup is a point read. + /// + public interface IMcpClientRegistrationRepository + { + /// Stores a new registration; false when storage refused it. + Task CreateAsync(McpClientRegistration registration); + + /// One registration by id, or null. + Task GetAsync(string registrationId); + + /// Every registration of one tenant. + Task> GetForTenantAsync(string tenantId); + + /// Deletes a registration; false when it did not exist. + Task DeleteAsync(string registrationId); + } + + /// One self-hosted MCP client registration. + public class McpClientRegistration + { + /// 32 lowercase hex characters; the client id is amc_ + this value. + public string RegistrationId { get; set; } = string.Empty; + + /// The registering tenant (lowercase GUID) — the only tenant the client can sign in to. + public string TenantId { get; set; } = string.Empty; + + /// Display name the Tenant Admin chose. + public string Name { get; set; } = string.Empty; + + /// The exact callback URL (https, or http on loopback). + public string RedirectUri { get; set; } = string.Empty; + + public string CreatedBy { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs index 3ace6130..959e096c 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs @@ -232,6 +232,14 @@ namespace AutopilotMonitor.Shared.Models /// public bool EnforceClientAppBinding { get; set; } = false; + /// + /// Self-hosted MCP client registrations. When true, Tenant Admins can register the exact OAuth + /// callback of a client their organization runs itself and connect it with client id amc_<id>; + /// the MCP server binds that flow to the registering tenant. When false the portal section is + /// hidden and every existing registration stops resolving (kill switch). Default false. + /// + public bool McpClientRegistrationEnabled { get; set; } = false; + // ===== VULNERABILITY CORRELATION SETTINGS ===== /// diff --git a/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs index ce813581..0f177684 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/InfrastructureApiModels.cs @@ -38,6 +38,9 @@ namespace AutopilotMonitor.Shared.Models public bool BootstrapTokenEnabled { get; set; } public bool UnrestrictedModeEnabled { get; set; } + /// Whether Tenant Admins can register self-hosted MCP clients (platform switch); drives the settings section. + public bool McpClientRegistrationEnabled { get; set; } + /// When this user last viewed the Platform tab of What's new; null = never and the key is omitted (client treats as first visit). public DateTime? WhatsNewSeenPlatformUtc { get; set; } /// Same for the Agent tab; null = never and the key is omitted. diff --git a/src/Shared/AutopilotMonitor.Shared/Models/McpClientRegistrationApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/McpClientRegistrationApiModels.cs new file mode 100644 index 00000000..7b8618d7 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/McpClientRegistrationApiModels.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Shared.Models +{ + /// One self-hosted MCP client registration as the portal lists it. + // Declaration order == wire order. + public class McpClientRegistrationItem + { + public string RegistrationId { get; set; } = default!; + + /// The OAuth client id the self-hosted client is configured with: amc_ + registration id. + public string ClientId { get; set; } = default!; + + public string Name { get; set; } = default!; + public string RedirectUri { get; set; } = default!; + public string CreatedBy { get; set; } = default!; + public DateTime CreatedUtc { get; set; } + } + + /// Response of GET tenants/{tenantId}/mcp-client-registrations. + // Declaration order == wire order. + public class McpClientRegistrationListResponse : IApiResponse + { + /// Whether the platform currently accepts self-hosted client registrations (operator switch). + public bool Enabled { get; set; } + + /// How many registrations a tenant may hold. + public int MaxRegistrations { get; set; } + + /// The MCP server URL the self-hosted client connects to. + public string ServerUrl { get; set; } = default!; + + public IReadOnlyList Registrations { get; set; } = default!; + } + + /// Body of POST tenants/{tenantId}/mcp-client-registrations. + public class CreateMcpClientRegistrationRequest : IApiRequest + { + /// Display name, 1-64 characters. + public string Name { get; set; } = string.Empty; + + /// The client's exact OAuth callback URL: https, or http on localhost / 127.0.0.1; no query, fragment or wildcard. + public string RedirectUri { get; set; } = string.Empty; + } + + /// Response of POST tenants/{tenantId}/mcp-client-registrations: the stored registration. + // Declaration order == wire order. + public class CreateMcpClientRegistrationResponse : IApiResponse + { + public McpClientRegistrationItem Registration { get; set; } = default!; + } + + /// + /// Response of the anonymous GET mcp/client-registrations/{registrationId} — the MCP server's OAuth proxy + /// resolves a client id amc_<registrationId> through it. Carries nothing secret: the tenant id + /// and the callback the registration binds the flow to. + /// + // Declaration order == wire order. + public class McpClientRegistrationLookupResponse : IApiResponse + { + public string RegistrationId { get; set; } = default!; + public string TenantId { get; set; } = default!; + public string RedirectUri { get; set; } = default!; + public string Name { get; set; } = default!; + } +} diff --git a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx index 037fd76d..2b97b68e 100644 --- a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx @@ -68,6 +68,8 @@ interface AdminConfigContextValue { setAllowAgentDowngrade: (value: boolean) => void; enforceClientAppBinding: boolean; setEnforceClientAppBinding: (value: boolean) => void; + mcpClientRegistrationEnabled: boolean; + setMcpClientRegistrationEnabled: (value: boolean) => void; modernDeploymentHarmlessEventIds: string; setModernDeploymentHarmlessEventIds: (value: string) => void; sessionDeletionKillSwitch: boolean; @@ -178,6 +180,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) const [slaNotificationCooldownHours, setSlaNotificationCooldownHours] = useState(24); const [allowAgentDowngrade, setAllowAgentDowngrade] = useState(false); const [enforceClientAppBinding, setEnforceClientAppBinding] = useState(false); + const [mcpClientRegistrationEnabled, setMcpClientRegistrationEnabled] = useState(false); const [modernDeploymentHarmlessEventIds, setModernDeploymentHarmlessEventIds] = useState("100, 1005, 1010"); const [sessionDeletionKillSwitch, setSessionDeletionKillSwitch] = useState(false); const [autoApproveNewTenants, setAutoApproveNewTenants] = useState(false); @@ -246,6 +249,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) setSlaNotificationCooldownHours(data.slaNotificationCooldownHours ?? 24); setAllowAgentDowngrade(data.allowAgentDowngrade ?? false); setEnforceClientAppBinding(data.enforceClientAppBinding ?? false); + setMcpClientRegistrationEnabled(data.mcpClientRegistrationEnabled ?? false); setModernDeploymentHarmlessEventIds(parseHarmlessEventIdsJson(data.modernDeploymentHarmlessEventIdsJson)); setSessionDeletionKillSwitch(data.sessionDeletionKillSwitch ?? false); setAutoApproveNewTenants(data.autoApproveNewTenants ?? false); @@ -363,6 +367,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) slaNotificationCooldownHours, allowAgentDowngrade, enforceClientAppBinding, + mcpClientRegistrationEnabled, modernDeploymentHarmlessEventIdsJson: serializeHarmlessEventIds(modernDeploymentHarmlessEventIds), sessionDeletionKillSwitch, autoApproveNewTenants, @@ -386,7 +391,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) } finally { setSavingConfig(false); } - }, [isGlobalAdmin, adminConfig, globalRateLimit, userRateLimit, portalUserRateLimit, globalAdminRateLimit, platformStatsBlobSasUrl, agentMigrateApiBaseUrl, agentMigrateTenantOverridesJson, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, enforceClientAppBinding, modernDeploymentHarmlessEventIds, sessionDeletionKillSwitch, autoApproveNewTenants, selfServiceAppHomingEnabled, imeMsiArchivingEnabled, maxImeMsiDownloadSizeMB, getAccessToken]); + }, [isGlobalAdmin, adminConfig, globalRateLimit, userRateLimit, portalUserRateLimit, globalAdminRateLimit, platformStatsBlobSasUrl, agentMigrateApiBaseUrl, agentMigrateTenantOverridesJson, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, enforceClientAppBinding, mcpClientRegistrationEnabled, modernDeploymentHarmlessEventIds, sessionDeletionKillSwitch, autoApproveNewTenants, selfServiceAppHomingEnabled, imeMsiArchivingEnabled, maxImeMsiDownloadSizeMB, getAccessToken]); // Reset admin config const handleResetAdminConfig = useCallback(() => { @@ -404,6 +409,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) setSlaNotificationCooldownHours(adminConfig.slaNotificationCooldownHours ?? 24); setAllowAgentDowngrade(adminConfig.allowAgentDowngrade ?? false); setEnforceClientAppBinding(adminConfig.enforceClientAppBinding ?? false); + setMcpClientRegistrationEnabled(adminConfig.mcpClientRegistrationEnabled ?? false); setModernDeploymentHarmlessEventIds(parseHarmlessEventIdsJson(adminConfig.modernDeploymentHarmlessEventIdsJson)); setSessionDeletionKillSwitch(adminConfig.sessionDeletionKillSwitch ?? false); setAutoApproveNewTenants(adminConfig.autoApproveNewTenants ?? false); @@ -565,6 +571,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode }) slaNotificationCooldownHours, setSlaNotificationCooldownHours, allowAgentDowngrade, setAllowAgentDowngrade, enforceClientAppBinding, setEnforceClientAppBinding, + mcpClientRegistrationEnabled, setMcpClientRegistrationEnabled, modernDeploymentHarmlessEventIds, setModernDeploymentHarmlessEventIds, sessionDeletionKillSwitch, setSessionDeletionKillSwitch, autoApproveNewTenants, setAutoApproveNewTenants, diff --git a/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx index 68958493..c719849c 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx @@ -31,6 +31,8 @@ interface AdminConfigSettingsSectionProps { setAllowAgentDowngrade: (value: boolean) => void; enforceClientAppBinding: boolean; setEnforceClientAppBinding: (value: boolean) => void; + mcpClientRegistrationEnabled: boolean; + setMcpClientRegistrationEnabled: (value: boolean) => void; modernDeploymentHarmlessEventIds: string; setModernDeploymentHarmlessEventIds: (value: string) => void; sessionDeletionKillSwitch: boolean; @@ -75,6 +77,8 @@ export function AdminConfigSettingsSection({ setAllowAgentDowngrade, enforceClientAppBinding, setEnforceClientAppBinding, + mcpClientRegistrationEnabled, + setMcpClientRegistrationEnabled, modernDeploymentHarmlessEventIds, setModernDeploymentHarmlessEventIds, sessionDeletionKillSwitch, @@ -330,6 +334,23 @@ export function AdminConfigSettingsSection({ +
+ +
+