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. */