mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat(mcp): tenant-bound registrations for self-hosted MCP clients (D-280)
A self-hosted AI client redirects to its own domain, which the vendor
redirect allowlist never admits, so it could not use the browser sign-in.
Tenant Admins can now register the client's exact OAuth callback in the
portal and configure the client with client id amc_<registrationId>.
- MCP OAuth proxy: an amc_ id is resolved through the backend; its
registration is the redirect allowlist entry for that exact callback.
Authorize and token run at the registering tenant's Entra authority, and
the token endpoint discards a token of any other tenant (code and
refresh). Lookups are cached for 60 s, a failed lookup fails closed. Every
other client keeps the allowlist check before its resolution.
- Backend: table McpClientRegistrations, TenantAdminOrGA routes
tenants/{tenantId}/mcp-client-registrations (list, create, delete; at
most three, https or loopback http, exact URI, audited) and the anonymous,
per-IP rate-limited lookup auth/mcp/client-registrations/{id} (under
/api/auth for the client-certificate exclusion list).
- AdminConfiguration.McpClientRegistrationEnabled (default off) is the
platform switch: off hides the portal section and makes every lookup 404.
auth/me carries it for the sidebar.
- Portal: Settings > Tenant > Self-hosted AI clients (Tenant Admin / Global
Admin), and the switch in Admin > Settings > Global.
- Tests: MCP tenant-client flow (authority, tid check, revocation, cache,
allowlist), backend round trip, wire parity, validation, cap, switch,
audit; nav visibility. Manifests and wire types regenerated.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
Submodule .claude/commands updated: 7f2a13f840...b5ec936c22
+1
-1
Submodule internal updated: 13af480538...d07d299f36
@@ -107,7 +107,8 @@ public class AuthFunctionSideEffectTests
|
||||
Mock.Of<ILogger<AutopilotMonitor.Functions.Security.EntraAppRegistry>>()),
|
||||
new Mock<AdminIdentityResolver>(
|
||||
_metricsRepoMock.Object, _tenantConfigMock.Object, Mock.Of<ILogger<AdminIdentityResolver>>()) { CallBase = false }.Object,
|
||||
_signalR);
|
||||
_signalR,
|
||||
adminConfigService.Object);
|
||||
|
||||
// Default: all fire-and-forget calls succeed
|
||||
_tenantConfigMock
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<IMcpClientRegistrationRepository> Repo { get; init; }
|
||||
public required Mock<IMaintenanceRepository> Audit { get; init; }
|
||||
}
|
||||
|
||||
private static Harness Build(bool enabled = true)
|
||||
{
|
||||
var rows = new List<McpClientRegistration>();
|
||||
var repo = new Mock<IMcpClientRegistrationRepository>();
|
||||
repo.Setup(r => r.GetForTenantAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((string t) => rows.Where(r => r.TenantId == t.ToLowerInvariant()).ToList());
|
||||
repo.Setup(r => r.GetAsync(It.IsAny<string>())).ReturnsAsync((string id) => rows.FirstOrDefault(r => r.RegistrationId == id));
|
||||
repo.Setup(r => r.CreateAsync(It.IsAny<McpClientRegistration>())).Callback<McpClientRegistration>(rows.Add).ReturnsAsync(true);
|
||||
repo.Setup(r => r.DeleteAsync(It.IsAny<string>())).ReturnsAsync((string id) => rows.RemoveAll(r => r.RegistrationId == id) > 0);
|
||||
var audit = new Mock<IMaintenanceRepository>();
|
||||
audit.Setup(a => a.LogAuditEntryAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<Dictionary<string, string>?>()))
|
||||
.ReturnsAsync(true);
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var adminConfig = new Mock<AdminConfigurationService>(Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.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<McpClientRegistrationService>.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<Dictionary<string, string>?>(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<McpClientRegistration>()), 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<McpClientRegistration>()), 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<Dictionary<string, string>?>()), 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<string>()), Times.Never);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ namespace AutopilotMonitor.Functions.DataAccess
|
||||
services.AddSingleton<IDeviceSecurityRepository, TableDeviceSecurityRepository>();
|
||||
services.AddSingleton<IUserUsageRepository, TableUserUsageRepository>();
|
||||
services.AddSingleton<IDelegationInvitationRepository, TableDelegationInvitationRepository>();
|
||||
services.AddSingleton<IMcpClientRegistrationRepository, TableMcpClientRegistrationRepository>();
|
||||
services.AddSingleton<IDistressReportRepository, TableDistressReportRepository>();
|
||||
services.AddSingleton<IOpsEventRepository, TableOpsEventRepository>();
|
||||
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+110
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Table-storage implementation of <see cref="IMcpClientRegistrationRepository"/>.
|
||||
/// PK = <see cref="PartitionKey"/> for every tenant, RK = registration id; the tenant view is a
|
||||
/// property filter, the MCP server's lookup a point read.
|
||||
/// </summary>
|
||||
public class TableMcpClientRegistrationRepository : IMcpClientRegistrationRepository
|
||||
{
|
||||
internal const string PartitionKey = "registrations";
|
||||
|
||||
private readonly TableClient _table;
|
||||
private readonly ILogger<TableMcpClientRegistrationRepository> _logger;
|
||||
|
||||
public TableMcpClientRegistrationRepository(TableStorageService storage, ILogger<TableMcpClientRegistrationRepository> logger)
|
||||
{
|
||||
_table = storage.GetTableClient(Constants.TableNames.McpClientRegistrations);
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<bool> 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<McpClientRegistration?> GetAsync(string registrationId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var response = await _table.GetEntityIfExistsAsync<TableEntity>(PartitionKey, registrationId);
|
||||
return response.HasValue && response.Value != null ? Map(response.Value) : null;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<List<McpClientRegistration>> GetForTenantAsync(string tenantId)
|
||||
{
|
||||
var results = new List<McpClientRegistration>();
|
||||
try
|
||||
{
|
||||
await foreach (var entity in _table.QueryAsync<TableEntity>(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<bool> 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("'", "''")}'";
|
||||
|
||||
/// <summary>Model → entity (every field in both directions; the offboarding wipe matches the lowercase TenantId).</summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+144
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>amc_<id></c> through the anonymous lookup. The lookup sits under <c>/api/auth</c> because the
|
||||
/// Function App requires client certificates outside a fixed list of path prefixes.
|
||||
/// </summary>
|
||||
public class McpClientRegistrationFunction
|
||||
{
|
||||
/// <summary>Per source IP and minute. Every lookup comes from the MCP server's egress IP, which caches answers for a minute.</summary>
|
||||
internal const int LookupRateLimitPerMinute = 120;
|
||||
|
||||
private readonly ILogger<McpClientRegistrationFunction> _logger;
|
||||
private readonly McpClientRegistrationService _service;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
|
||||
public McpClientRegistrationFunction(
|
||||
ILogger<McpClientRegistrationFunction> logger,
|
||||
McpClientRegistrationService service,
|
||||
RateLimitService rateLimitService)
|
||||
{
|
||||
_logger = logger;
|
||||
_service = service;
|
||||
_rateLimitService = rateLimitService;
|
||||
}
|
||||
|
||||
/// <summary>GET /api/tenants/{tenantId}/mcp-client-registrations</summary>
|
||||
[Function("GetMcpClientRegistrations")]
|
||||
[Authorize]
|
||||
public async Task<HttpResponseData> 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(),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>POST /api/tenants/{tenantId}/mcp-client-registrations</summary>
|
||||
[Function("CreateMcpClientRegistration")]
|
||||
[Authorize]
|
||||
public async Task<HttpResponseData> 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<CreateMcpClientRegistrationRequest>();
|
||||
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) });
|
||||
}
|
||||
|
||||
/// <summary>DELETE /api/tenants/{tenantId}/mcp-client-registrations/{registrationId}</summary>
|
||||
[Function("DeleteMcpClientRegistration")]
|
||||
[Authorize]
|
||||
public async Task<HttpResponseData> 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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Function("LookupMcpClientRegistration")]
|
||||
public async Task<HttpResponseData> 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,
|
||||
};
|
||||
}
|
||||
@@ -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<AuthFunction> 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<string>? 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);
|
||||
|
||||
@@ -270,6 +270,7 @@ builder.Services.AddSingleton<DelegatedAdminService>();
|
||||
builder.Services.AddSingleton<DelegatedSlotService>();
|
||||
builder.Services.AddSingleton<DelegationSelfService>();
|
||||
builder.Services.AddSingleton<McpUserService>();
|
||||
builder.Services.AddSingleton<McpClientRegistrationService>();
|
||||
builder.Services.AddSingleton<PreviewWhitelistService>();
|
||||
// Shared activation path (whitelist add + auto-promote + welcome email) — used by the
|
||||
// Global Admin approve endpoint and the tenant auto-approve queue worker.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Self-hosted MCP client registrations (see <see cref="IMcpClientRegistrationRepository"/>): validation,
|
||||
/// the per-tenant cap, the operator switch and the audit trail. The MCP server's OAuth proxy resolves a
|
||||
/// client id <c>amc_<id></c> through <see cref="LookupAsync"/> and binds the flow to the registering tenant.
|
||||
/// </summary>
|
||||
public class McpClientRegistrationService
|
||||
{
|
||||
public const int MaxRegistrationsPerTenant = 3;
|
||||
public const int MaxNameLength = 64;
|
||||
/// <summary>Mirrors the MCP proxy's redirect_uri length limit (oauth-limits.ts).</summary>
|
||||
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<McpClientRegistrationService> _logger;
|
||||
|
||||
public McpClientRegistrationService(
|
||||
IMcpClientRegistrationRepository repo,
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
AdminConfigurationService adminConfigService,
|
||||
ILogger<McpClientRegistrationService> logger)
|
||||
{
|
||||
_repo = repo;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_adminConfigService = adminConfigService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>The operator switch (5-minute cached admin configuration).</summary>
|
||||
public virtual async Task<bool> IsEnabledAsync()
|
||||
=> (await _adminConfigService.GetConfigurationAsync()).McpClientRegistrationEnabled;
|
||||
|
||||
public static string ClientIdOf(string registrationId) => ClientIdPrefix + registrationId;
|
||||
|
||||
/// <summary>Null when the name is acceptable, else the reason.</summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<List<McpClientRegistration>> ListAsync(string tenantId)
|
||||
=> (await _repo.GetForTenantAsync(tenantId)).OrderBy(r => r.CreatedAt).ToList();
|
||||
|
||||
/// <summary>Creates a registration for <paramref name="tenantId"/>; the result names the HTTP status to answer.</summary>
|
||||
public virtual async Task<McpClientRegistrationResult> 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<string, string> { { "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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes one of <paramref name="tenantId"/>'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.
|
||||
/// </summary>
|
||||
public virtual async Task<bool> 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<string, string> { { "Name", registration.Name }, { "RedirectUri", registration.RedirectUri } });
|
||||
_logger.LogWarning("MCP client registration {RegistrationId} deleted for tenant {TenantId} by {Actor}",
|
||||
registrationId, registration.TenantId, actor);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The MCP proxy's lookup: the registration, or null when unknown or while the switch is off.</summary>
|
||||
public virtual async Task<McpClientRegistration?> LookupAsync(string registrationId)
|
||||
{
|
||||
if (!IsRegistrationId(registrationId)) return null;
|
||||
if (!await IsEnabledAsync()) return null;
|
||||
return await _repo.GetAsync(registrationId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Outcome of <see cref="McpClientRegistrationService.CreateAsync"/>.</summary>
|
||||
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);
|
||||
}
|
||||
+1
@@ -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).
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* Tenant-bound clients (portal registrations): a self-hosted client presents client_id
|
||||
* `amc_<registrationId>`. 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<void>((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<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
clearTenantClientCache();
|
||||
});
|
||||
|
||||
function jwt(claims: Record<string, unknown>): 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<string, string>) {
|
||||
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<string, unknown> };
|
||||
}
|
||||
|
||||
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<string, unknown>).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);
|
||||
});
|
||||
});
|
||||
@@ -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_<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. */
|
||||
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_<registrationId> 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;
|
||||
|
||||
@@ -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_<registrationId>`. 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<string, { value: TenantClient | null; expiresAt: number }>();
|
||||
|
||||
/** 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<TenantClient | null | 'unavailable'> {
|
||||
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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AutopilotMonitor.Shared.DataAccess
|
||||
{
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <c>amc_<RegistrationId></c>. The MCP server's OAuth proxy treats the registration as that
|
||||
/// client's redirect allowlist entry and binds the flow to <see cref="McpClientRegistration.TenantId"/>.
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public interface IMcpClientRegistrationRepository
|
||||
{
|
||||
/// <summary>Stores a new registration; false when storage refused it.</summary>
|
||||
Task<bool> CreateAsync(McpClientRegistration registration);
|
||||
|
||||
/// <summary>One registration by id, or null.</summary>
|
||||
Task<McpClientRegistration?> GetAsync(string registrationId);
|
||||
|
||||
/// <summary>Every registration of one tenant.</summary>
|
||||
Task<List<McpClientRegistration>> GetForTenantAsync(string tenantId);
|
||||
|
||||
/// <summary>Deletes a registration; false when it did not exist.</summary>
|
||||
Task<bool> DeleteAsync(string registrationId);
|
||||
}
|
||||
|
||||
/// <summary>One self-hosted MCP client registration.</summary>
|
||||
public class McpClientRegistration
|
||||
{
|
||||
/// <summary>32 lowercase hex characters; the client id is <c>amc_</c> + this value.</summary>
|
||||
public string RegistrationId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The registering tenant (lowercase GUID) — the only tenant the client can sign in to.</summary>
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Display name the Tenant Admin chose.</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The exact callback URL (https, or http on loopback).</summary>
|
||||
public string RedirectUri { get; set; } = string.Empty;
|
||||
|
||||
public string CreatedBy { get; set; } = string.Empty;
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -232,6 +232,14 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public bool EnforceClientAppBinding { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool McpClientRegistrationEnabled { get; set; } = false;
|
||||
|
||||
// ===== VULNERABILITY CORRELATION SETTINGS =====
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -38,6 +38,9 @@ namespace AutopilotMonitor.Shared.Models
|
||||
public bool BootstrapTokenEnabled { get; set; }
|
||||
public bool UnrestrictedModeEnabled { get; set; }
|
||||
|
||||
/// <summary>Whether Tenant Admins can register self-hosted MCP clients (platform switch); drives the settings section.</summary>
|
||||
public bool McpClientRegistrationEnabled { get; set; }
|
||||
|
||||
/// <summary>When this user last viewed the Platform tab of What's new; null = never and the key is omitted (client treats as first visit).</summary>
|
||||
public DateTime? WhatsNewSeenPlatformUtc { get; set; }
|
||||
/// <summary>Same for the Agent tab; null = never and the key is omitted.</summary>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Models
|
||||
{
|
||||
/// <summary>One self-hosted MCP client registration as the portal lists it.</summary>
|
||||
// Declaration order == wire order.
|
||||
public class McpClientRegistrationItem
|
||||
{
|
||||
public string RegistrationId { get; set; } = default!;
|
||||
|
||||
/// <summary>The OAuth client id the self-hosted client is configured with: <c>amc_</c> + registration id.</summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>Response of GET tenants/{tenantId}/mcp-client-registrations.</summary>
|
||||
// Declaration order == wire order.
|
||||
public class McpClientRegistrationListResponse : IApiResponse
|
||||
{
|
||||
/// <summary>Whether the platform currently accepts self-hosted client registrations (operator switch).</summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>How many registrations a tenant may hold.</summary>
|
||||
public int MaxRegistrations { get; set; }
|
||||
|
||||
/// <summary>The MCP server URL the self-hosted client connects to.</summary>
|
||||
public string ServerUrl { get; set; } = default!;
|
||||
|
||||
public IReadOnlyList<McpClientRegistrationItem> Registrations { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>Body of POST tenants/{tenantId}/mcp-client-registrations.</summary>
|
||||
public class CreateMcpClientRegistrationRequest : IApiRequest
|
||||
{
|
||||
/// <summary>Display name, 1-64 characters.</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The client's exact OAuth callback URL: https, or http on localhost / 127.0.0.1; no query, fragment or wildcard.</summary>
|
||||
public string RedirectUri { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Response of POST tenants/{tenantId}/mcp-client-registrations: the stored registration.</summary>
|
||||
// Declaration order == wire order.
|
||||
public class CreateMcpClientRegistrationResponse : IApiResponse
|
||||
{
|
||||
public McpClientRegistrationItem Registration { get; set; } = default!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response of the anonymous GET mcp/client-registrations/{registrationId} — the MCP server's OAuth proxy
|
||||
/// resolves a client id <c>amc_<registrationId></c> through it. Carries nothing secret: the tenant id
|
||||
/// and the callback the registration binds the flow to.
|
||||
/// </summary>
|
||||
// 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!;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-start space-x-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mcpClientRegistrationEnabled}
|
||||
onChange={(e) => setMcpClientRegistrationEnabled(e.target.checked)}
|
||||
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-green-600 focus:ring-green-500"
|
||||
/>
|
||||
<span>
|
||||
<span className="text-indigo-900 dark:text-indigo-100 font-medium">Self-hosted MCP client registration</span>
|
||||
<p className="text-sm text-indigo-800 dark:text-gray-300 mt-1">
|
||||
When{" "}<strong>on</strong>, Tenant Admins see Settings → Tenant → Self-hosted AI clients and can register the exact OAuth callback of an AI client their organization runs itself; the client then signs in through the MCP server, bound to that tenant. When{" "}<strong>off</strong>{" "}(default), the section is hidden and existing registrations stop working within a few minutes (kill switch).
|
||||
</p>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block">
|
||||
<span className="text-indigo-900 dark:text-indigo-100 font-medium">ModernDeployment Harmless EventIDs</span>
|
||||
|
||||
@@ -21,6 +21,7 @@ export function SectionGlobalSettings() {
|
||||
slaNotificationCooldownHours, setSlaNotificationCooldownHours,
|
||||
allowAgentDowngrade, setAllowAgentDowngrade,
|
||||
enforceClientAppBinding, setEnforceClientAppBinding,
|
||||
mcpClientRegistrationEnabled, setMcpClientRegistrationEnabled,
|
||||
modernDeploymentHarmlessEventIds, setModernDeploymentHarmlessEventIds,
|
||||
sessionDeletionKillSwitch, setSessionDeletionKillSwitch,
|
||||
autoApproveNewTenants, setAutoApproveNewTenants,
|
||||
@@ -63,6 +64,8 @@ export function SectionGlobalSettings() {
|
||||
setAllowAgentDowngrade={setAllowAgentDowngrade}
|
||||
enforceClientAppBinding={enforceClientAppBinding}
|
||||
setEnforceClientAppBinding={setEnforceClientAppBinding}
|
||||
mcpClientRegistrationEnabled={mcpClientRegistrationEnabled}
|
||||
setMcpClientRegistrationEnabled={setMcpClientRegistrationEnabled}
|
||||
modernDeploymentHarmlessEventIds={modernDeploymentHarmlessEventIds}
|
||||
setModernDeploymentHarmlessEventIds={setModernDeploymentHarmlessEventIds}
|
||||
sessionDeletionKillSwitch={sessionDeletionKillSwitch}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SectionSubmitLogs } from "../sections/SectionSubmitLogs";
|
||||
import { SectionContact } from "../sections/SectionContact";
|
||||
import { SectionOptionalGraphCapabilities } from "../sections/SectionOptionalGraphCapabilities";
|
||||
import { SectionDelegatedAccess } from "../sections/SectionDelegatedAccess";
|
||||
import { SectionSelfHostedClients } from "../sections/SectionSelfHostedClients";
|
||||
|
||||
const SECTION_COMPONENTS: Record<TenantSectionId, React.ComponentType> = {
|
||||
"plan": SectionPlan,
|
||||
@@ -22,6 +23,7 @@ const SECTION_COMPONENTS: Record<TenantSectionId, React.ComponentType> = {
|
||||
"sla-targets": SectionSlaTargets,
|
||||
"access-management": SectionAccessManagement,
|
||||
"delegations": SectionDelegatedAccess,
|
||||
"ai-clients": SectionSelfHostedClients,
|
||||
"bootstrap-sessions": SectionBootstrapSessions,
|
||||
"graph-permissions": SectionOptionalGraphCapabilities,
|
||||
"contact": SectionContact,
|
||||
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useTenant } from "@/contexts/TenantContext";
|
||||
import { useTenantConfig } from "../../TenantConfigContext";
|
||||
import { apiErrorText, fetchJson, fetchOk, jsonBody } from "@/lib/apiClient";
|
||||
import { api } from "@/lib/api";
|
||||
import { SectionCardHeader } from "@/components/SectionCardHeader";
|
||||
import { DOCS_PATHS } from "@/lib/docsPaths";
|
||||
import type {
|
||||
CreateMcpClientRegistrationRequest,
|
||||
CreateMcpClientRegistrationResponse,
|
||||
McpClientRegistrationListResponse,
|
||||
} from "@/utils/wire-types.generated";
|
||||
|
||||
function formatDay(iso: string | undefined | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → Tenant → Self-hosted AI clients. A Tenant Admin registers the exact OAuth callback of an AI
|
||||
* client the organization runs itself; the client then connects to the MCP server with client id
|
||||
* amc_<id> through the normal browser sign-in, bound to this tenant. Every change is audited.
|
||||
*/
|
||||
export function SectionSelfHostedClients() {
|
||||
const { getAccessToken } = useAuth();
|
||||
const { tenantId } = useTenant();
|
||||
const { canEditConfig } = useTenantConfig();
|
||||
|
||||
const [data, setData] = useState<McpClientRegistrationListResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [flash, setFlash] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [redirectUri, setRedirectUri] = useState("");
|
||||
const [copied, setCopied] = useState<string | null>(null);
|
||||
|
||||
const notify = (msg: string) => {
|
||||
setFlash(msg);
|
||||
setTimeout(() => setFlash(null), 4000);
|
||||
};
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!tenantId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setData(await fetchJson<McpClientRegistrationListResponse>(api.tenants.mcpClientRegistrations(tenantId), getAccessToken));
|
||||
} catch (err) {
|
||||
setError(apiErrorText(err, "Failed to load self-hosted clients"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [tenantId, getAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canEditConfig) return;
|
||||
const run = async () => {
|
||||
await load();
|
||||
};
|
||||
void run();
|
||||
}, [load, canEditConfig]);
|
||||
|
||||
const create = useCallback(async () => {
|
||||
if (!tenantId) return;
|
||||
setBusy("create");
|
||||
setError(null);
|
||||
try {
|
||||
const created = await fetchJson<CreateMcpClientRegistrationResponse>(api.tenants.mcpClientRegistrations(tenantId), getAccessToken, {
|
||||
method: "POST",
|
||||
body: jsonBody<CreateMcpClientRegistrationRequest>({ name: name.trim(), redirectUri: redirectUri.trim() }),
|
||||
});
|
||||
setName("");
|
||||
setRedirectUri("");
|
||||
notify(`${created.registration.name} registered. Configure the client with the client ID shown below.`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(apiErrorText(err, "Failed to register the client"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}, [tenantId, name, redirectUri, getAccessToken, load]);
|
||||
|
||||
const remove = useCallback(async (registrationId: string, label: string) => {
|
||||
if (!tenantId) return;
|
||||
setBusy(registrationId);
|
||||
setError(null);
|
||||
try {
|
||||
await fetchOk(api.tenants.mcpClientRegistration(tenantId, registrationId), getAccessToken, { method: "DELETE" });
|
||||
setConfirmDelete(null);
|
||||
notify(`${label} deleted. The client can no longer sign in.`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(apiErrorText(err, "Failed to delete the registration"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}, [tenantId, getAccessToken, load]);
|
||||
|
||||
const copy = useCallback(async (value: string, key: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(key);
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
} catch {
|
||||
setCopied(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!canEditConfig) {
|
||||
return (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 text-sm text-amber-800">
|
||||
This page is available to tenant administrators only.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const registrations = data?.registrations ?? [];
|
||||
const atCap = data ? registrations.length >= data.maxRegistrations : false;
|
||||
const inputClass = "px-3 py-1.5 border border-gray-300 rounded-lg text-sm bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{flash && <div className="bg-green-50 border border-green-200 rounded-lg p-4 text-sm text-green-800">{flash}</div>}
|
||||
{error && <div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-800">{error}</div>}
|
||||
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<SectionCardHeader
|
||||
tone="skyIndigo"
|
||||
iconPath="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
title="Self-hosted AI clients"
|
||||
subtitle="Connect an AI client your organization runs itself through the normal sign-in. Only accounts of this tenant can sign in through a registered client."
|
||||
docsPath={DOCS_PATHS.mcpUsers}
|
||||
/>
|
||||
<div className="p-6 space-y-4">
|
||||
{data && !data.enabled && (
|
||||
<p className="text-sm text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||
Self-hosted client registration is currently switched off on this platform. Existing registrations do not work until it is switched on again.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Name, e.g. Team chat"
|
||||
maxLength={64}
|
||||
className={`${inputClass} w-44`}
|
||||
aria-label="Client name"
|
||||
/>
|
||||
<input
|
||||
type="url"
|
||||
value={redirectUri}
|
||||
onChange={(e) => setRedirectUri(e.target.value)}
|
||||
placeholder="https://chat.example.com/api/mcp/autopilot-monitor/oauth/callback"
|
||||
className={`${inputClass} flex-1 min-w-[18rem] font-mono`}
|
||||
aria-label="Callback URL"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void create()}
|
||||
disabled={busy !== null || atCap || !data?.enabled || !name.trim() || !redirectUri.trim()}
|
||||
className="px-3 py-1.5 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{busy === "create" ? "Registering…" : "Register"}
|
||||
</button>
|
||||
</div>
|
||||
{data && (
|
||||
<p className="text-xs text-gray-500">
|
||||
{registrations.length} of {data.maxRegistrations} registrations used. The callback URL must match exactly what the client sends: https, or http only for localhost.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading && !data ? (
|
||||
<p className="text-sm text-gray-500">Loading…</p>
|
||||
) : registrations.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">No self-hosted client is registered.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{registrations.map((r) => (
|
||||
<li key={r.registrationId} className="py-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||
<span className="font-medium text-gray-900">{r.name}</span>
|
||||
<span className="font-mono text-xs text-gray-600 truncate max-w-[28rem]" title={r.redirectUri}>{r.redirectUri}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{r.createdBy} · {formatDay(r.createdUtc)}
|
||||
</span>
|
||||
<span className="ml-auto flex items-center gap-2">
|
||||
<code className="text-xs bg-gray-100 text-gray-800 rounded px-1.5 py-0.5">{r.clientId}</code>
|
||||
<button type="button" onClick={() => void copy(r.clientId, r.registrationId)} className="text-xs text-indigo-600 hover:text-indigo-800">
|
||||
{copied === r.registrationId ? "Copied" : "Copy client ID"}
|
||||
</button>
|
||||
{confirmDelete === r.registrationId ? (
|
||||
<>
|
||||
<span className="text-xs text-gray-600">Delete? The client stops working.</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void remove(r.registrationId, r.name)}
|
||||
className="text-xs font-medium text-white bg-red-600 rounded px-2 py-1 hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{busy === r.registrationId ? "Deleting…" : "Confirm"}
|
||||
</button>
|
||||
<button type="button" onClick={() => setConfirmDelete(null)} className="text-xs text-gray-500 hover:text-gray-700">Cancel</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" onClick={() => setConfirmDelete(r.registrationId)} className="text-xs text-red-600 hover:text-red-800">
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{data && (
|
||||
<details className="text-sm text-gray-700 bg-gray-50 border border-gray-200 rounded-lg px-4 py-3">
|
||||
<summary className="cursor-pointer font-medium text-gray-800">How to configure the client</summary>
|
||||
<ol className="mt-3 space-y-2 list-decimal list-inside">
|
||||
<li>
|
||||
Server URL:{" "}
|
||||
<code className="text-xs bg-white border border-gray-200 rounded px-1.5 py-0.5">{data.serverUrl}</code>{" "}
|
||||
<button type="button" onClick={() => void copy(data.serverUrl, "server")} className="text-xs text-indigo-600 hover:text-indigo-800">
|
||||
{copied === "server" ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</li>
|
||||
<li>Authentication: OAuth. Enter the client ID from the list above and leave the client secret empty.</li>
|
||||
<li>Leave the authorization and token URLs empty; the client discovers them from the server.</li>
|
||||
<li>
|
||||
Register the callback URL the client really uses. LibreChat, for example, uses{" "}
|
||||
<code className="text-xs bg-white border border-gray-200 rounded px-1.5 py-0.5">https://<your LibreChat host>/api/mcp/<server name>/oauth/callback</code>
|
||||
, where the server name is the name you give the server in LibreChat.
|
||||
</li>
|
||||
<li>Connecting opens the Microsoft sign-in. Users sign in with their account of this tenant; their role applies as in the portal.</li>
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export const TENANT_NAV_SECTIONS = [
|
||||
{ id: "access-management", label: "Access Management", description: "Manage tenant admins and operators" },
|
||||
{ id: "delegations", label: "Delegated Access", description: "Who can read your tenant, and the tenants you manage (MSP)" },
|
||||
{ id: "ai-clients", label: "Self-hosted AI clients", description: "Connect an AI client your organization runs itself" },
|
||||
{ id: "autopilot", label: "Autopilot Validation", description: "Autopilot device and corporate identifier validation" },
|
||||
{ id: "hardware-whitelist", label: "Hardware Whitelist", description: "Manufacturer and model whitelist configuration" },
|
||||
{ id: "notifications", label: "Notifications", description: "Webhook notification configuration" },
|
||||
|
||||
@@ -277,6 +277,7 @@ function toUserInfo(data: AuthMeResponse, account: AccountInfo): UserInfo {
|
||||
hasMcpAccess: data.hasMcpAccess || false,
|
||||
bootstrapTokenEnabled: data.bootstrapTokenEnabled || false,
|
||||
unrestrictedModeEnabled: data.unrestrictedModeEnabled || false,
|
||||
mcpClientRegistrationEnabled: data.mcpClientRegistrationEnabled || false,
|
||||
// What's new seen marks: absent key = never viewed (first-visit window applies).
|
||||
whatsNewSeenPlatformUtc: data.whatsNewSeenPlatformUtc,
|
||||
whatsNewSeenAgentUtc: data.whatsNewSeenAgentUtc,
|
||||
@@ -304,6 +305,7 @@ function claimsOnlyUserInfo(account: AccountInfo): UserInfo {
|
||||
hasMcpAccess: false,
|
||||
bootstrapTokenEnabled: false,
|
||||
unrestrictedModeEnabled: false,
|
||||
mcpClientRegistrationEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -223,6 +223,15 @@ describe("item and sub-item filtering", () => {
|
||||
expect(subIds(bootstrapOperator)).toContain("cfg-bootstrap-sessions");
|
||||
expect(subIds(bootstrapOperator)).not.toContain("cfg-agent-unrestricted");
|
||||
});
|
||||
|
||||
it("self-hosted AI clients follow the platform switch and are Tenant Admin only", () => {
|
||||
expect(subIds(flagsOf("TenantAdmin"))).not.toContain("cfg-ai-clients");
|
||||
const switchedOn = (u: Parameters<typeof user>[0]) =>
|
||||
deriveNavFlags({ user: user({ ...u, mcpClientRegistrationEnabled: true }), hasGlobalScope: false, hasFleetScope: false, globalAdminMode: false });
|
||||
expect(subIds(switchedOn({ isTenantAdmin: true, role: "Admin" }))).toContain("cfg-ai-clients");
|
||||
expect(subIds(switchedOn({ role: "Operator" }))).not.toContain("cfg-ai-clients");
|
||||
expect(subIds(switchedOn({ role: "Viewer" }))).not.toContain("cfg-ai-clients");
|
||||
});
|
||||
});
|
||||
|
||||
// ── layer 3: nav ↔ route-guard consistency ────────────────────────────────────
|
||||
|
||||
@@ -209,6 +209,11 @@ export const api = {
|
||||
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/admins/${encodeURIComponent(adminUpn)}/${encodeURIComponent(action)}`,
|
||||
adminPermissions: (tenantId: string, adminUpn: string) =>
|
||||
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/admins/${encodeURIComponent(adminUpn)}/permissions`,
|
||||
// Self-hosted MCP client registrations (Tenant Admin).
|
||||
mcpClientRegistrations: (tenantId: string) =>
|
||||
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/mcp-client-registrations`,
|
||||
mcpClientRegistration: (tenantId: string, registrationId: string) =>
|
||||
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/mcp-client-registrations/${encodeURIComponent(registrationId)}`,
|
||||
offboard: (tenantId: string) =>
|
||||
`${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/offboard`,
|
||||
offboardFeedback: (tenantId: string) =>
|
||||
|
||||
@@ -210,6 +210,7 @@ export const EXPANDABLE_NAV_GROUPS: ExpandableNavGroup[] = [
|
||||
items: [
|
||||
{ id: "cfg-access-mgmt", label: "Access Management", href: route("/settings/tenant/access-management") },
|
||||
{ id: "cfg-delegations", label: "Delegated Access", href: route("/settings/tenant/delegations") },
|
||||
{ id: "cfg-ai-clients", label: "Self-hosted AI clients", href: route("/settings/tenant/ai-clients") },
|
||||
{ id: "cfg-autopilot", label: "Autopilot Validation", href: route("/settings/tenant/autopilot") },
|
||||
{ id: "cfg-hardware", label: "Hardware Whitelist", href: route("/settings/tenant/hardware-whitelist") },
|
||||
{ id: "cfg-notifications", label: "Notifications", href: route("/settings/tenant/notifications") },
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface NavUser {
|
||||
canManageBootstrapTokens?: boolean;
|
||||
bootstrapTokenEnabled?: boolean;
|
||||
unrestrictedModeEnabled?: boolean;
|
||||
mcpClientRegistrationEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface NavVisibilityInput {
|
||||
@@ -41,6 +42,8 @@ export interface NavFlags {
|
||||
canManageBootstrapTokens: boolean;
|
||||
bootstrapTokenEnabled: boolean;
|
||||
unrestrictedModeEnabled: boolean;
|
||||
/** Platform switch for self-hosted MCP client registrations. */
|
||||
mcpClientRegistrationEnabled: boolean;
|
||||
/** Minimal nav (Progress Portal only): no tenant role and no fleet/platform scope. */
|
||||
isRegularUser: boolean;
|
||||
/** The cross-tenant session browser link. */
|
||||
@@ -69,6 +72,7 @@ export function deriveNavFlags({ user, hasGlobalScope, hasFleetScope, globalAdmi
|
||||
canManageBootstrapTokens: user?.canManageBootstrapTokens ?? false,
|
||||
bootstrapTokenEnabled: user?.bootstrapTokenEnabled ?? false,
|
||||
unrestrictedModeEnabled: user?.unrestrictedModeEnabled ?? false,
|
||||
mcpClientRegistrationEnabled: user?.mcpClientRegistrationEnabled ?? false,
|
||||
// Regular users see minimal nav. A read-only Global Reader has platform scope, and a
|
||||
// delegated MSP admin has fleet scope → both get the group-filtered nav instead.
|
||||
isRegularUser: !isTenantMember && !hasFleetScope,
|
||||
@@ -128,6 +132,10 @@ export function filterExpandableNavGroups(
|
||||
if (sub.id === "cfg-agent-unrestricted") {
|
||||
return flags.isAdminLike && flags.unrestrictedModeEnabled;
|
||||
}
|
||||
// Registrations are a Tenant Admin (or Global Admin) write surface behind the platform switch.
|
||||
if (sub.id === "cfg-ai-clients") {
|
||||
return flags.isAdminLike && flags.mcpClientRegistrationEnabled;
|
||||
}
|
||||
// Tenant-admin-only sub-sections: Operators and Viewers (read-only settings
|
||||
// viewers) don't see them — matches the in-page "tenant administrators only" gates. A platform
|
||||
// scope (GA / read-only GlobalReader) keeps the full GA-identical sidebar.
|
||||
|
||||
@@ -33,6 +33,7 @@ export const SHARED_MANIFEST = {
|
||||
"diagnosticsDownloadTimeoutSeconds",
|
||||
"mcpAccessPolicy",
|
||||
"enforceClientAppBinding",
|
||||
"mcpClientRegistrationEnabled",
|
||||
"nvdApiKey",
|
||||
"opsAlertRulesJson",
|
||||
"opsAlertTelegramEnabled",
|
||||
@@ -89,6 +90,7 @@ export const SHARED_MANIFEST = {
|
||||
"diagnosticsDownloadTimeoutSeconds": 120,
|
||||
"mcpAccessPolicy": "WhitelistOnly",
|
||||
"enforceClientAppBinding": false,
|
||||
"mcpClientRegistrationEnabled": false,
|
||||
"nvdApiKey": null,
|
||||
"opsAlertRulesJson": null,
|
||||
"opsAlertTelegramEnabled": false,
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"diagnosticsDownloadTimeoutSeconds",
|
||||
"mcpAccessPolicy",
|
||||
"enforceClientAppBinding",
|
||||
"mcpClientRegistrationEnabled",
|
||||
"nvdApiKey",
|
||||
"opsAlertRulesJson",
|
||||
"opsAlertTelegramEnabled",
|
||||
@@ -85,6 +86,7 @@
|
||||
"diagnosticsDownloadTimeoutSeconds": 120,
|
||||
"mcpAccessPolicy": "WhitelistOnly",
|
||||
"enforceClientAppBinding": false,
|
||||
"mcpClientRegistrationEnabled": false,
|
||||
"nvdApiKey": null,
|
||||
"opsAlertRulesJson": null,
|
||||
"opsAlertTelegramEnabled": false,
|
||||
@@ -1423,6 +1425,14 @@
|
||||
},
|
||||
"doc": "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."
|
||||
},
|
||||
{
|
||||
"name": "mcpClientRegistrationEnabled",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "boolean"
|
||||
},
|
||||
"doc": "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."
|
||||
},
|
||||
{
|
||||
"name": "nvdApiKey",
|
||||
"type": {
|
||||
@@ -4295,6 +4305,14 @@
|
||||
"name": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mcpClientRegistrationEnabled",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "boolean"
|
||||
},
|
||||
"doc": "Whether Tenant Admins can register self-hosted MCP clients (platform switch); drives the settings section."
|
||||
},
|
||||
{
|
||||
"name": "whatsNewSeenPlatformUtc",
|
||||
"type": {
|
||||
@@ -5945,6 +5963,41 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"CreateMcpClientRegistrationRequest": {
|
||||
"kind": "object",
|
||||
"doc": "Body of POST tenants/{tenantId}/mcp-client-registrations.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "name",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "Display name, 1-64 characters."
|
||||
},
|
||||
{
|
||||
"name": "redirectUri",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "The client's exact OAuth callback URL: https, or http on localhost / 127.0.0.1; no query, fragment or wildcard."
|
||||
}
|
||||
]
|
||||
},
|
||||
"CreateMcpClientRegistrationResponse": {
|
||||
"kind": "object",
|
||||
"doc": "Response of POST tenants/{tenantId}/mcp-client-registrations: the stored registration.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "registration",
|
||||
"type": {
|
||||
"kind": "ref",
|
||||
"name": "McpClientRegistrationItem"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"CreateTenantGroupRequest": {
|
||||
"kind": "object",
|
||||
"doc": "Body of POST global/tenant-groups.",
|
||||
@@ -13281,6 +13334,129 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpClientRegistrationItem": {
|
||||
"kind": "object",
|
||||
"doc": "One self-hosted MCP client registration as the portal lists it.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "registrationId",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "clientId",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "The OAuth client id the self-hosted client is configured with: amc_ + registration id."
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "redirectUri",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "createdBy",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "createdUtc",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpClientRegistrationListResponse": {
|
||||
"kind": "object",
|
||||
"doc": "Response of GET tenants/{tenantId}/mcp-client-registrations.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "enabled",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "boolean"
|
||||
},
|
||||
"doc": "Whether the platform currently accepts self-hosted client registrations (operator switch)."
|
||||
},
|
||||
{
|
||||
"name": "maxRegistrations",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "number"
|
||||
},
|
||||
"doc": "How many registrations a tenant may hold."
|
||||
},
|
||||
{
|
||||
"name": "serverUrl",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
},
|
||||
"doc": "The MCP server URL the self-hosted client connects to."
|
||||
},
|
||||
{
|
||||
"name": "registrations",
|
||||
"type": {
|
||||
"kind": "array",
|
||||
"element": {
|
||||
"kind": "ref",
|
||||
"name": "McpClientRegistrationItem"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpClientRegistrationLookupResponse": {
|
||||
"kind": "object",
|
||||
"doc": "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.",
|
||||
"fields": [
|
||||
{
|
||||
"name": "registrationId",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "tenantId",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "redirectUri",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpHealthCheckResponse": {
|
||||
"kind": "object",
|
||||
"doc": "Response of GET health/mcp: the standalone MCP-server reachability probe.",
|
||||
|
||||
@@ -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_<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. */
|
||||
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_<registrationId> 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;
|
||||
|
||||
Reference in New Issue
Block a user