From 16ca3a059c77f7fe967096cd4a5356287228a03e Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sun, 6 Sep 2026 14:01:01 +0200 Subject: [PATCH] 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 --- .claude/commands | 2 +- internal | 2 +- .../ApplicationPrincipalTests.cs | 3 +- .../AuthFunctionSideEffectTests.cs | 2 +- .../McpQuotaServiceTests.cs | 2 +- .../McpUserServiceAllMembersTests.cs | 3 +- .../McpUserServiceDelegatedTests.cs | 3 +- .../McpUserServiceIdentityBindingTests.cs | 9 +- .../McpUserServiceTenantMcpSwitchTests.cs | 166 ++++++++++++++++++ .../PolicyEnforcementMiddlewareTests.cs | 111 ++++++++++++ .../TenantConfigProjectionTests.cs | 2 +- .../TableStorage/TableConfigRepository.cs | 4 + .../UpdateTenantConfigurationFunction.cs | 5 +- .../Helpers/ClientSourceHeader.cs | 19 ++ .../Helpers/ResponseHelper.cs | 8 +- .../Helpers/TenantConfigProjection.cs | 3 + .../Middleware/GlobalExceptionMiddleware.cs | 4 +- .../McpQuotaEnforcementMiddleware.cs | 4 +- .../Middleware/PolicyEnforcementMiddleware.cs | 60 +++++-- .../Services/McpUserService.cs | 41 ++++- .../Services/TenantConfigPatchService.cs | 1 + .../src/__tests__/access-guard.test.ts | 22 ++- .../autopilot-monitor-mcp/src/access-guard.ts | 35 ++-- .../src/generated/wire-types.generated.ts | 4 + .../Models/Config/TenantConfiguration.cs | 16 ++ .../components/TenantManagementSection.tsx | 42 +++++ .../sections/SectionTenantConfigReport.tsx | 8 + .../utils/shared-manifests.generated.ts | 2 + .../utils/shared-manifests.json | 20 +++ .../utils/wire-types.generated.ts | 4 + 30 files changed, 544 insertions(+), 63 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceTenantMcpSwitchTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Helpers/ClientSourceHeader.cs diff --git a/.claude/commands b/.claude/commands index 165a6ec9..f1a25adf 160000 --- a/.claude/commands +++ b/.claude/commands @@ -1 +1 @@ -Subproject commit 165a6ec9cb034b32a6a3bce98763fa1a1378e1b5 +Subproject commit f1a25adfa4d2f057f3dfd9aabb2d38f0c386f474 diff --git a/internal b/internal index 267628ea..65fed34b 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 267628eab137f0525ffa4cee27c1947706930b52 +Subproject commit 65fed34b983b0a9ebfc8bb0c1187f34a82349100 diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ApplicationPrincipalTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ApplicationPrincipalTests.cs index 060ab821..134162db 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/ApplicationPrincipalTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/ApplicationPrincipalTests.cs @@ -356,7 +356,8 @@ public class ApplicationPrincipalTests var members = new StubTenantMemberRoleResolver(); var sut = new McpUserService( adminRepo.Object, bindings, cache, NullLogger.Instance, - globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, members); + globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, members, + new TenantConfigurationService(Mock.Of(), NullLogger.Instance, cache)); return (sut, members); } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs index 08d77f60..3e0af720 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs @@ -83,7 +83,7 @@ public class AuthFunctionSideEffectTests var mcpUserMock = new Mock( adminRepo, new StubAdminIdentityBindingService(bound: true), cache, Mock.Of>(), 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(); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs index 94e0bcd5..84752bc7 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs @@ -138,7 +138,7 @@ public class McpQuotaServiceTests cache ??= new MemoryCache(new MemoryCacheOptions()); var mcpUserService = new McpUserService( adminRepo.Object, new StubAdminIdentityBindingService(bound), cache, NullLogger.Instance, - globalAdminService: null!, delegatedAdminService: null!, adminConfigService: null!, memberRoleResolver: null!); + globalAdminService: null!, delegatedAdminService: null!, adminConfigService: null!, memberRoleResolver: null!, tenantConfigService: null!); var configRepo = new Mock(); configRepo.Setup(r => r.GetAdminConfigurationAsync()) diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceAllMembersTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceAllMembersTests.cs index 474bfe48..f8107703 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceAllMembersTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceAllMembersTests.cs @@ -50,7 +50,8 @@ public class McpUserServiceAllMembersTests _sut = new McpUserService( _adminRepo.Object, bindings, cache, NullLogger.Instance, - _globalAdmin.Object, _delegatedAdmin.Object, adminConfig.Object, _memberRoles); + _globalAdmin.Object, _delegatedAdmin.Object, adminConfig.Object, _memberRoles, + new TenantConfigurationService(Mock.Of(), NullLogger.Instance, cache)); } private void MemberOf(string tenantId, string role) => diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceDelegatedTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceDelegatedTests.cs index 0c8dedcf..50f724a2 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceDelegatedTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceDelegatedTests.cs @@ -54,7 +54,8 @@ public class McpUserServiceDelegatedTests _sut = new McpUserService( _adminRepo.Object, bindings, cache, NullLogger.Instance, - _globalAdmin.Object, _delegatedAdmin.Object, _adminConfig.Object, _memberRoles); + _globalAdmin.Object, _delegatedAdmin.Object, _adminConfig.Object, _memberRoles, + new TenantConfigurationService(Mock.Of(), NullLogger.Instance, cache)); } private void SetPolicy(McpAccessPolicy policy) => diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceIdentityBindingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceIdentityBindingTests.cs index 7e553c1b..c020761b 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceIdentityBindingTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceIdentityBindingTests.cs @@ -59,7 +59,8 @@ public class McpUserServiceIdentityBindingTests _sut = new McpUserService( _adminRepo.Object, _bindings, cache, NullLogger.Instance, - globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, _memberRoles); + globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, _memberRoles, + new TenantConfigurationService(Mock.Of(), NullLogger.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.Instance, - globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, _memberRoles); + globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, _memberRoles, + new TenantConfigurationService(Mock.Of(), NullLogger.Instance, cache)); _adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny())).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.Instance, - globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, _memberRoles); + globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, _memberRoles, + new TenantConfigurationService(Mock.Of(), NullLogger.Instance, cache)); SetRow(enabled: false); var bound = await sut.IsAllowedAsync(Upn, HomeTenant, Oid); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceTenantMcpSwitchTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceTenantMcpSwitchTests.cs new file mode 100644 index 00000000..9860d70b --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceTenantMcpSwitchTests.cs @@ -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; + +/// +/// The tenant-level MCP switch (, 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. +/// +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 _adminRepo = new(); + private readonly Mock _configRepo = new(); + private readonly Mock _globalAdmin; + private readonly Mock _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( + _adminRepo.Object, bindings, cache, NullLogger.Instance) { CallBase = false }; + _globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny())).ReturnsAsync((string?)null); + _delegatedAdmin = new Mock( + _adminRepo.Object, bindings, new StubTenantEntitlementService(TenantEdition.Pro), + cache, NullLogger.Instance) { CallBase = false }; + _delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny())).ReturnsAsync(DelegatedScope.Empty); + var adminConfig = new Mock( + Mock.Of(), NullLogger.Instance, cache) { CallBase = false }; + adminConfig.Setup(x => x.GetConfigurationAsync()) + .ReturnsAsync(new AdminConfiguration { McpAccessPolicy = McpAccessPolicy.AllMembers.ToString() }); + _adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny())).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())).ReturnsAsync((TenantConfiguration?)null); + var tenantConfig = new TenantConfigurationService(_configRepo.Object, NullLogger.Instance, cache); + + _sut = new McpUserService( + _adminRepo.Object, bindings, cache, NullLogger.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())) + .ReturnsAsync(new DelegatedScope(new Dictionary + { + [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())) + .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()), Times.Never); + _configRepo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny(), It.IsAny(), It.IsAny()), 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 = " " })); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs index 367fbe5d..89b0bb6c 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs @@ -1676,4 +1676,115 @@ public class PolicyEnforcementMiddlewareTests Assert.True(result.Allowed); h.ConfigRepo.Verify(r => r.GetTenantConfigurationAsync(It.IsAny()), 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); + } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs index 35a762c7..31722306 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs @@ -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", }; diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index a6e59496..bbe8172b 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -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", diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs index 8aa6c71c..94ea1f30 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs @@ -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 diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/ClientSourceHeader.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/ClientSourceHeader.cs new file mode 100644 index 00000000..771f5b38 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/ClientSourceHeader.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Http; + +namespace AutopilotMonitor.Functions.Helpers; + +/// +/// The self-declared X-Client-Source request header. The MCP server stamps mcp 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. +/// +internal static class ClientSourceHeader +{ + public const string HeaderName = "X-Client-Source"; + public const string Mcp = "mcp"; + + /// True when the request declares itself as forwarded by the MCP server. + public static bool IsMcp(HttpContext? httpContext) + => httpContext != null && string.Equals( + httpContext.Request.Headers[HeaderName].FirstOrDefault(), Mcp, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/ResponseHelper.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/ResponseHelper.cs index edcc4adc..8c71d8a3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/ResponseHelper.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/ResponseHelper.cs @@ -134,13 +134,7 @@ public static class ResponseHelper /// Detect MCP clients via the X-Client-Source header set by the MCP server. 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()); /// /// Extract a safe, human-readable error message from known exception types. diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs index 3045149b..ecd40d56 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs @@ -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), diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/GlobalExceptionMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/GlobalExceptionMiddleware.cs index f484cf8a..d7436903 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/GlobalExceptionMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/GlobalExceptionMiddleware.cs @@ -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. diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs index a0e28abe..a4acb804 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs @@ -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; diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs index 3ff4e73a..946c2443 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs @@ -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. is a thin /// adapter that reads the request, calls this, and writes the response. + /// is the self-declared X-Client-Source: mcp marker: it only ever + /// narrows (the tenant MCP switch), never grants. /// internal async Task 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 /// already treats an elapsed DisabledUntil as /// re-enabled, so the gate lifts on its own; auth/me persists the flip on the next login. /// - private async Task FindSuspendedTenantAsync(string jwtTenantId, string targetTenantId) + private async Task> 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 }; + } + + /// + /// 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. + /// + private async Task> WithoutMcpDisabledAsync(IReadOnlyCollection tenantIds) + { + var admitted = new List(tenantIds.Count); + foreach (var tenantId in tenantIds) + { + var (config, _) = await _tenantConfigService.TryGetConfigurationAsync(tenantId); + if (!config.McpDisabled) + admitted.Add(tenantId); + } + return admitted; } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs b/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs index 89fd3342..76f47e6d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs @@ -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). /// +/// +/// A tenant whose configuration carries (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 +/// PolicyEnforcementMiddleware. +/// /// 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; } /// @@ -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)"); } + /// + /// 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. + /// + public static string McpDisabledMessage(TenantConfiguration config) + => !string.IsNullOrWhiteSpace(config.McpDisabledReason) + ? config.McpDisabledReason! + : "MCP access is disabled for your organization. Contact your administrator."; + /// /// 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. diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs index f9840b86..649d66db 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs @@ -142,6 +142,7 @@ namespace AutopilotMonitor.Functions.Services internal static readonly HashSet GaOnlyFields = new(StringComparer.OrdinalIgnoreCase) { "Disabled", "DisabledReason", "DisabledUntil", + "McpDisabled", "McpDisabledReason", "AllowInsecureAgentRequests", "BootstrapTokenEnabled", "UnrestrictedModeEnabled", "EntraAppRolesEnabled", "EnableEspContinueAnywayObservation", diff --git a/src/McpServer/autopilot-monitor-mcp/src/__tests__/access-guard.test.ts b/src/McpServer/autopilot-monitor-mcp/src/__tests__/access-guard.test.ts index 833c7570..dfd9fbcb 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/__tests__/access-guard.test.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/__tests__/access-guard.test.ts @@ -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); }); diff --git a/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts b/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts index 7cd01260..181e58a1 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/access-guard.ts @@ -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; } diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts index f4ed5e16..35f4a3cd 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts @@ -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. */ diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index 607e9592..809bde0b 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -133,6 +133,20 @@ namespace AutopilotMonitor.Shared.Models /// public DateTime? DisabledUntil { get; set; } + /// + /// 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 + /// + public bool McpDisabled { get; set; } = false; + + /// + /// Optional reason why MCP access was disabled. Shown to the caller in the 403 response. + /// + public string? McpDisabledReason { get; set; } + // ===== SECURITY SETTINGS ===== /// @@ -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", diff --git a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx index 7852e36e..3af39db3 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx @@ -588,6 +588,14 @@ function TenantManagementSectionInner({ Suspended )} + {tenant.mcpDisabled && ( + + MCP off + + )} {!previewApproved.has(tenant.tenantId) && ( Waitlist @@ -764,6 +772,40 @@ function TenantManagementSectionInner({ + {/* MCP switch (operator control, GA-only field — saved by the modal's generic Save) */} +
+

MCP Access

+

+ 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. +

+
+ + + {editingTenant.mcpDisabled && ( +
+ + 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" + /> +
+ )} +
+
+ {/* Plan & Trial (own save path — PATCH plan endpoint; the modal's generic Save does not touch these fields, the backend preserves them on PUT) */}
diff --git a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx index 7ea54c6e..e0bf6726 100644 --- a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx @@ -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 = { 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)})` : ''}
)} + {config.mcpDisabled && ( +
+ MCP ACCESS DISABLED{config.mcpDisabledReason ? `: ${config.mcpDisabledReason}` : ''} +
+ )} {/* Legend */} diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts index ab0fc061..5e103834 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts @@ -78,6 +78,8 @@ export const SHARED_MANIFEST = { "disabled", "disabledReason", "disabledUntil", + "mcpDisabled", + "mcpDisabledReason", "customRateLimitRequestsPerMinute", "customUserRateLimitRequestsPerMinute", "planTier", diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 3149a2ed..6c0d9974 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -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": { diff --git a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts index 5d1d9ac8..50af5153 100644 --- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts @@ -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. */