mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
backend/web/mcp: per-tenant MCP switch as an operator control (D-210)
TenantConfiguration.McpDisabled + McpDisabledReason (GA-only, same deny-list and PUT guard as Disabled). Front door: McpUserService denies every caller of a closed home tenant below the platform roles. Per request: PolicyEnforcementMiddleware denies a closed home or target tenant for X-Client-Source: mcp (403 McpDisabled) and narrows the delegated fleet bound, never to null. ClientSourceHeader is the one reader of the header. The MCP access guard surfaces the backend reason verbatim. GA tenant modal, list badge and config report; wire types regenerated; internal doc, health marker, gitlinks bumped. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+1
-1
Submodule .claude/commands updated: 165a6ec9cb...f1a25adfa4
+1
-1
Submodule internal updated: 267628eab1...65fed34b98
@@ -356,7 +356,8 @@ public class ApplicationPrincipalTests
|
||||
var members = new StubTenantMemberRoleResolver();
|
||||
var sut = new McpUserService(
|
||||
adminRepo.Object, bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, members);
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, members,
|
||||
new TenantConfigurationService(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache));
|
||||
return (sut, members);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ public class AuthFunctionSideEffectTests
|
||||
var mcpUserMock = new Mock<McpUserService>(
|
||||
adminRepo, new StubAdminIdentityBindingService(bound: true), cache, Mock.Of<ILogger<McpUserService>>(),
|
||||
globalAdminMock.Object, delegatedAdminMock.Object, adminConfigService.Object,
|
||||
new TenantMemberRoleResolver(_tenantAdminsMock.Object, _tenantConfigMock.Object))
|
||||
new TenantMemberRoleResolver(_tenantAdminsMock.Object, _tenantConfigMock.Object), _tenantConfigMock.Object)
|
||||
{ CallBase = false };
|
||||
|
||||
_metricsRepoMock = new Mock<IMetricsRepository>();
|
||||
|
||||
@@ -138,7 +138,7 @@ public class McpQuotaServiceTests
|
||||
cache ??= new MemoryCache(new MemoryCacheOptions());
|
||||
var mcpUserService = new McpUserService(
|
||||
adminRepo.Object, new StubAdminIdentityBindingService(bound), cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdminService: null!, delegatedAdminService: null!, adminConfigService: null!, memberRoleResolver: null!);
|
||||
globalAdminService: null!, delegatedAdminService: null!, adminConfigService: null!, memberRoleResolver: null!, tenantConfigService: null!);
|
||||
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
configRepo.Setup(r => r.GetAdminConfigurationAsync())
|
||||
|
||||
@@ -50,7 +50,8 @@ public class McpUserServiceAllMembersTests
|
||||
|
||||
_sut = new McpUserService(
|
||||
_adminRepo.Object, bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
_globalAdmin.Object, _delegatedAdmin.Object, adminConfig.Object, _memberRoles);
|
||||
_globalAdmin.Object, _delegatedAdmin.Object, adminConfig.Object, _memberRoles,
|
||||
new TenantConfigurationService(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache));
|
||||
}
|
||||
|
||||
private void MemberOf(string tenantId, string role) =>
|
||||
|
||||
@@ -54,7 +54,8 @@ public class McpUserServiceDelegatedTests
|
||||
|
||||
_sut = new McpUserService(
|
||||
_adminRepo.Object, bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
_globalAdmin.Object, _delegatedAdmin.Object, _adminConfig.Object, _memberRoles);
|
||||
_globalAdmin.Object, _delegatedAdmin.Object, _adminConfig.Object, _memberRoles,
|
||||
new TenantConfigurationService(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache));
|
||||
}
|
||||
|
||||
private void SetPolicy(McpAccessPolicy policy) =>
|
||||
|
||||
@@ -59,7 +59,8 @@ public class McpUserServiceIdentityBindingTests
|
||||
|
||||
_sut = new McpUserService(
|
||||
_adminRepo.Object, _bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, _memberRoles);
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, _memberRoles,
|
||||
new TenantConfigurationService(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache));
|
||||
}
|
||||
|
||||
private void SetRow(bool enabled, string? plan = "pro") =>
|
||||
@@ -131,7 +132,8 @@ public class McpUserServiceIdentityBindingTests
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { McpAccessPolicy = McpAccessPolicy.WhitelistOnly.ToString() });
|
||||
var sut = new McpUserService(_adminRepo.Object, bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, _memberRoles);
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, _memberRoles,
|
||||
new TenantConfigurationService(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache));
|
||||
_adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny<string>())).ReturnsAsync((McpUserEntry?)null);
|
||||
|
||||
var result = await sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
@@ -171,7 +173,8 @@ public class McpUserServiceIdentityBindingTests
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { McpAccessPolicy = McpAccessPolicy.AllMembers.ToString() });
|
||||
var sut = new McpUserService(_adminRepo.Object, _bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, _memberRoles);
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, _memberRoles,
|
||||
new TenantConfigurationService(Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, cache));
|
||||
SetRow(enabled: false);
|
||||
|
||||
var bound = await sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
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>
|
||||
/// The tenant-level MCP switch (<see cref="TenantConfiguration.McpDisabled"/>, operator control) at the MCP
|
||||
/// front door: a closed HOME tenant denies every caller below the platform roles — members under AllMembers,
|
||||
/// delegated (MSP) admins and enabled McpUsers rows alike — with the operator's reason (or a neutral default),
|
||||
/// while Global Admin / Reader keep administering the tenant and other tenants are untouched. The read is
|
||||
/// side-effect-free: a tenant without a config row is not closed and no default row is ever persisted.
|
||||
/// </summary>
|
||||
public class McpUserServiceTenantMcpSwitchTests
|
||||
{
|
||||
private const string Upn = "operator@customer.example";
|
||||
private const string HomeTenant = "11111111-1111-1111-1111-111111111111";
|
||||
private const string OtherTenant = "22222222-2222-2222-2222-222222222222";
|
||||
private const string Oid = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa";
|
||||
|
||||
private readonly Mock<IAdminRepository> _adminRepo = new();
|
||||
private readonly Mock<IConfigRepository> _configRepo = new();
|
||||
private readonly Mock<GlobalAdminService> _globalAdmin;
|
||||
private readonly Mock<DelegatedAdminService> _delegatedAdmin;
|
||||
private readonly StubTenantMemberRoleResolver _memberRoles = new();
|
||||
private readonly McpUserService _sut;
|
||||
|
||||
public McpUserServiceTenantMcpSwitchTests()
|
||||
{
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var bindings = new StubAdminIdentityBindingService(bound: true);
|
||||
_globalAdmin = new Mock<GlobalAdminService>(
|
||||
_adminRepo.Object, bindings, cache, NullLogger<GlobalAdminService>.Instance) { CallBase = false };
|
||||
_globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync((string?)null);
|
||||
_delegatedAdmin = new Mock<DelegatedAdminService>(
|
||||
_adminRepo.Object, bindings, new StubTenantEntitlementService(TenantEdition.Pro),
|
||||
cache, NullLogger<DelegatedAdminService>.Instance) { CallBase = false };
|
||||
_delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync(DelegatedScope.Empty);
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { McpAccessPolicy = McpAccessPolicy.AllMembers.ToString() });
|
||||
_adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny<string>())).ReturnsAsync((McpUserEntry?)null);
|
||||
// No tenant has a config row unless a test closes one — the service must treat "no row" as open.
|
||||
_configRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>())).ReturnsAsync((TenantConfiguration?)null);
|
||||
var tenantConfig = new TenantConfigurationService(_configRepo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
|
||||
|
||||
_sut = new McpUserService(
|
||||
_adminRepo.Object, bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
_globalAdmin.Object, _delegatedAdmin.Object, adminConfig.Object, _memberRoles, tenantConfig);
|
||||
}
|
||||
|
||||
private void MemberOf(string tenantId, string role) =>
|
||||
_memberRoles.Verdict = (tid, upn, _) =>
|
||||
string.Equals(tid, tenantId, StringComparison.OrdinalIgnoreCase) && upn == Upn
|
||||
? new MemberRoleInfo { Role = role }
|
||||
: null;
|
||||
|
||||
private void CloseMcp(string tenantId, string? reason = null) =>
|
||||
_configRepo.Setup(r => r.GetTenantConfigurationAsync(tenantId))
|
||||
.ReturnsAsync(new TenantConfiguration { TenantId = tenantId, McpDisabled = true, McpDisabledReason = reason });
|
||||
|
||||
[Fact]
|
||||
public async Task Member_OfClosedHomeTenant_IsDenied_WithOperatorReason()
|
||||
{
|
||||
MemberOf(HomeTenant, Constants.TenantRoles.Admin);
|
||||
CloseMcp(HomeTenant, "No AI access by customer request");
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
Assert.Equal("No AI access by customer request", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Member_OfClosedHomeTenant_WithoutReason_GetsNeutralDefault()
|
||||
{
|
||||
MemberOf(HomeTenant, Constants.TenantRoles.Admin);
|
||||
CloseMcp(HomeTenant);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
Assert.Contains("disabled for your organization", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DelegatedAdmin_WhoseHomeTenantIsClosed_LosesMcp()
|
||||
{
|
||||
// Same reach as a suspension: the MSP's own organization said "no MCP" — its staff cannot route
|
||||
// around that by reading managed customers instead.
|
||||
_delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>()))
|
||||
.ReturnsAsync(new DelegatedScope(new Dictionary<string, string>
|
||||
{
|
||||
[OtherTenant] = Constants.DelegatedRoles.DelegatedReader,
|
||||
}));
|
||||
CloseMcp(HomeTenant, "closed");
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
Assert.Equal("closed", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnabledMcpUsersRow_DoesNotOverride_ClosedHomeTenant()
|
||||
{
|
||||
_adminRepo.Setup(x => x.GetMcpUserAsync(Upn))
|
||||
.ReturnsAsync(new McpUserEntry { Upn = Upn, IsEnabled = true });
|
||||
CloseMcp(HomeTenant);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GlobalAdmin_OfClosedHomeTenant_StillAllowed()
|
||||
{
|
||||
_globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>()))
|
||||
.ReturnsAsync(Constants.GlobalRoles.GlobalAdmin);
|
||||
CloseMcp(HomeTenant);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal(Constants.GlobalRoles.GlobalAdmin, result.AccessGrant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClosedTenant_DoesNotAffect_AnotherTenantsMember()
|
||||
{
|
||||
MemberOf(OtherTenant, Constants.TenantRoles.Viewer);
|
||||
CloseMcp(HomeTenant);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, OtherTenant, Oid);
|
||||
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal("AllMembers", result.AccessGrant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TenantWithoutConfigRow_IsOpen_AndNoRowIsPersisted()
|
||||
{
|
||||
MemberOf(HomeTenant, Constants.TenantRoles.Operator);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.True(result.IsAllowed);
|
||||
_configRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
_configRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void McpDisabledMessage_PrefersOperatorReason_ElseDefault()
|
||||
{
|
||||
Assert.Equal("why", McpUserService.McpDisabledMessage(new TenantConfiguration { McpDisabled = true, McpDisabledReason = "why" }));
|
||||
Assert.Contains("Contact your administrator", McpUserService.McpDisabledMessage(new TenantConfiguration { McpDisabled = true, McpDisabledReason = " " }));
|
||||
}
|
||||
}
|
||||
@@ -1676,4 +1676,115 @@ public class PolicyEnforcementMiddlewareTests
|
||||
Assert.True(result.Allowed);
|
||||
h.ConfigRepo.Verify(r => r.GetTenantConfigurationAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
// ── Tenant MCP switch (TenantConfiguration.McpDisabled) — the per-request half ────────────
|
||||
// The MCP front door (McpUserService) already denies a caller whose home tenant is closed; this gate
|
||||
// is what makes the switch hold per request (front-door verdicts are cached) and what keeps a closed
|
||||
// tenant out of reach as a TARGET — a delegated read into it, or its rows in the MSP's aggregate.
|
||||
// It fires only for requests the MCP server forwards (X-Client-Source: mcp): the switch closes the AI
|
||||
// channel, not the tenant's own portal/API access.
|
||||
|
||||
private static void AsMcpDisabled(Harness h, string tenantId, string? reason = null) =>
|
||||
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(tenantId))
|
||||
.ReturnsAsync(new TenantConfiguration { TenantId = tenantId, McpDisabled = true, McpDisabledReason = reason });
|
||||
|
||||
[Fact]
|
||||
public async Task McpDisabled_HomeTenant_McpClient_IsForbidden_WithOperatorReason()
|
||||
{
|
||||
const string upn = "admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
AsMcpDisabled(h, TenantA, reason: "No AI access by customer request");
|
||||
|
||||
var result = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn), isMcpClient: true);
|
||||
|
||||
Assert.False(result.Allowed);
|
||||
Assert.Equal(403, result.StatusCode);
|
||||
Assert.Equal("McpDisabled", result.ErrorCode);
|
||||
Assert.Equal("No AI access by customer request", result.ErrorMessage);
|
||||
Assert.Equal("McpDisabled", result.LogReason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task McpDisabled_HomeTenant_PortalClient_IsAllowed()
|
||||
{
|
||||
// The same caller without the MCP marker is a portal user — the tenant keeps its own data access.
|
||||
const string upn = "admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
AsMcpDisabled(h, TenantA);
|
||||
|
||||
var result = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.True(result.Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task McpDisabled_GlobalAdmin_McpClient_BypassesGate()
|
||||
{
|
||||
const string upn = "ga@platform.example";
|
||||
var h = BuildHarness();
|
||||
h.AsGlobalRole(Constants.GlobalRoles.GlobalAdmin);
|
||||
AsMcpDisabled(h, TenantA);
|
||||
AsMcpDisabled(h, TenantB);
|
||||
|
||||
var own = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn), isMcpClient: true);
|
||||
var cross = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn), isMcpClient: true);
|
||||
|
||||
Assert.True(own.Allowed);
|
||||
Assert.True(cross.Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task McpDisabled_DelegatedReader_TargetClosed_IsForbidden()
|
||||
{
|
||||
// The customer said "no MCP": its MSP cannot read it through MCP either, even with a valid grant.
|
||||
const string upn = "msp@partner.example";
|
||||
var h = BuildHarness();
|
||||
h.AsDelegated(TenantB);
|
||||
AsMcpDisabled(h, TenantB, reason: "closed");
|
||||
|
||||
var mcp = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn), isMcpClient: true);
|
||||
var portal = await h.Middleware.DecideAsync("GET", $"/api/config/{TenantB}", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.False(mcp.Allowed);
|
||||
Assert.Equal("McpDisabled", mcp.ErrorCode);
|
||||
Assert.Equal("closed", mcp.ErrorMessage);
|
||||
Assert.True(portal.Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task McpDisabled_DelegatedAggregate_McpClient_NarrowsBound_NeverToNull()
|
||||
{
|
||||
// The bounded fleet aggregate drops the closed tenant instead of failing: the published bound shrinks
|
||||
// to the open subset (here: empty) and must never become null — null means "all tenants" downstream.
|
||||
const string upn = "msp@partner.example";
|
||||
var h = BuildHarness();
|
||||
h.AsDelegated(TenantB);
|
||||
AsMcpDisabled(h, TenantB);
|
||||
|
||||
var mcp = await h.Middleware.DecideAsync("GET", "/api/global/sessions", null, AuthedPrincipal(TenantA, upn), isMcpClient: true);
|
||||
var portal = await h.Middleware.DecideAsync("GET", "/api/global/sessions", null, AuthedPrincipal(TenantA, upn));
|
||||
|
||||
Assert.True(mcp.Allowed);
|
||||
Assert.NotNull(mcp.Context!.AllowedTenantIds);
|
||||
Assert.Empty(mcp.Context!.AllowedTenantIds!);
|
||||
Assert.True(mcp.Context!.IsDelegatedAggregate);
|
||||
Assert.Contains(TenantB.ToLowerInvariant(), portal.Context!.AllowedTenantIds!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Suspended_And_McpDisabled_SuspensionWins()
|
||||
{
|
||||
// Both flags set: the stronger, longer-standing verdict is reported so support reads the right cause.
|
||||
const string upn = "admin@contoso.com";
|
||||
var h = BuildHarness();
|
||||
h.AsTenantAdmin(TenantA, upn);
|
||||
h.ConfigRepo.Setup(r => r.GetTenantConfigurationAsync(TenantA))
|
||||
.ReturnsAsync(new TenantConfiguration { TenantId = TenantA, Disabled = true, McpDisabled = true });
|
||||
|
||||
var result = await h.Middleware.DecideAsync("GET", "/api/sessions", null, AuthedPrincipal(TenantA, upn), isMcpClient: true);
|
||||
|
||||
Assert.Equal("TenantSuspended", result.ErrorCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ public class TenantConfigProjectionTests
|
||||
{
|
||||
"tenantId", "domainName", "planTier", "trialExpiresUtc", "trialConsumed", "maxDelegatedTenantsOverride",
|
||||
"mcpUsagePlanOverride", "payingCustomer", "managedByProTenantId",
|
||||
"disabled", "disabledReason",
|
||||
"disabled", "disabledReason", "mcpDisabled", "mcpDisabledReason",
|
||||
"onboardedAt", "onboardedBy", "lastUpdated", "dataRetentionDays",
|
||||
"homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince",
|
||||
};
|
||||
|
||||
+4
@@ -667,6 +667,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "Disabled", config.Disabled },
|
||||
{ "DisabledReason", config.DisabledReason },
|
||||
{ "DisabledUntil", config.DisabledUntil },
|
||||
{ "McpDisabled", config.McpDisabled },
|
||||
{ "McpDisabledReason", config.McpDisabledReason },
|
||||
{ "CustomRateLimitRequestsPerMinute", config.CustomRateLimitRequestsPerMinute },
|
||||
{ "CustomUserRateLimitRequestsPerMinute", config.CustomUserRateLimitRequestsPerMinute },
|
||||
{ "ManufacturerWhitelist", config.ManufacturerWhitelist },
|
||||
@@ -778,6 +780,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
Disabled = entity.GetBoolean("Disabled") ?? false,
|
||||
DisabledReason = entity.GetString("DisabledReason"),
|
||||
DisabledUntil = entity.GetDateTime("DisabledUntil"),
|
||||
McpDisabled = entity.GetBoolean("McpDisabled") ?? false,
|
||||
McpDisabledReason = entity.GetString("McpDisabledReason"),
|
||||
CustomRateLimitRequestsPerMinute = entity.GetInt32("CustomRateLimitRequestsPerMinute"),
|
||||
CustomUserRateLimitRequestsPerMinute = entity.GetInt32("CustomUserRateLimitRequestsPerMinute"),
|
||||
ManufacturerWhitelist = entity.GetString("ManufacturerWhitelist") ?? "Dell*,HP*,Lenovo*,Microsoft Corporation",
|
||||
|
||||
+4
-1
@@ -127,7 +127,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
config.EnableEspContinueAnywayObservation != existingConfig.EnableEspContinueAnywayObservation ||
|
||||
config.CustomRateLimitRequestsPerMinute != existingConfig.CustomRateLimitRequestsPerMinute ||
|
||||
config.CustomUserRateLimitRequestsPerMinute != existingConfig.CustomUserRateLimitRequestsPerMinute ||
|
||||
config.Disabled != existingConfig.Disabled)
|
||||
config.Disabled != existingConfig.Disabled ||
|
||||
config.McpDisabled != existingConfig.McpDisabled)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Tenant Admin {User} attempted to modify GA-only fields for tenant {TenantId}",
|
||||
@@ -146,6 +147,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
config.Disabled = existingConfig.Disabled;
|
||||
config.DisabledReason = existingConfig.DisabledReason;
|
||||
config.DisabledUntil = existingConfig.DisabledUntil;
|
||||
config.McpDisabled = existingConfig.McpDisabled;
|
||||
config.McpDisabledReason = existingConfig.McpDisabledReason;
|
||||
}
|
||||
|
||||
// Safety: if GA gate is off, force UnrestrictedMode to false
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// The self-declared <c>X-Client-Source</c> request header. The MCP server stamps <c>mcp</c> on every
|
||||
/// call it forwards; the value is caller-supplied and therefore only ever selects a MORE restrictive or
|
||||
/// more descriptive path (quota layer, tenant MCP switch, richer error envelopes) — never a grant.
|
||||
/// </summary>
|
||||
internal static class ClientSourceHeader
|
||||
{
|
||||
public const string HeaderName = "X-Client-Source";
|
||||
public const string Mcp = "mcp";
|
||||
|
||||
/// <summary>True when the request declares itself as forwarded by the MCP server.</summary>
|
||||
public static bool IsMcp(HttpContext? httpContext)
|
||||
=> httpContext != null && string.Equals(
|
||||
httpContext.Request.Headers[HeaderName].FirstOrDefault(), Mcp, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -134,13 +134,7 @@ public static class ResponseHelper
|
||||
|
||||
/// <summary>Detect MCP clients via the X-Client-Source header set by the MCP server.</summary>
|
||||
internal static bool IsMcpRequest(HttpRequestData req)
|
||||
{
|
||||
var httpContext = req.FunctionContext.GetHttpContext();
|
||||
if (httpContext == null) return false;
|
||||
return string.Equals(
|
||||
httpContext.Request.Headers["X-Client-Source"].FirstOrDefault(),
|
||||
"mcp", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
=> ClientSourceHeader.IsMcp(req.FunctionContext.GetHttpContext());
|
||||
|
||||
/// <summary>
|
||||
/// Extract a safe, human-readable error message from known exception types.
|
||||
|
||||
@@ -34,6 +34,9 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
("managedByProTenantId", c => c.ManagedByProTenantId),
|
||||
("disabled", c => c.Disabled),
|
||||
("disabledReason", c => c.DisabledReason),
|
||||
// Operator MCP switch — a flag plus its reason, rendered by the GA tenant list.
|
||||
("mcpDisabled", c => c.McpDisabled),
|
||||
("mcpDisabledReason", c => c.McpDisabledReason),
|
||||
("onboardedAt", c => c.OnboardedAt),
|
||||
("onboardedBy", c => c.OnboardedBy),
|
||||
("lastUpdated", c => c.LastUpdated),
|
||||
|
||||
@@ -45,9 +45,7 @@ public class GlobalExceptionMiddleware : IFunctionsWorkerMiddleware
|
||||
_logger.LogError(ex, "Unhandled exception in {Function} [CorrelationId={CorrelationId}]",
|
||||
functionName, correlationId);
|
||||
|
||||
var isMcp = string.Equals(
|
||||
httpContext.Request.Headers["X-Client-Source"].FirstOrDefault(),
|
||||
"mcp", StringComparison.OrdinalIgnoreCase);
|
||||
var isMcp = ClientSourceHeader.IsMcp(httpContext);
|
||||
|
||||
// Same envelope as ResponseHelper.InternalServerErrorAsync: MCP clients get the
|
||||
// operation + recovery hint; the CLR exception type never leaves the process.
|
||||
|
||||
@@ -84,9 +84,7 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
return;
|
||||
}
|
||||
|
||||
var isMcpRequest = string.Equals(
|
||||
httpContext.Request.Headers["X-Client-Source"].FirstOrDefault(), "mcp", StringComparison.OrdinalIgnoreCase);
|
||||
if (!isMcpRequest)
|
||||
if (!ClientSourceHeader.IsMcp(httpContext))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
|
||||
@@ -92,7 +92,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
PolicyResult result;
|
||||
try
|
||||
{
|
||||
result = await DecideAsync(httpMethod, requestPath, queryTenantId, principal);
|
||||
result = await DecideAsync(httpMethod, requestPath, queryTenantId, principal, ClientSourceHeader.IsMcp(httpContext));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -122,7 +122,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
LogSanitizer.Clean(principal?.GetObjectId()), LogSanitizer.Clean(principal?.GetTenantId()), clientSource, mcpToolName);
|
||||
await ReportPrivilegedDenialAsync(context, result, httpMethod, requestPath, principal, clientSource, mcpToolName);
|
||||
// Envelope: the human message is `error`, the policy code (Forbidden, CrossTenantAccessDenied,
|
||||
// TenantSuspended, AuthenticationRequired, InsufficientPermissions) is `code`.
|
||||
// TenantSuspended, McpDisabled, AuthenticationRequired, InsufficientPermissions) is `code`.
|
||||
await ApiErrorWriter.WriteAsync(
|
||||
httpContext, context.GetCorrelationId(), (HttpStatusCode)result.StatusCode,
|
||||
result.ErrorCode ?? Constants.ApiErrorCodes.Forbidden, result.ErrorMessage ?? "Access denied.");
|
||||
@@ -204,9 +204,12 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
/// full decision — including the additive tenant-role resolution that drives RequestContext.IsTenantAdmin —
|
||||
/// is unit-testable without faking the worker's HttpContext feature. <see cref="Invoke"/> is a thin
|
||||
/// adapter that reads the request, calls this, and writes the response.
|
||||
/// <paramref name="isMcpClient"/> is the self-declared <c>X-Client-Source: mcp</c> marker: it only ever
|
||||
/// narrows (the tenant MCP switch), never grants.
|
||||
/// </summary>
|
||||
internal async Task<PolicyResult> DecideAsync(
|
||||
string httpMethod, string requestPath, string? queryTenantId, ClaimsPrincipal? principal)
|
||||
string httpMethod, string requestPath, string? queryTenantId, ClaimsPrincipal? principal,
|
||||
bool isMcpClient = false)
|
||||
{
|
||||
// Sanitized copies for logging only — never used for routing/authorization decisions.
|
||||
var logPath = LogSanitizer.Clean(requestPath);
|
||||
@@ -308,7 +311,8 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
// (SecurityValidator → 403 TenantDisabled). Cached, side-effect-free read; a missing row is not disabled.
|
||||
if (principal != null && !hasGlobalScope)
|
||||
{
|
||||
var suspended = await FindSuspendedTenantAsync(jwtTenantId, targetTenantId);
|
||||
var gateConfigs = await LoadGateConfigsAsync(jwtTenantId, targetTenantId);
|
||||
var suspended = gateConfigs.FirstOrDefault(c => c.IsCurrentlyDisabled());
|
||||
if (suspended != null)
|
||||
{
|
||||
_logger.LogWarning("[PolicyEnforcement] BLOCKED suspended tenant: user={User} tenant={Tenant} path={Path}",
|
||||
@@ -319,6 +323,24 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
: "Your tenant has been suspended. Please contact support for more information.",
|
||||
decision.UserIdentifier, decision.UserRole, "TenantSuspended", catalogEntry.Policy);
|
||||
}
|
||||
|
||||
// Tenant MCP switch (TenantConfiguration.McpDisabled, operator control) — the per-request half of
|
||||
// the gate McpUserService applies at the MCP front door. Same reach as the suspension gate, but only
|
||||
// for requests the MCP server forwards: a member of a closed tenant loses MCP everywhere, and no
|
||||
// MCP call reaches a closed tenant as its target (delegated / MSP reads included). Direct portal
|
||||
// or API use with a personal token is untouched — the switch closes the AI channel, not the data.
|
||||
if (isMcpClient)
|
||||
{
|
||||
var mcpClosed = gateConfigs.FirstOrDefault(c => c.McpDisabled);
|
||||
if (mcpClosed != null)
|
||||
{
|
||||
_logger.LogWarning("[PolicyEnforcement] BLOCKED MCP-disabled tenant: user={User} tenant={Tenant} path={Path}",
|
||||
LogSanitizer.Clean(decision.UserIdentifier), LogSanitizer.Clean(mcpClosed.TenantId), logPath);
|
||||
return PolicyResult.Deny((int)HttpStatusCode.Forbidden, "McpDisabled",
|
||||
McpUserService.McpDisabledMessage(mcpClosed),
|
||||
decision.UserIdentifier, decision.UserRole, "McpDisabled", catalogEntry.Policy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge delegated state from the two delegated admission paths:
|
||||
@@ -328,6 +350,11 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
// managed tenant set on the decision instead. Either way the caller is a delegated reader here.
|
||||
var admittedViaDelegatedSubset = decision.AllowedTenantIds != null;
|
||||
var effectiveAllowedTenantIds = allowedTenantIds ?? decision.AllowedTenantIds;
|
||||
// The bounded (MSP) set seen through MCP loses every tenant that closed its MCP surface — otherwise a
|
||||
// closed customer's data would still flow into the MSP's fleet aggregate. Narrowing only ever shrinks
|
||||
// the bound and never turns it into null (null = unbounded for the repository); an empty set is fine.
|
||||
if (isMcpClient && effectiveAllowedTenantIds != null)
|
||||
effectiveAllowedTenantIds = await WithoutMcpDisabledAsync(effectiveAllowedTenantIds);
|
||||
|
||||
var requestContext = new RequestContext
|
||||
{
|
||||
@@ -381,17 +408,30 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
/// <see cref="TenantConfiguration.IsCurrentlyDisabled"/> already treats an elapsed DisabledUntil as
|
||||
/// re-enabled, so the gate lifts on its own; auth/me persists the flip on the next login.
|
||||
/// </summary>
|
||||
private async Task<TenantConfiguration?> FindSuspendedTenantAsync(string jwtTenantId, string targetTenantId)
|
||||
private async Task<IReadOnlyList<TenantConfiguration>> LoadGateConfigsAsync(string jwtTenantId, string targetTenantId)
|
||||
{
|
||||
var (homeConfig, _) = await _tenantConfigService.TryGetConfigurationAsync(jwtTenantId);
|
||||
if (homeConfig.IsCurrentlyDisabled())
|
||||
return homeConfig;
|
||||
|
||||
if (string.Equals(targetTenantId, jwtTenantId, StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
return new[] { homeConfig };
|
||||
|
||||
var (targetConfig, _) = await _tenantConfigService.TryGetConfigurationAsync(targetTenantId);
|
||||
return targetConfig.IsCurrentlyDisabled() ? targetConfig : null;
|
||||
return new[] { homeConfig, targetConfig };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The bound minus every tenant whose MCP surface is closed (cached, side-effect-free reads — one per
|
||||
/// managed tenant, only on delegated MCP requests). Never null: see the isolation invariant at the call site.
|
||||
/// </summary>
|
||||
private async Task<IReadOnlyCollection<string>> WithoutMcpDisabledAsync(IReadOnlyCollection<string> tenantIds)
|
||||
{
|
||||
var admitted = new List<string>(tenantIds.Count);
|
||||
foreach (var tenantId in tenantIds)
|
||||
{
|
||||
var (config, _) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
|
||||
if (!config.McpDisabled)
|
||||
admitted.Add(tenantId);
|
||||
}
|
||||
return admitted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -27,6 +27,13 @@ namespace AutopilotMonitor.Functions.Services;
|
||||
/// mutable, so a foreign-tenant (or recycled) token carrying a whitelisted UPN string resolves to NO row.
|
||||
/// Rows without a binding are inert, exactly like GlobalAdmins rows (no grandfathering).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A tenant whose configuration carries <see cref="TenantConfiguration.McpDisabled"/> (operator control,
|
||||
/// Global Admin → Tenant Management) is closed for MCP: every caller whose token was issued for that tenant
|
||||
/// is denied unless they hold a platform role — delegated (MSP) scope and McpUsers rows included, mirroring
|
||||
/// the suspension gate. The per-request half of the same switch (the tenant as a data TARGET) lives in
|
||||
/// <c>PolicyEnforcementMiddleware</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class McpUserService
|
||||
{
|
||||
@@ -38,6 +45,7 @@ public class McpUserService
|
||||
private readonly DelegatedAdminService _delegatedAdminService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly TenantMemberRoleResolver _memberRoleResolver;
|
||||
private readonly TenantConfigurationService _tenantConfigService;
|
||||
// Per-process cache for the McpUsers whitelist lookup. On scaled-out Flex Consumption an add/remove
|
||||
// of an McpUsers row only invalidates the mutating instance, so other instances serve a stale
|
||||
// allow/deny until expiry. A short TTL caps that cross-instance window so an MCP-access grant/revoke
|
||||
@@ -53,7 +61,8 @@ public class McpUserService
|
||||
GlobalAdminService globalAdminService,
|
||||
DelegatedAdminService delegatedAdminService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
TenantMemberRoleResolver memberRoleResolver)
|
||||
TenantMemberRoleResolver memberRoleResolver,
|
||||
TenantConfigurationService tenantConfigService)
|
||||
{
|
||||
_adminRepo = adminRepo;
|
||||
_bindings = bindings;
|
||||
@@ -63,6 +72,7 @@ public class McpUserService
|
||||
_delegatedAdminService = delegatedAdminService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_memberRoleResolver = memberRoleResolver;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -134,6 +144,18 @@ public class McpUserService
|
||||
return McpAccessCheckResult.Allowed(
|
||||
upn, globalRole, isGlobalAdmin, globalRole, delegatedTenantIds, delegatedRole);
|
||||
|
||||
// Operator control (tenant-level MCP switch): the tenant the token was issued for has MCP disabled →
|
||||
// denied for everyone below the platform roles, delegated scope and McpUsers rows included — the
|
||||
// same reach a suspension takes away. Side-effect-free cached read; a tenant without a config row
|
||||
// (never onboarded) is simply not disabled. Checked here, at the front door, so the caller gets one
|
||||
// clear message on connect instead of a 403 per tool call from the per-request gate.
|
||||
if (!string.IsNullOrWhiteSpace(homeTenantId))
|
||||
{
|
||||
var (homeConfig, _) = await _tenantConfigService.TryGetConfigurationAsync(homeTenantId);
|
||||
if (homeConfig.McpDisabled)
|
||||
return McpAccessCheckResult.Denied(McpDisabledMessage(homeConfig));
|
||||
}
|
||||
|
||||
// A delegated (MSP) admin with an active scope is granted MCP access automatically — "delegated =
|
||||
// scoped global", and they are already curated via the Delegated Admins UI, so a separate enabled
|
||||
// McpUsers row would be redundant friction. Their reach is bounded client- and server-side to the
|
||||
@@ -149,10 +171,12 @@ public class McpUserService
|
||||
return McpAccessCheckResult.Allowed(upn, "McpUser", false, null, delegatedTenantIds, delegatedRole);
|
||||
|
||||
if (policy != McpAccessPolicy.AllMembers)
|
||||
// Surfaced to the end user by the MCP server's access guard. Keep it
|
||||
// self-explanatory so a denied colleague understands they simply need
|
||||
// Surfaced verbatim to the end user by the MCP server's access guard. Keep it
|
||||
// self-explanatory, advice included, so a denied colleague understands they simply need
|
||||
// to be whitelisted, rather than reading it as an auth failure.
|
||||
return McpAccessCheckResult.Denied("User not enabled for MCP usage (account is not on the MCP whitelist)");
|
||||
return McpAccessCheckResult.Denied(Constants.PrincipalKeys.IsApplication(upn)
|
||||
? "Service principal not enabled for MCP usage (the application is not on the MCP whitelist — ask the MCP server administrator to enable it)"
|
||||
: "User not enabled for MCP usage (account is not on the MCP whitelist — ask the MCP server administrator to whitelist your account)");
|
||||
|
||||
// AllMembers: "member" = an effective role (Admin / Operator / Viewer) in the tenant the token was
|
||||
// issued for — the same resolver the policy middleware uses on every tenant endpoint. A token from
|
||||
@@ -168,6 +192,15 @@ public class McpUserService
|
||||
: "User not enabled for MCP usage (account has no role in its organization's tenant — ask a Tenant Admin to add you)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The 403 text for a tenant that has MCP switched off: the operator's reason when one was given, else a
|
||||
/// neutral default. Shared with the per-request gate so both halves of the switch speak the same words.
|
||||
/// </summary>
|
||||
public static string McpDisabledMessage(TenantConfiguration config)
|
||||
=> !string.IsNullOrWhiteSpace(config.McpDisabledReason)
|
||||
? config.McpDisabledReason!
|
||||
: "MCP access is disabled for your organization. Contact your administrator.";
|
||||
|
||||
/// <summary>
|
||||
/// Whitelists a UPN, binding it to its home tenant (and object id, when known) FIRST — a row without a
|
||||
/// binding is inert, and a binding conflict (UPN already homed elsewhere) aborts before any row is written.
|
||||
|
||||
@@ -142,6 +142,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
internal static readonly HashSet<string> GaOnlyFields = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Disabled", "DisabledReason", "DisabledUntil",
|
||||
"McpDisabled", "McpDisabledReason",
|
||||
"AllowInsecureAgentRequests", "BootstrapTokenEnabled",
|
||||
"UnrestrictedModeEnabled", "EntraAppRolesEnabled",
|
||||
"EnableEspContinueAnywayObservation",
|
||||
|
||||
@@ -199,16 +199,32 @@ describe('accessGuard — 403 (authorization + fail-closed)', () => {
|
||||
|
||||
it('gives a genuine whitelist denial an actionable message naming the user', async () => {
|
||||
const upn = uniqueUpn();
|
||||
stubBackend({ body: { allowed: false, reason: 'User not enabled for MCP usage' } });
|
||||
// The backend's reason carries cause AND remedy; the guard surfaces it verbatim.
|
||||
const reason = 'User not enabled for MCP usage (account is not on the MCP whitelist — ask the MCP server administrator to whitelist your account)';
|
||||
stubBackend({ body: { allowed: false, reason } });
|
||||
const out = await runGuard(mockReq(`Bearer ${validToken(upn)}`));
|
||||
expect(out.status).toBe(403);
|
||||
const body = out.body as { error: string; message: string };
|
||||
expect(body.error).toBe('User not enabled for MCP usage');
|
||||
// The message must name the account and point at the fix (get whitelisted).
|
||||
expect(body.message).toContain(upn);
|
||||
expect(body.message).toContain(reason);
|
||||
expect(body.message).toMatch(/whitelist/i);
|
||||
});
|
||||
|
||||
it('surfaces an organization-level MCP switch-off without whitelist advice', async () => {
|
||||
// Tenant closed for MCP by the operator: the recorded reason is the whole story — telling the
|
||||
// user to "get whitelisted" would send them chasing a fix that cannot help.
|
||||
const upn = uniqueUpn();
|
||||
stubBackend({ body: { allowed: false, reason: 'No AI access by customer request' } });
|
||||
const out = await runGuard(mockReq(`Bearer ${validToken(upn)}`));
|
||||
expect(out.status).toBe(403);
|
||||
const body = out.body as { error: string; reason: string; message: string };
|
||||
expect(body.reason).toBe('No AI access by customer request');
|
||||
expect(body.message).toContain('No AI access by customer request');
|
||||
expect(body.message).not.toMatch(/whitelist/i);
|
||||
});
|
||||
|
||||
it('does NOT label an infrastructure failure as a whitelist problem', async () => {
|
||||
// A cold/unreachable backend must not tell the user they are "not enabled".
|
||||
stubBackend({ reject: true });
|
||||
@@ -394,7 +410,8 @@ describe('accessGuard — application principals (app-only tokens)', () => {
|
||||
|
||||
it('names the service principal and the Members fix in a denial', async () => {
|
||||
const appId = uniqueAppId();
|
||||
stubBackend({ body: { allowed: false, reason: 'Service principal not enabled for MCP usage' } });
|
||||
const reason = 'Service principal not enabled for MCP usage (the application is not a member of its tenant — ask a Tenant Admin to add it under Members as a service principal)';
|
||||
stubBackend({ body: { allowed: false, reason } });
|
||||
|
||||
const out = await runGuard(mockReq(`Bearer ${appToken(appId)}`));
|
||||
|
||||
@@ -402,6 +419,7 @@ describe('accessGuard — application principals (app-only tokens)', () => {
|
||||
const body = out.body as { error: string; message: string };
|
||||
expect(body.error).toBe('Service principal not enabled for MCP usage');
|
||||
expect(body.message).toContain(appId);
|
||||
expect(body.message).toContain(reason);
|
||||
expect(body.message).toMatch(/Members/);
|
||||
expect(body.message).not.toMatch(/whitelist/i);
|
||||
});
|
||||
|
||||
@@ -453,28 +453,19 @@ export function accessGuard(req: Request, res: Response, next: NextFunction): vo
|
||||
return;
|
||||
}
|
||||
console.error(`[mcp-auth] 403 not-whitelisted (method=${rpcMethod}, upn=${upn})`);
|
||||
// Genuine authorization denial — most commonly the user's account is not
|
||||
// on the MCP whitelist. Spell that out and tell them what to do, so they
|
||||
// can ask the MCP server owner to enable their account instead of being
|
||||
// left guessing why authentication "failed". A service principal is
|
||||
// granted under Members (as a Viewer) rather than whitelisted.
|
||||
res.status(403).json(isApplicationKey(upn)
|
||||
? {
|
||||
error: 'Service principal not enabled for MCP usage',
|
||||
reason: result.reason,
|
||||
message:
|
||||
`The service principal (${upn.slice(APPLICATION_KEY_PREFIX.length)}) is not enabled to use this ` +
|
||||
'Autopilot Monitor MCP server. Ask a Tenant Admin to add it under Members as a service principal ' +
|
||||
'(read-only), then retry.',
|
||||
}
|
||||
: {
|
||||
error: 'User not enabled for MCP usage',
|
||||
reason: result.reason,
|
||||
message:
|
||||
`Your account (${upn}) is not enabled to use this Autopilot Monitor MCP server. ` +
|
||||
'Ask the MCP server owner/administrator to whitelist your account for MCP access, ' +
|
||||
'then reconnect.',
|
||||
});
|
||||
// Genuine authorization denial. The backend's reason names the cause AND the remedy
|
||||
// (not on the whitelist → ask the MCP server administrator; no role in the tenant → ask a
|
||||
// Tenant Admin; the organization switched MCP off → the operator's recorded reason), so it
|
||||
// is surfaced verbatim instead of a fixed "ask to be whitelisted" that would send a user
|
||||
// whose whole organization is closed for MCP chasing the wrong fix.
|
||||
const subject = isApplicationKey(upn)
|
||||
? `The service principal (${upn.slice(APPLICATION_KEY_PREFIX.length)})`
|
||||
: `Your account (${upn})`;
|
||||
res.status(403).json({
|
||||
error: isApplicationKey(upn) ? 'Service principal not enabled for MCP usage' : 'User not enabled for MCP usage',
|
||||
reason: result.reason,
|
||||
message: `${subject} cannot use this Autopilot Monitor MCP server: ${result.reason}. Reconnect once access has been granted.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -4505,6 +4505,10 @@ export interface TenantConfiguration {
|
||||
disabledReason?: string | null;
|
||||
/** Optional date/time until which the tenant is disabled If set and in the past, the tenant can be automatically re-enabled If null, the tenant remains disabled until manually re-enabled */
|
||||
disabledUntil?: string | null;
|
||||
/** Operator control: when true, the MCP (AI assistant) surface is closed for this tenant — its members cannot open an MCP session and no MCP call may read it as a target, delegated (MSP) reads included. Platform roles bypass it. Blocks only requests marked X-Client-Source: mcp; portal and direct API use with a personal token are unaffected. Global Admin only. Default: false */
|
||||
mcpDisabled: boolean;
|
||||
/** Optional reason why MCP access was disabled. Shown to the caller in the 403 response. */
|
||||
mcpDisabledReason?: string | null;
|
||||
/** Optional per-tenant override for the device (agent/cert) API rate limit. If null, the effective limit is the global AdminConfiguration.GlobalRateLimitRequestsPerMinute. If set, this value takes precedence. Global-Admin-only (see UpdateTenantConfigurationFunction GA-gate). */
|
||||
customRateLimitRequestsPerMinute?: number | null;
|
||||
/** Optional per-tenant override for the user (portal/JWT) API rate limit applied to standard users (Tenant Admins, Operators, Viewers). If null, the effective limit is the global AdminConfiguration.UserRateLimitRequestsPerMinute. Global-Admin-only. Note: Global Admins are rate-limited by the global GlobalAdminRateLimitRequestsPerMinute (cross-tenant), so this override does not apply to them. */
|
||||
|
||||
@@ -133,6 +133,20 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public DateTime? DisabledUntil { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Operator control: when true, the MCP (AI assistant) surface is closed for this tenant — its
|
||||
/// members cannot open an MCP session and no MCP call may read it as a target, delegated (MSP)
|
||||
/// reads included. Platform roles bypass it. Blocks only requests marked X-Client-Source: mcp;
|
||||
/// portal and direct API use with a personal token are unaffected. Global Admin only.
|
||||
/// Default: false
|
||||
/// </summary>
|
||||
public bool McpDisabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Optional reason why MCP access was disabled. Shown to the caller in the 403 response.
|
||||
/// </summary>
|
||||
public string? McpDisabledReason { get; set; }
|
||||
|
||||
// ===== SECURITY SETTINGS =====
|
||||
|
||||
/// <summary>
|
||||
@@ -1059,6 +1073,8 @@ namespace AutopilotMonitor.Shared.Models
|
||||
Disabled = false,
|
||||
DisabledReason = null,
|
||||
DisabledUntil = null,
|
||||
McpDisabled = false,
|
||||
McpDisabledReason = null,
|
||||
CustomRateLimitRequestsPerMinute = null,
|
||||
CustomUserRateLimitRequestsPerMinute = null,
|
||||
ManufacturerWhitelist = "Dell*,HP*,Lenovo*,Microsoft Corporation",
|
||||
|
||||
@@ -588,6 +588,14 @@ function TenantManagementSectionInner({
|
||||
Suspended
|
||||
</span>
|
||||
)}
|
||||
{tenant.mcpDisabled && (
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-800"
|
||||
title={tenant.mcpDisabledReason || 'MCP access is switched off for this tenant'}
|
||||
>
|
||||
MCP off
|
||||
</span>
|
||||
)}
|
||||
{!previewApproved.has(tenant.tenantId) && (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-amber-100 text-amber-800">
|
||||
Waitlist
|
||||
@@ -764,6 +772,40 @@ function TenantManagementSectionInner({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* MCP switch (operator control, GA-only field — saved by the modal's generic Save) */}
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<h3 className="font-semibold text-amber-900 mb-1">MCP Access</h3>
|
||||
<p className="text-xs text-gray-600 mb-3">
|
||||
Closes the MCP (AI assistant) surface for this tenant: its members cannot connect,
|
||||
and no MCP call can read it as a target, delegated (MSP) reads included. Portal and
|
||||
API access with a personal token stay unchanged. Platform roles bypass the switch.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editingTenant.mcpDisabled}
|
||||
onChange={(e) => setEditingTenant({ ...editingTenant, mcpDisabled: e.target.checked })}
|
||||
className="w-4 h-4 text-amber-600 border-gray-300 rounded focus:ring-amber-500"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700">Disable MCP access</span>
|
||||
</label>
|
||||
|
||||
{editingTenant.mcpDisabled && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Reason</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingTenant.mcpDisabledReason || ''}
|
||||
onChange={(e) => setEditingTenant({ ...editingTenant, mcpDisabledReason: e.target.value })}
|
||||
placeholder="Optional: shown to every caller that is refused"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-amber-500 focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plan & Trial (own save path — PATCH plan endpoint; the modal's generic Save
|
||||
does not touch these fields, the backend preserves them on PUT) */}
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
|
||||
@@ -26,6 +26,8 @@ interface TenantConfig {
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
disabledUntil?: string;
|
||||
mcpDisabled?: boolean;
|
||||
mcpDisabledReason?: string;
|
||||
|
||||
// Security
|
||||
customRateLimitRequestsPerMinute?: number | null;
|
||||
@@ -111,6 +113,7 @@ interface TenantConfig {
|
||||
|
||||
const DEFAULTS: Record<string, unknown> = {
|
||||
disabled: false,
|
||||
mcpDisabled: false,
|
||||
customRateLimitRequestsPerMinute: null,
|
||||
customUserRateLimitRequestsPerMinute: null,
|
||||
manufacturerWhitelist: 'Dell*,HP*,Lenovo*,Microsoft Corporation',
|
||||
@@ -512,6 +515,11 @@ export function SectionTenantConfigReport() {
|
||||
{config.disabledUntil ? ` (until ${formatDate(config.disabledUntil)})` : ''}
|
||||
</div>
|
||||
)}
|
||||
{config.mcpDisabled && (
|
||||
<div className="mt-3 p-2 bg-amber-100 dark:bg-amber-900/40 rounded text-sm text-amber-800 dark:text-amber-300 font-medium">
|
||||
MCP ACCESS DISABLED{config.mcpDisabledReason ? `: ${config.mcpDisabledReason}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
|
||||
@@ -78,6 +78,8 @@ export const SHARED_MANIFEST = {
|
||||
"disabled",
|
||||
"disabledReason",
|
||||
"disabledUntil",
|
||||
"mcpDisabled",
|
||||
"mcpDisabledReason",
|
||||
"customRateLimitRequestsPerMinute",
|
||||
"customUserRateLimitRequestsPerMinute",
|
||||
"planTier",
|
||||
|
||||
@@ -74,6 +74,8 @@
|
||||
"disabled",
|
||||
"disabledReason",
|
||||
"disabledUntil",
|
||||
"mcpDisabled",
|
||||
"mcpDisabledReason",
|
||||
"customRateLimitRequestsPerMinute",
|
||||
"customUserRateLimitRequestsPerMinute",
|
||||
"planTier",
|
||||
@@ -20407,6 +20409,24 @@
|
||||
"optional": true,
|
||||
"doc": "Optional date/time until which the tenant is disabled If set and in the past, the tenant can be automatically re-enabled If null, the tenant remains disabled until manually re-enabled"
|
||||
},
|
||||
{
|
||||
"name": "mcpDisabled",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "boolean"
|
||||
},
|
||||
"doc": "Operator control: when true, the MCP (AI assistant) surface is closed for this tenant — its members cannot open an MCP session and no MCP call may read it as a target, delegated (MSP) reads included. Platform roles bypass it. Blocks only requests marked X-Client-Source: mcp; portal and direct API use with a personal token are unaffected. Global Admin only. Default: false"
|
||||
},
|
||||
{
|
||||
"name": "mcpDisabledReason",
|
||||
"type": {
|
||||
"kind": "primitive",
|
||||
"name": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"optional": true,
|
||||
"doc": "Optional reason why MCP access was disabled. Shown to the caller in the 403 response."
|
||||
},
|
||||
{
|
||||
"name": "customRateLimitRequestsPerMinute",
|
||||
"type": {
|
||||
|
||||
@@ -4505,6 +4505,10 @@ export interface TenantConfiguration {
|
||||
disabledReason?: string | null;
|
||||
/** Optional date/time until which the tenant is disabled If set and in the past, the tenant can be automatically re-enabled If null, the tenant remains disabled until manually re-enabled */
|
||||
disabledUntil?: string | null;
|
||||
/** Operator control: when true, the MCP (AI assistant) surface is closed for this tenant — its members cannot open an MCP session and no MCP call may read it as a target, delegated (MSP) reads included. Platform roles bypass it. Blocks only requests marked X-Client-Source: mcp; portal and direct API use with a personal token are unaffected. Global Admin only. Default: false */
|
||||
mcpDisabled: boolean;
|
||||
/** Optional reason why MCP access was disabled. Shown to the caller in the 403 response. */
|
||||
mcpDisabledReason?: string | null;
|
||||
/** Optional per-tenant override for the device (agent/cert) API rate limit. If null, the effective limit is the global AdminConfiguration.GlobalRateLimitRequestsPerMinute. If set, this value takes precedence. Global-Admin-only (see UpdateTenantConfigurationFunction GA-gate). */
|
||||
customRateLimitRequestsPerMinute?: number | null;
|
||||
/** Optional per-tenant override for the user (portal/JWT) API rate limit applied to standard users (Tenant Admins, Operators, Viewers). If null, the effective limit is the global AdminConfiguration.UserRateLimitRequestsPerMinute. Global-Admin-only. Note: Global Admins are rate-limited by the global GlobalAdminRateLimitRequestsPerMinute (cross-tenant), so this override does not apply to them. */
|
||||
|
||||
Reference in New Issue
Block a user