mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
93c6e1facb
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>
145 lines
6.5 KiB
C#
145 lines
6.5 KiB
C#
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,
|
|
};
|
|
}
|