mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
fix(mcp): AllMembers grants tenant members only, not every authenticated token; per-user overrides stay visible
McpAccessPolicy.AllMembers returned Allowed for any valid JWT, so a Progress Portal end-user without a role could open an MCP session. The AllMembers path now requires an effective role (Admin / Operator / Viewer) in the token's tenant, resolved by the new TenantMemberRoleResolver that the policy middleware shares (table first, Entra app-role claim when the tenant opted in). Explicit grants (platform role, delegated scope, enabled McpUsers row) and the Disabled-row kill-switch keep working in a policy-independent order; auth/mcp and auth/me hand the token's app roles through. The MCP Users settings section shows the user list under AllMembers too, as the per-user override list (individual usage plan, disable = block). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+1
-1
Submodule docs updated: a3af579f8d...bf2371a89c
+2
-1
@@ -79,7 +79,8 @@ public class AdminIdentityBindingAuthorizationTests
|
||||
return new Harness
|
||||
{
|
||||
Middleware = new PolicyEnforcementMiddleware(
|
||||
NullLogger<PolicyEnforcementMiddleware>.Instance, globalAdmin, delegatedAdmin, tenantAdmins, tenantConfig),
|
||||
NullLogger<PolicyEnforcementMiddleware>.Instance, globalAdmin, delegatedAdmin,
|
||||
new TenantMemberRoleResolver(tenantAdmins, tenantConfig), tenantConfig),
|
||||
Repo = repo,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,7 +82,8 @@ public class AuthFunctionSideEffectTests
|
||||
|
||||
var mcpUserMock = new Mock<McpUserService>(
|
||||
adminRepo, new StubAdminIdentityBindingService(bound: true), cache, Mock.Of<ILogger<McpUserService>>(),
|
||||
globalAdminMock.Object, delegatedAdminMock.Object, adminConfigService.Object)
|
||||
globalAdminMock.Object, delegatedAdminMock.Object, adminConfigService.Object,
|
||||
new TenantMemberRoleResolver(_tenantAdminsMock.Object, _tenantConfigMock.Object))
|
||||
{ CallBase = false };
|
||||
|
||||
_metricsRepoMock = new Mock<IMetricsRepository>();
|
||||
|
||||
@@ -82,7 +82,7 @@ public class McpQuotaServiceTests
|
||||
cache ??= new MemoryCache(new MemoryCacheOptions());
|
||||
var mcpUserService = new McpUserService(
|
||||
adminRepo.Object, new StubAdminIdentityBindingService(bound), cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdminService: null!, delegatedAdminService: null!, adminConfigService: null!);
|
||||
globalAdminService: null!, delegatedAdminService: null!, adminConfigService: null!, memberRoleResolver: null!);
|
||||
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
configRepo.Setup(r => r.GetAdminConfigurationAsync())
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// McpAccessPolicy.AllMembers means "every effective MEMBER of the token's tenant", never "every
|
||||
/// authenticated token": an employee who signs in without a role (Progress Portal end-user) or a token from
|
||||
/// an organization that never onboarded is denied. Membership is decided by the shared
|
||||
/// <see cref="TenantMemberRoleResolver"/> (table first, Entra app-role claim when the tenant opted in), the
|
||||
/// explicit grants (platform role, delegated scope, enabled McpUsers row) keep working, and the McpUsers
|
||||
/// table doubles as the per-user override list (usage plan, block).
|
||||
/// </summary>
|
||||
public class McpUserServiceAllMembersTests
|
||||
{
|
||||
private const string Upn = "operator@customer.example";
|
||||
private const string HomeTenant = "11111111-1111-1111-1111-111111111111";
|
||||
private const string ManagedTenant = "22222222-2222-2222-2222-222222222222";
|
||||
private const string Oid = "aaaaaaaa-1111-1111-1111-aaaaaaaaaaaa";
|
||||
|
||||
private readonly Mock<IAdminRepository> _adminRepo = new();
|
||||
private readonly Mock<GlobalAdminService> _globalAdmin;
|
||||
private readonly Mock<DelegatedAdminService> _delegatedAdmin;
|
||||
private readonly StubTenantMemberRoleResolver _memberRoles = new();
|
||||
private readonly McpUserService _sut;
|
||||
|
||||
public McpUserServiceAllMembersTests()
|
||||
{
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var bindings = new StubAdminIdentityBindingService(bound: true);
|
||||
_globalAdmin = new Mock<GlobalAdminService>(
|
||||
_adminRepo.Object, bindings, cache, NullLogger<GlobalAdminService>.Instance) { CallBase = false };
|
||||
_globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync((string?)null);
|
||||
_delegatedAdmin = new Mock<DelegatedAdminService>(
|
||||
_adminRepo.Object, bindings, new StubTenantEntitlementService(TenantEdition.Pro),
|
||||
cache, NullLogger<DelegatedAdminService>.Instance) { CallBase = false };
|
||||
_delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>())).ReturnsAsync(DelegatedScope.Empty);
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { McpAccessPolicy = McpAccessPolicy.AllMembers.ToString() });
|
||||
_adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny<string>())).ReturnsAsync((McpUserEntry?)null);
|
||||
|
||||
_sut = new McpUserService(
|
||||
_adminRepo.Object, bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
_globalAdmin.Object, _delegatedAdmin.Object, adminConfig.Object, _memberRoles);
|
||||
}
|
||||
|
||||
private void MemberOf(string tenantId, string role) =>
|
||||
_memberRoles.Verdict = (tid, upn, _) =>
|
||||
string.Equals(tid, tenantId, StringComparison.OrdinalIgnoreCase) && upn == Upn
|
||||
? new MemberRoleInfo { Role = role }
|
||||
: null;
|
||||
|
||||
// ── who is a member ──────────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData(Constants.TenantRoles.Admin)]
|
||||
[InlineData(Constants.TenantRoles.Operator)]
|
||||
[InlineData(Constants.TenantRoles.Viewer)]
|
||||
public async Task EffectiveTenantRole_IsAllowed_AsAllMembers(string role)
|
||||
{
|
||||
MemberOf(HomeTenant, role);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal("AllMembers", result.AccessGrant);
|
||||
Assert.False(result.IsGlobalAdmin);
|
||||
Assert.Null(result.GlobalRole);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AuthenticatedWithoutRole_IsDenied()
|
||||
{
|
||||
// The Progress Portal end-user: a valid token from an onboarded tenant, no TenantAdmins row, no claim.
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
Assert.Contains("no role in its organization's tenant", result.Reason);
|
||||
Assert.Equal(new[] { (HomeTenant, Upn) }, _memberRoles.Lookups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MembershipIsResolvedInTheTokenTenant_NotElsewhere()
|
||||
{
|
||||
// A role in some OTHER tenant is not a role in the tenant the token was issued for.
|
||||
MemberOf(ManagedTenant, Constants.TenantRoles.Admin);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MissingTenantId_IsDenied_WithoutResolvingMembership()
|
||||
{
|
||||
_memberRoles.Verdict = (_, _, _) => new MemberRoleInfo { Role = Constants.TenantRoles.Admin };
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, homeTenantId: null, objectId: Oid);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
Assert.Empty(_memberRoles.Lookups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppRoles_ReachTheResolver()
|
||||
{
|
||||
// Claim-derived membership is the resolver's job (gated by the tenant's opt-in); the service must
|
||||
// hand the token's roles through unchanged.
|
||||
IReadOnlyList<string>? seen = null;
|
||||
_memberRoles.Verdict = (_, _, roles) =>
|
||||
{
|
||||
seen = roles;
|
||||
return roles != null && roles.Contains(Constants.TenantRoles.Operator)
|
||||
? new MemberRoleInfo { Role = Constants.TenantRoles.Operator }
|
||||
: null;
|
||||
};
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid, new[] { Constants.TenantRoles.Operator });
|
||||
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal(new[] { Constants.TenantRoles.Operator }, seen);
|
||||
}
|
||||
|
||||
// ── explicit grants still hold, in the same order as WhitelistOnly ──────────
|
||||
|
||||
[Fact]
|
||||
public async Task PlatformRole_WinsOverMembership_AndReportsItsGrant()
|
||||
{
|
||||
_globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<AdminIdentity?>()))
|
||||
.ReturnsAsync(Constants.GlobalRoles.GlobalAdmin);
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal(Constants.GlobalRoles.GlobalAdmin, result.AccessGrant);
|
||||
Assert.True(result.IsGlobalAdmin);
|
||||
Assert.Empty(_memberRoles.Lookups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DelegatedAdmin_WithoutHomeMembership_IsAllowed()
|
||||
{
|
||||
// An MSP whose own home tenant is not a customer: no home role, but a curated delegated scope.
|
||||
_delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<AdminIdentity?>()))
|
||||
.ReturnsAsync(new DelegatedScope(new Dictionary<string, string>
|
||||
{
|
||||
[ManagedTenant] = Constants.DelegatedRoles.DelegatedReader,
|
||||
}));
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal("DelegatedAdmin", result.AccessGrant);
|
||||
Assert.Equal(new[] { ManagedTenant }, result.DelegatedTenantIds);
|
||||
Assert.Empty(_memberRoles.Lookups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnabledMcpUsersRow_GrantsANonMember()
|
||||
{
|
||||
_adminRepo.Setup(x => x.GetMcpUserAsync(Upn))
|
||||
.ReturnsAsync(new McpUserEntry { Upn = Upn, IsEnabled = true, UsagePlan = "pro" });
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.True(result.IsAllowed);
|
||||
Assert.Equal("McpUser", result.AccessGrant);
|
||||
Assert.Empty(_memberRoles.Lookups);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisabledMcpUsersRow_BlocksAMember()
|
||||
{
|
||||
// The per-user override list's "block" lever: a tenant member the operator disabled stays out.
|
||||
MemberOf(HomeTenant, Constants.TenantRoles.Admin);
|
||||
_adminRepo.Setup(x => x.GetMcpUserAsync(Upn))
|
||||
.ReturnsAsync(new McpUserEntry { Upn = Upn, IsEnabled = false });
|
||||
|
||||
var result = await _sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
Assert.False(result.IsAllowed);
|
||||
Assert.Contains("disabled on the MCP whitelist", result.Reason);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ public class McpUserServiceDelegatedTests
|
||||
private readonly Mock<DelegatedAdminService> _delegatedAdmin;
|
||||
private readonly Mock<AdminConfigurationService> _adminConfig;
|
||||
private readonly Mock<IAdminRepository> _adminRepo;
|
||||
private readonly StubTenantMemberRoleResolver _memberRoles = new();
|
||||
private readonly McpUserService _sut;
|
||||
|
||||
public McpUserServiceDelegatedTests()
|
||||
@@ -53,7 +54,7 @@ public class McpUserServiceDelegatedTests
|
||||
|
||||
_sut = new McpUserService(
|
||||
_adminRepo.Object, bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
_globalAdmin.Object, _delegatedAdmin.Object, _adminConfig.Object);
|
||||
_globalAdmin.Object, _delegatedAdmin.Object, _adminConfig.Object, _memberRoles);
|
||||
}
|
||||
|
||||
private void SetPolicy(McpAccessPolicy policy) =>
|
||||
@@ -187,8 +188,9 @@ public class McpUserServiceDelegatedTests
|
||||
public async Task AllMembers_WithoutMcpUserRow_IsAllowed()
|
||||
{
|
||||
// Control for the hoisted kill-switch: a MISSING row is the normal AllMembers case and must
|
||||
// still be granted — only an explicit Disabled row denies.
|
||||
// still be granted to a tenant member — only an explicit Disabled row denies.
|
||||
SetPolicy(McpAccessPolicy.AllMembers);
|
||||
_memberRoles.Verdict = (_, _, _) => new MemberRoleInfo { Role = Constants.TenantRoles.Operator };
|
||||
|
||||
var result = await _sut.IsAllowedAsync(DelegatedUpn, TenantA, Oid);
|
||||
|
||||
|
||||
@@ -28,6 +28,9 @@ public class McpUserServiceIdentityBindingTests
|
||||
|
||||
private readonly Mock<IAdminRepository> _adminRepo = new();
|
||||
private readonly StubAdminIdentityBindingService _bindings;
|
||||
// Home-tenant membership for the AllMembers path: this suite is about the identity binding, so every
|
||||
// caller counts as a tenant member and only the binding decides (see McpUserServiceAllMembersTests).
|
||||
private readonly StubTenantMemberRoleResolver _memberRoles = StubTenantMemberRoleResolver.Everyone();
|
||||
private readonly McpUserService _sut;
|
||||
|
||||
public McpUserServiceIdentityBindingTests()
|
||||
@@ -56,7 +59,7 @@ public class McpUserServiceIdentityBindingTests
|
||||
|
||||
_sut = new McpUserService(
|
||||
_adminRepo.Object, _bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object);
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, _memberRoles);
|
||||
}
|
||||
|
||||
private void SetRow(bool enabled, string? plan = "pro") =>
|
||||
@@ -128,7 +131,7 @@ public class McpUserServiceIdentityBindingTests
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { McpAccessPolicy = McpAccessPolicy.WhitelistOnly.ToString() });
|
||||
var sut = new McpUserService(_adminRepo.Object, bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object);
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, _memberRoles);
|
||||
_adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny<string>())).ReturnsAsync((McpUserEntry?)null);
|
||||
|
||||
var result = await sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
@@ -168,7 +171,7 @@ public class McpUserServiceIdentityBindingTests
|
||||
adminConfig.Setup(x => x.GetConfigurationAsync())
|
||||
.ReturnsAsync(new AdminConfiguration { McpAccessPolicy = McpAccessPolicy.AllMembers.ToString() });
|
||||
var sut = new McpUserService(_adminRepo.Object, _bindings, cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object);
|
||||
globalAdmin.Object, delegatedAdmin.Object, adminConfig.Object, _memberRoles);
|
||||
SetRow(enabled: false);
|
||||
|
||||
var bound = await sut.IsAllowedAsync(Upn, HomeTenant, Oid);
|
||||
|
||||
@@ -118,7 +118,8 @@ public class PolicyEnforcementMiddlewareTests
|
||||
configRepo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
|
||||
|
||||
var mw = new PolicyEnforcementMiddleware(
|
||||
NullLogger<PolicyEnforcementMiddleware>.Instance, globalAdmin, delegatedAdmin, tenantAdmins, tenantConfig);
|
||||
NullLogger<PolicyEnforcementMiddleware>.Instance, globalAdmin, delegatedAdmin,
|
||||
new TenantMemberRoleResolver(tenantAdmins, tenantConfig), tenantConfig);
|
||||
|
||||
return new Harness { Middleware = mw, Repo = repo, ConfigRepo = configRepo };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Scripted <see cref="TenantMemberRoleResolver"/>: the verdict function receives (tenantId, upn, appRoles)
|
||||
/// and answers with the effective member role (null = non-member). Default verdict = nobody is a member.
|
||||
/// </summary>
|
||||
internal sealed class StubTenantMemberRoleResolver : TenantMemberRoleResolver
|
||||
{
|
||||
/// <summary>The scripted verdict; replace it mid-test to change who is a member.</summary>
|
||||
public Func<string, string, IReadOnlyList<string>?, MemberRoleInfo?> Verdict { get; set; }
|
||||
|
||||
/// <summary>Every (tenantId, upn) the resolver was asked about, in call order.</summary>
|
||||
public List<(string TenantId, string Upn)> Lookups { get; } = new();
|
||||
|
||||
public StubTenantMemberRoleResolver(Func<string, string, IReadOnlyList<string>?, MemberRoleInfo?>? verdict = null)
|
||||
: base(null!, null!)
|
||||
{
|
||||
Verdict = verdict ?? ((_, _, _) => null);
|
||||
}
|
||||
|
||||
public static StubTenantMemberRoleResolver Everyone(string role = AutopilotMonitor.Shared.Constants.TenantRoles.Admin)
|
||||
=> new((_, _, _) => new MemberRoleInfo { Role = role });
|
||||
|
||||
public override Task<MemberRoleInfo?> ResolveAsync(string tenantId, string upn, IReadOnlyList<string>? appRoles)
|
||||
{
|
||||
Lookups.Add((tenantId, upn));
|
||||
return Task.FromResult(Verdict(tenantId, upn, appRoles));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The shared effective-role resolver (policy middleware + MCP AllMembers gate): TenantAdmins table first,
|
||||
/// the token's Entra app-role claim only when no row exists AND the tenant opted in, and no tenant-config
|
||||
/// read at all for a roleless token — so the common "no row, no claim" path costs one cached membership read.
|
||||
/// </summary>
|
||||
public class TenantMemberRoleResolverTests
|
||||
{
|
||||
private const string Tenant = "11111111-1111-1111-1111-111111111111";
|
||||
private const string Upn = "user@customer.example";
|
||||
|
||||
private readonly Mock<IAdminRepository> _adminRepo = new();
|
||||
private readonly Mock<IConfigRepository> _configRepo = new();
|
||||
private readonly TenantMemberRoleResolver _sut;
|
||||
|
||||
public TenantMemberRoleResolverTests()
|
||||
{
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
_adminRepo.Setup(r => r.GetTenantMemberAsync(It.IsAny<string>(), It.IsAny<string>()))
|
||||
.ReturnsAsync((TenantMember?)null);
|
||||
_configRepo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync((TenantConfiguration?)null);
|
||||
_sut = new TenantMemberRoleResolver(
|
||||
new TenantAdminsService(_adminRepo.Object, cache, NullLogger<TenantAdminsService>.Instance),
|
||||
new TenantConfigurationService(_configRepo.Object, NullLogger<TenantConfigurationService>.Instance, cache));
|
||||
}
|
||||
|
||||
private void Row(string role, bool enabled = true) =>
|
||||
_adminRepo.Setup(r => r.GetTenantMemberAsync(Tenant, Upn))
|
||||
.ReturnsAsync(new TenantMember { TenantId = Tenant, Upn = Upn, Role = role, IsEnabled = enabled });
|
||||
|
||||
private void AppRolesEnabled() =>
|
||||
_configRepo.Setup(r => r.GetTenantConfigurationAsync(Tenant))
|
||||
.ReturnsAsync(new TenantConfiguration { TenantId = Tenant, EntraAppRolesEnabled = true });
|
||||
|
||||
[Fact]
|
||||
public async Task EnabledRow_WinsOverClaim_WithoutReadingConfig()
|
||||
{
|
||||
Row(Constants.TenantRoles.Viewer);
|
||||
AppRolesEnabled();
|
||||
|
||||
var role = await _sut.ResolveAsync(Tenant, Upn, new[] { Constants.TenantRoles.Admin });
|
||||
|
||||
Assert.Equal(Constants.TenantRoles.Viewer, role?.Role);
|
||||
_configRepo.Verify(r => r.GetTenantConfigurationAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisabledRow_SuppressesTheClaim()
|
||||
{
|
||||
Row(Constants.TenantRoles.Admin, enabled: false);
|
||||
AppRolesEnabled();
|
||||
|
||||
var role = await _sut.ResolveAsync(Tenant, Upn, new[] { Constants.TenantRoles.Admin });
|
||||
|
||||
Assert.Null(role);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoRow_NoClaim_IsNonMember_WithoutReadingConfig()
|
||||
{
|
||||
Assert.Null(await _sut.ResolveAsync(Tenant, Upn, null));
|
||||
Assert.Null(await _sut.ResolveAsync(Tenant, Upn, Array.Empty<string>()));
|
||||
_configRepo.Verify(r => r.GetTenantConfigurationAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task NoRow_Claim_CountsOnlyWhenTheTenantOptedIn()
|
||||
{
|
||||
var claim = new[] { Constants.TenantRoles.Operator };
|
||||
|
||||
Assert.Null(await _sut.ResolveAsync(Tenant, Upn, claim));
|
||||
|
||||
AppRolesEnabled();
|
||||
var role = await _sut.ResolveAsync(Tenant, Upn, claim);
|
||||
|
||||
Assert.Equal(Constants.TenantRoles.Operator, role?.Role);
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,9 @@ public class AuthFunction
|
||||
var delegatedScopeTask = _delegatedAdminService.GetScopeAsync(identity);
|
||||
var isApprovedTask = _previewWhitelistService.IsApprovedAsync(tenantId);
|
||||
var membershipTask = _tenantAdminsService.GetTableMembershipAsync(tenantId, upn);
|
||||
var mcpCheckTask = _mcpUserService.IsAllowedAsync(upn, tenantId, objectId);
|
||||
// App roles ride along so the AllMembers path sees a claim-derived member the same way the policy
|
||||
// middleware does (the table-vs-claim reconciliation happens inside the shared role resolver).
|
||||
var mcpCheckTask = _mcpUserService.IsAllowedAsync(upn, tenantId, objectId, principal.GetAppRoles());
|
||||
var existingAdminsTask = _tenantAdminsService.GetTenantAdminsAsync(tenantId);
|
||||
|
||||
await Task.WhenAll(tenantConfigTask, globalRoleTask, delegatedScopeTask, isApprovedTask,
|
||||
|
||||
@@ -200,7 +200,9 @@ public class McpUserFunction
|
||||
|
||||
// JWT tid + oid complete the caller's identity: platform-role and delegated (MSP) grants resolve on
|
||||
// the identity binding (never the UPN alone); the delegated seat additionally requires a Pro home tenant.
|
||||
var result = await _mcpUserService.IsAllowedAsync(upn, principal?.GetTenantId(), principal?.GetObjectId());
|
||||
// The token's app roles feed the AllMembers member check (claim-derived Admin/Operator when the tenant opted in).
|
||||
var result = await _mcpUserService.IsAllowedAsync(
|
||||
upn, principal?.GetTenantId(), principal?.GetObjectId(), principal?.GetAppRoles());
|
||||
|
||||
var response = req.CreateResponse(result.IsAllowed ? HttpStatusCode.OK : HttpStatusCode.Forbidden);
|
||||
// Only surface platform-role flags when the caller actually has one (null ⇒ key omitted).
|
||||
|
||||
@@ -36,20 +36,20 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
private readonly ILogger<PolicyEnforcementMiddleware> _logger;
|
||||
private readonly GlobalAdminService _globalAdminService;
|
||||
private readonly DelegatedAdminService _delegatedAdminService;
|
||||
private readonly TenantAdminsService _tenantAdminsService;
|
||||
private readonly TenantMemberRoleResolver _memberRoleResolver;
|
||||
private readonly TenantConfigurationService _tenantConfigService;
|
||||
|
||||
public PolicyEnforcementMiddleware(
|
||||
ILogger<PolicyEnforcementMiddleware> logger,
|
||||
GlobalAdminService globalAdminService,
|
||||
DelegatedAdminService delegatedAdminService,
|
||||
TenantAdminsService tenantAdminsService,
|
||||
TenantMemberRoleResolver memberRoleResolver,
|
||||
TenantConfigurationService tenantConfigService)
|
||||
{
|
||||
_logger = logger;
|
||||
_globalAdminService = globalAdminService;
|
||||
_delegatedAdminService = delegatedAdminService;
|
||||
_tenantAdminsService = tenantAdminsService;
|
||||
_memberRoleResolver = memberRoleResolver;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
}
|
||||
|
||||
@@ -561,33 +561,13 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective tenant member role: the TenantAdmins table entry if present,
|
||||
/// otherwise — when the tenant has Entra app-roles enabled — the role derived from the token's
|
||||
/// "roles" claim. Table always wins (manual override). The tenant-config lookup is only
|
||||
/// performed when there is no table entry, so the common (table member) path stays cheap.
|
||||
/// Effective tenant member role (table first, Entra app-role claim as the gated fallback) — see
|
||||
/// <see cref="TenantMemberRoleResolver"/>, which the MCP access check shares so "member" means the same
|
||||
/// thing on every path.
|
||||
/// </summary>
|
||||
private async Task<MemberRoleInfo?> ResolveEffectiveRoleAsync(
|
||||
private Task<MemberRoleInfo?> ResolveEffectiveRoleAsync(
|
||||
string tenantId, string upn, ClaimsPrincipal? principal)
|
||||
{
|
||||
var (state, tableRole) = await _tenantAdminsService.GetTableMembershipAsync(tenantId, upn);
|
||||
|
||||
// Table-first: an enabled row wins, a disabled row is an explicit deny. Both skip the
|
||||
// claim path entirely — and avoid the tenant-config lookup. Only when no row exists do
|
||||
// we consult the Entra app-role claim (gated by the per-tenant opt-in flag).
|
||||
if (state != TableMemberState.NotPresent)
|
||||
return EntraAppRoleResolver.Resolve(state, tableRole, appRoles: null, appRolesEnabled: false);
|
||||
|
||||
if (principal == null)
|
||||
return null;
|
||||
|
||||
// Side-effect-free read: TryGetConfigurationAsync does NOT persist a default row for a missing
|
||||
// tenant (GetConfigurationAsync would). Authorization role resolution must never create config as
|
||||
// a side effect — otherwise an external delegated/MSP user whose own home tenant is not onboarded
|
||||
// would get a phantom TenantConfiguration row written on their first cross-tenant read. A missing
|
||||
// config simply means EntraAppRolesEnabled = false (the default), so the role result is unchanged.
|
||||
var (config, _) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
|
||||
return EntraAppRoleResolver.Resolve(state, tableRole, principal.GetAppRoles(), config.EntraAppRolesEnabled);
|
||||
}
|
||||
=> _memberRoleResolver.ResolveAsync(tenantId, upn, principal?.GetAppRoles());
|
||||
|
||||
/// <summary>
|
||||
/// Platform-wide cross-tenant READ: admits GlobalAdmin and the read-only GlobalReader. The
|
||||
|
||||
@@ -259,6 +259,7 @@ builder.Services.AddSingleton<PreviewWhitelistService>();
|
||||
// Global Admin approve endpoint and the tenant auto-approve queue worker.
|
||||
builder.Services.AddSingleton<TenantApprovalService>();
|
||||
builder.Services.AddSingleton<TenantAdminsService>();
|
||||
builder.Services.AddSingleton<AutopilotMonitor.Functions.Security.TenantMemberRoleResolver>();
|
||||
builder.Services.AddSingleton<HealthCheckService>();
|
||||
builder.Services.AddSingleton<BackendBuildInfo>();
|
||||
builder.Services.AddSingleton<GatherRuleService>();
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security;
|
||||
|
||||
/// <summary>
|
||||
/// The ONE resolver for a caller's effective tenant member role: the TenantAdmins table entry if present,
|
||||
/// otherwise — when the tenant has Entra app-roles enabled — the role derived from the token's "roles"
|
||||
/// claim. Table always wins (manual override): an enabled row supplies the role, a disabled row is an
|
||||
/// explicit deny that suppresses the claim. Shared by the policy-enforcement middleware (per-request
|
||||
/// authorization) and the MCP access check (AllMembers = "every tenant member", not "every token"), so the
|
||||
/// two can never disagree about who is a member.
|
||||
/// </summary>
|
||||
public class TenantMemberRoleResolver
|
||||
{
|
||||
private readonly TenantAdminsService _tenantAdminsService;
|
||||
private readonly TenantConfigurationService _tenantConfigService;
|
||||
|
||||
public TenantMemberRoleResolver(TenantAdminsService tenantAdminsService, TenantConfigurationService tenantConfigService)
|
||||
{
|
||||
_tenantAdminsService = tenantAdminsService;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Effective member role of <paramref name="upn"/> in <paramref name="tenantId"/>, or null for a
|
||||
/// non-member (no row and no usable claim) and for a disabled row. <paramref name="appRoles"/> are the
|
||||
/// token's raw "roles" claim values (null/empty when the token carries none).
|
||||
/// </summary>
|
||||
public virtual async Task<MemberRoleInfo?> ResolveAsync(string tenantId, string upn, IReadOnlyList<string>? appRoles)
|
||||
{
|
||||
var (state, tableRole) = await _tenantAdminsService.GetTableMembershipAsync(tenantId, upn);
|
||||
|
||||
// Table-first: an enabled row wins, a disabled row is an explicit deny. Both skip the claim path
|
||||
// entirely — and avoid the tenant-config lookup. Only when no row exists do we consult the Entra
|
||||
// app-role claim (gated by the per-tenant opt-in flag); a token without roles needs no lookup at all.
|
||||
if (state != TableMemberState.NotPresent)
|
||||
return EntraAppRoleResolver.Resolve(state, tableRole, appRoles: null, appRolesEnabled: false);
|
||||
|
||||
if (appRoles == null || appRoles.Count == 0)
|
||||
return null;
|
||||
|
||||
// Side-effect-free read: TryGetConfigurationAsync does NOT persist a default row for a missing
|
||||
// tenant (GetConfigurationAsync would). Authorization role resolution must never create config as
|
||||
// a side effect — otherwise an external delegated/MSP user whose own home tenant is not onboarded
|
||||
// would get a phantom TenantConfiguration row written on their first cross-tenant read. A missing
|
||||
// config simply means EntraAppRolesEnabled = false (the default), so the role result is unchanged.
|
||||
var (config, _) = await _tenantConfigService.TryGetConfigurationAsync(tenantId);
|
||||
return EntraAppRoleResolver.Resolve(state, tableRole, appRoles, config.EntraAppRolesEnabled);
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,14 @@ namespace AutopilotMonitor.Functions.Services;
|
||||
/// Service for managing MCP (Model Context Protocol) user access.
|
||||
/// Access is governed by AdminConfiguration.McpAccessPolicy:
|
||||
/// - Disabled: no one can access MCP
|
||||
/// - WhitelistOnly: Global Admins + explicit McpUsers table entries
|
||||
/// - AllMembers: any authenticated user
|
||||
/// - WhitelistOnly: platform roles, delegated (MSP) admins and enabled McpUsers rows
|
||||
/// - AllMembers: the WhitelistOnly grants PLUS every effective member (Admin / Operator / Viewer — table
|
||||
/// row or Entra app-role claim) of the tenant the token was issued for. NOT "any authenticated token":
|
||||
/// an employee who merely signs in (e.g. for the Progress Portal) holds no role and is denied, exactly
|
||||
/// as they are on every tenant endpoint the MCP tools would call.
|
||||
/// An EXPLICITLY DISABLED McpUsers row is an operator kill-switch that denies access under every
|
||||
/// policy and every grant path (platform role, delegated auto-grant, AllMembers).
|
||||
/// policy and every grant path (platform role, delegated auto-grant, AllMembers). Under AllMembers the
|
||||
/// McpUsers table is therefore a per-user override list (individual usage plan, block), not a whitelist.
|
||||
/// <para>
|
||||
/// The McpUsers row is looked up by UPN but is honoured (grant, usage-plan override AND kill-switch) only
|
||||
/// for the identity the UPN is bound to in <see cref="AdminIdentityBindingService"/> — the caller's validated
|
||||
@@ -33,6 +37,7 @@ public class McpUserService
|
||||
private readonly GlobalAdminService _globalAdminService;
|
||||
private readonly DelegatedAdminService _delegatedAdminService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly TenantMemberRoleResolver _memberRoleResolver;
|
||||
// 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
|
||||
@@ -47,7 +52,8 @@ public class McpUserService
|
||||
ILogger<McpUserService> logger,
|
||||
GlobalAdminService globalAdminService,
|
||||
DelegatedAdminService delegatedAdminService,
|
||||
AdminConfigurationService adminConfigService)
|
||||
AdminConfigurationService adminConfigService,
|
||||
TenantMemberRoleResolver memberRoleResolver)
|
||||
{
|
||||
_adminRepo = adminRepo;
|
||||
_bindings = bindings;
|
||||
@@ -56,6 +62,7 @@ public class McpUserService
|
||||
_globalAdminService = globalAdminService;
|
||||
_delegatedAdminService = delegatedAdminService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_memberRoleResolver = memberRoleResolver;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -64,10 +71,13 @@ public class McpUserService
|
||||
/// together with the UPN they form the <see cref="AdminIdentity"/> the platform-role and delegated
|
||||
/// (MSP) grant paths AND the McpUsers whitelist resolve on — all three are keyed on the identity binding,
|
||||
/// never on the UPN alone, and the delegated path additionally requires a Pro home tenant. Null tid/oid
|
||||
/// fails closed to no platform role, an empty delegated scope and no whitelist row; only the AllMembers
|
||||
/// path (tenant-bound by the auth middleware itself) is unaffected.
|
||||
/// fails closed to no platform role, an empty delegated scope and no whitelist row. The AllMembers path
|
||||
/// resolves the caller's effective role in <paramref name="homeTenantId"/> (the tenant the token was
|
||||
/// issued for) from the TenantAdmins table and, when that tenant opted in, the token's
|
||||
/// <paramref name="appRoles"/> claim — a null tenant id is no member of anything.
|
||||
/// </summary>
|
||||
public virtual async Task<McpAccessCheckResult> IsAllowedAsync(string? upn, string? homeTenantId, string? objectId)
|
||||
public virtual async Task<McpAccessCheckResult> IsAllowedAsync(
|
||||
string? upn, string? homeTenantId, string? objectId, IReadOnlyList<string>? appRoles = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(upn))
|
||||
return McpAccessCheckResult.Denied("Missing UPN");
|
||||
@@ -109,41 +119,49 @@ public class McpUserService
|
||||
if (!Enum.TryParse<McpAccessPolicy>(config.McpAccessPolicy, out var policy))
|
||||
policy = McpAccessPolicy.WhitelistOnly;
|
||||
|
||||
switch (policy)
|
||||
{
|
||||
case McpAccessPolicy.Disabled:
|
||||
// Disabled blocks everyone — platform roles and delegated admins alike.
|
||||
return McpAccessCheckResult.Denied("MCP access is disabled");
|
||||
// Disabled blocks everyone — platform roles and delegated admins alike.
|
||||
if (policy == McpAccessPolicy.Disabled)
|
||||
return McpAccessCheckResult.Denied("MCP access is disabled");
|
||||
|
||||
case McpAccessPolicy.AllMembers:
|
||||
return McpAccessCheckResult.Allowed(
|
||||
upn, "AllMembers", isGlobalAdmin, globalRole, delegatedTenantIds, delegatedRole);
|
||||
// The explicit grants below hold under BOTH remaining policies; AllMembers only ADDS the tenant-role
|
||||
// path at the end. Same order everywhere so the reported AccessGrant is policy-independent.
|
||||
|
||||
case McpAccessPolicy.WhitelistOnly:
|
||||
default:
|
||||
// Any platform role (GlobalAdmin or read-only GlobalReader) always has MCP access
|
||||
// (unless explicitly disabled via the kill-switch above).
|
||||
if (globalRole != null)
|
||||
return McpAccessCheckResult.Allowed(
|
||||
upn, globalRole, isGlobalAdmin, globalRole, delegatedTenantIds, delegatedRole);
|
||||
// Any platform role (GlobalAdmin or read-only GlobalReader) always has MCP access
|
||||
// (unless explicitly disabled via the kill-switch above).
|
||||
if (globalRole != null)
|
||||
return McpAccessCheckResult.Allowed(
|
||||
upn, globalRole, isGlobalAdmin, globalRole, delegatedTenantIds, delegatedRole);
|
||||
|
||||
// A delegated (MSP) admin with an active scope is granted MCP access automatically under
|
||||
// WhitelistOnly — "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 managed tenant set; no platform/aggregate
|
||||
// path. (An explicit Disabled row above still revokes this.)
|
||||
if (!delegatedScope.IsEmpty)
|
||||
return McpAccessCheckResult.Allowed(
|
||||
upn, "DelegatedAdmin", isGlobalAdmin: false, globalRole: null,
|
||||
delegatedTenantIds: delegatedTenantIds, delegatedRole: delegatedRole);
|
||||
// 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
|
||||
// managed tenant set; no platform/aggregate path. (An explicit Disabled row above still revokes this.)
|
||||
// Under AllMembers this also covers an MSP whose own home tenant is not an onboarded customer.
|
||||
if (!delegatedScope.IsEmpty)
|
||||
return McpAccessCheckResult.Allowed(
|
||||
upn, "DelegatedAdmin", isGlobalAdmin: false, globalRole: null,
|
||||
delegatedTenantIds: delegatedTenantIds, delegatedRole: delegatedRole);
|
||||
|
||||
return whitelistState == McpWhitelistState.Enabled
|
||||
? McpAccessCheckResult.Allowed(upn, "McpUser", false, null, delegatedTenantIds, delegatedRole)
|
||||
// Surfaced to the end user by the MCP server's access guard. Keep it
|
||||
// self-explanatory so a denied colleague understands they simply need
|
||||
// to be whitelisted, rather than reading it as an auth failure.
|
||||
: McpAccessCheckResult.Denied("User not enabled for MCP usage (account is not on the MCP whitelist)");
|
||||
}
|
||||
// An enabled McpUsers row is an explicit per-account grant under both policies.
|
||||
if (whitelistState == McpWhitelistState.Enabled)
|
||||
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
|
||||
// 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)");
|
||||
|
||||
// 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
|
||||
// an organization that never onboarded, or from an employee without a role (Progress Portal
|
||||
// end-users), resolves to no role and is denied here instead of failing on every tool call.
|
||||
var memberRole = string.IsNullOrWhiteSpace(homeTenantId)
|
||||
? null
|
||||
: await _memberRoleResolver.ResolveAsync(homeTenantId, upn, appRoles);
|
||||
return memberRole != null
|
||||
? McpAccessCheckResult.Allowed(upn, "AllMembers", false, null, delegatedTenantIds, delegatedRole)
|
||||
: McpAccessCheckResult.Denied("User not enabled for MCP usage (account has no role in its organization's tenant — ask a Tenant Admin to add you)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -9,10 +9,15 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// <summary>MCP access completely disabled.</summary>
|
||||
Disabled = 0,
|
||||
|
||||
/// <summary>Only Global Admins and explicitly whitelisted MCP users.</summary>
|
||||
/// <summary>Platform roles, delegated (MSP) admins and explicitly whitelisted MCP users.</summary>
|
||||
WhitelistOnly = 1,
|
||||
|
||||
/// <summary>Any authenticated user with a valid tenant membership.</summary>
|
||||
/// <summary>
|
||||
/// The WhitelistOnly grants plus every effective tenant member (Admin / Operator / Viewer) of the
|
||||
/// tenant the token was issued for. Not "any authenticated user": a sign-in without a role (e.g. a
|
||||
/// Progress Portal end-user) is denied. The McpUsers table then acts as a per-user override list
|
||||
/// (individual usage plan, block).
|
||||
/// </summary>
|
||||
AllMembers = 2
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,16 @@ const POLICY_LABELS: Record<McpPolicy, string> = {
|
||||
|
||||
const POLICY_DESCRIPTIONS: Record<McpPolicy, string> = {
|
||||
Disabled: "MCP access is completely disabled. No one can connect.",
|
||||
WhitelistOnly: "Only Global Admins and explicitly added MCP users can connect.",
|
||||
AllMembers: "Any authenticated user can connect to the MCP server.",
|
||||
WhitelistOnly: "Only Global Admins, delegated admins and explicitly added MCP users can connect.",
|
||||
AllMembers:
|
||||
"Every member of an onboarded tenant (Admin, Operator or Viewer) can connect. Signed-in users without a role cannot.",
|
||||
};
|
||||
|
||||
// What the list below means under each policy that shows it.
|
||||
const LIST_PURPOSE: Record<Exclude<McpPolicy, "Disabled">, string> = {
|
||||
WhitelistOnly: "Global Admins always have access, no separate entry needed.",
|
||||
AllMembers:
|
||||
"The list below holds per-user overrides: assign an individual usage plan, or disable an entry to block that account.",
|
||||
};
|
||||
|
||||
export default function McpUsersSection() {
|
||||
@@ -287,7 +295,7 @@ export default function McpUsersSection() {
|
||||
<h2 className="text-lg font-semibold text-gray-900">MCP User Management</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
Control who can access the AI agent interface (Model Context Protocol).
|
||||
Control who can access the AI agent interface (Model Context Protocol) and assign individual usage plans.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -316,9 +324,7 @@ export default function McpUsersSection() {
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-medium">Access Policy</p>
|
||||
<p className="mt-1">{POLICY_DESCRIPTIONS[policy]}</p>
|
||||
{policy === "WhitelistOnly" && (
|
||||
<p className="mt-1">Global Admins always have access, no separate entry needed.</p>
|
||||
)}
|
||||
{policy !== "Disabled" && <p className="mt-1">{LIST_PURPOSE[policy]}</p>}
|
||||
</div>
|
||||
<select
|
||||
value={policy}
|
||||
@@ -333,8 +339,8 @@ export default function McpUsersSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User list only visible when WhitelistOnly */}
|
||||
{policy === "WhitelistOnly" && (
|
||||
{/* User list: the whitelist (WhitelistOnly) or the per-user override list (AllMembers) */}
|
||||
{policy !== "Disabled" && (
|
||||
<>
|
||||
{/* Add user form */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -419,7 +425,11 @@ export default function McpUsersSection() {
|
||||
{/* User list */}
|
||||
{!loading && paginatedUsers.length === 0 && (
|
||||
<p className="text-sm text-gray-500 py-4 text-center">
|
||||
{searchQuery ? "No users match your search." : "No MCP users added yet. Add a user above to grant MCP access."}
|
||||
{searchQuery
|
||||
? "No users match your search."
|
||||
: policy === "AllMembers"
|
||||
? "No per-user overrides yet. Add a user above to assign an individual usage plan or block their MCP access."
|
||||
: "No MCP users added yet. Add a user above to grant MCP access."}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -514,15 +524,9 @@ export default function McpUsersSection() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Message for other policies */}
|
||||
{policy === "Disabled" && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
|
||||
MCP access is currently disabled. Change the policy in Global Config to enable it.
|
||||
</div>
|
||||
)}
|
||||
{policy === "AllMembers" && (
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-sm text-green-800">
|
||||
All authenticated users can access the MCP server. No whitelist management needed.
|
||||
MCP access is currently disabled. Change the policy above to enable it.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user