mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
feat(mcp): organization-wide MCP quota next to the per-user quota
Every X-Client-Source: mcp request is now charged against two budgets: the
caller's own daily/monthly windows (per-user override or tenant edition plan)
and the organization-wide daily/monthly windows of the token's tenant, which
always follow the tenant's edition plan so a per-user override never widens
them. Ten accounts no longer mean ten times the budget.
Tenant counters live in the new McpTenantUsage table (PK = tenantId,
RK = {yyyyMMdd}_{oid}: one partition range per check, no hot row, never a
property scan over UserUsageLog); exact-PK offboarding bucket and 90-day
retention included. PlanTierDefinition gains nullable tenant limits (null =
catalog fallback: Community 300/9000, Pro 3000/60000; 0 = unlimited). The 429
body carries level=user|tenant and a message naming whose budget it is,
X-MCP-Quota-Tenant-* headers ride along, and mcp-usage/me exposes both
budgets (wire types regenerated).
MCP server: the quota 429 body was rendered as Error: undefined because it
has no error key; it now gets a dedicated block with the reset time and a
level-specific suggestion. Web: Usage Plans edits the tenant limits; the
self-service MCP Usage page renders both budgets (the quota node was never
displayed before).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -541,6 +541,10 @@ public class ConfigWireParityTests
|
||||
DailyRequestLimit = 1000,
|
||||
MonthlyRequestLimit = 30000,
|
||||
Description = "Paid tier",
|
||||
// Organization-wide windows; the community tier above leaves them unset (catalog fallback,
|
||||
// keys omitted on the wire).
|
||||
TenantDailyRequestLimit = 3000,
|
||||
TenantMonthlyRequestLimit = 60000,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -102,6 +102,8 @@ public class FeatureEntitlementCatalogTests
|
||||
Assert.Equal("community", e.McpUsagePlanName);
|
||||
Assert.Equal(100, e.McpDailyRequestLimit);
|
||||
Assert.Equal(3000, e.McpMonthlyRequestLimit);
|
||||
Assert.Equal(300, e.McpTenantDailyRequestLimit);
|
||||
Assert.Equal(9000, e.McpTenantMonthlyRequestLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -117,6 +119,8 @@ public class FeatureEntitlementCatalogTests
|
||||
Assert.Equal("pro", e.McpUsagePlanName);
|
||||
Assert.Equal(1000, e.McpDailyRequestLimit);
|
||||
Assert.Equal(20000, e.McpMonthlyRequestLimit);
|
||||
Assert.Equal(3000, e.McpTenantDailyRequestLimit);
|
||||
Assert.Equal(60000, e.McpTenantMonthlyRequestLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using AutopilotMonitor.Functions.Middleware;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
@@ -644,77 +645,50 @@ public class InfrastructureWireParityTests
|
||||
[Fact]
|
||||
public void McpQuotaExceededResponse_matches_the_daily_blocked_shape()
|
||||
{
|
||||
var decision = new McpQuotaDecision
|
||||
{
|
||||
Allowed = false,
|
||||
Plan = "community",
|
||||
Scope = "daily",
|
||||
DailyLimit = 200,
|
||||
MonthlyLimit = 2000,
|
||||
DailyUsed = 200,
|
||||
MonthlyUsed = 940,
|
||||
ResetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
// The caller's OWN daily window: level=user, limit/used from the user counters, plan named.
|
||||
var decision = McpQuotaService.BuildDecision(
|
||||
new McpPlanLimits("community", 200, 2000, "community", 600, 6000),
|
||||
dailyUsed: 200, monthlyUsed: 940, tenantDailyUsed: 250, tenantMonthlyUsed: 1200,
|
||||
new DateTime(2026, 8, 30, 15, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
quotaExceeded = true,
|
||||
plan = decision.Plan,
|
||||
scope = decision.Scope,
|
||||
limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit,
|
||||
used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed,
|
||||
resetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z."
|
||||
plan = "community",
|
||||
scope = "daily",
|
||||
level = "user",
|
||||
limit = 200,
|
||||
used = 200L,
|
||||
resetUtc = "2026-08-31T00:00:00Z",
|
||||
message = "MCP daily request quota exceeded for plan 'community'. Resets at 2026-08-31T00:00:00Z."
|
||||
},
|
||||
new McpQuotaExceededResponse
|
||||
{
|
||||
QuotaExceeded = true,
|
||||
Plan = decision.Plan,
|
||||
Scope = decision.Scope,
|
||||
Limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit,
|
||||
Used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed,
|
||||
ResetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
Message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z."
|
||||
});
|
||||
McpQuotaEnforcementMiddleware.BuildExceededResponse(decision));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void McpQuotaExceededResponse_matches_the_monthly_blocked_shape()
|
||||
public void McpQuotaExceededResponse_matches_the_tenant_monthly_blocked_shape()
|
||||
{
|
||||
var decision = new McpQuotaDecision
|
||||
{
|
||||
Allowed = false,
|
||||
Plan = "pro",
|
||||
Scope = "monthly",
|
||||
DailyLimit = 1000,
|
||||
MonthlyLimit = 10000,
|
||||
DailyUsed = 120,
|
||||
MonthlyUsed = 10000,
|
||||
ResetUtc = new DateTime(2026, 9, 1, 0, 0, 0, DateTimeKind.Utc),
|
||||
};
|
||||
// The ORGANIZATION's monthly window while the caller is well inside their own plan: level=tenant,
|
||||
// limit/used from the tenant counters, the message names the tenant plan (not the user's override).
|
||||
var decision = McpQuotaService.BuildDecision(
|
||||
new McpPlanLimits("power", 10000, 100000, "pro", 3000, 60000),
|
||||
dailyUsed: 120, monthlyUsed: 5000, tenantDailyUsed: 900, tenantMonthlyUsed: 60000,
|
||||
new DateTime(2026, 8, 30, 15, 0, 0, DateTimeKind.Utc));
|
||||
|
||||
AssertParity(
|
||||
new
|
||||
{
|
||||
quotaExceeded = true,
|
||||
plan = decision.Plan,
|
||||
scope = decision.Scope,
|
||||
limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit,
|
||||
used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed,
|
||||
resetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z."
|
||||
plan = "power",
|
||||
scope = "monthly",
|
||||
level = "tenant",
|
||||
limit = 60000,
|
||||
used = 60000L,
|
||||
resetUtc = "2026-09-01T00:00:00Z",
|
||||
message = "MCP monthly request quota of your organization exceeded (tenant plan 'pro', shared by all its members). Resets at 2026-09-01T00:00:00Z."
|
||||
},
|
||||
new McpQuotaExceededResponse
|
||||
{
|
||||
QuotaExceeded = true,
|
||||
Plan = decision.Plan,
|
||||
Scope = decision.Scope,
|
||||
Limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit,
|
||||
Used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed,
|
||||
ResetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
Message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z."
|
||||
});
|
||||
McpQuotaEnforcementMiddleware.BuildExceededResponse(decision));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -730,6 +704,7 @@ public class InfrastructureWireParityTests
|
||||
quotaExceeded = true,
|
||||
plan = "community",
|
||||
scope,
|
||||
level = "user",
|
||||
limit = 200,
|
||||
used = 200L,
|
||||
resetUtc = "2026-08-31T00:00:00Z",
|
||||
@@ -740,6 +715,7 @@ public class InfrastructureWireParityTests
|
||||
QuotaExceeded = true,
|
||||
Plan = "community",
|
||||
Scope = null,
|
||||
Level = "user",
|
||||
Limit = 200,
|
||||
Used = 200,
|
||||
ResetUtc = "2026-08-31T00:00:00Z",
|
||||
|
||||
@@ -22,29 +22,41 @@ public class McpQuotaServiceTests
|
||||
|
||||
// ── BuildDecision (pure window math) ─────────────────────────────────────────
|
||||
|
||||
private static McpPlanLimits Limits(
|
||||
int daily = 100, int monthly = 3000, int tenantDaily = 300, int tenantMonthly = 9000, string plan = "community")
|
||||
=> new(plan, daily, monthly, plan, tenantDaily, tenantMonthly);
|
||||
|
||||
private static McpQuotaDecision Decide(
|
||||
McpPlanLimits limits, long dailyUsed, long monthlyUsed, long tenantDailyUsed = 0, long tenantMonthlyUsed = 0)
|
||||
=> McpQuotaService.BuildDecision(limits, dailyUsed, monthlyUsed, tenantDailyUsed, tenantMonthlyUsed, Now);
|
||||
|
||||
[Fact]
|
||||
public void BuildDecision_UnderBothLimits_Allowed()
|
||||
public void BuildDecision_UnderAllLimits_Allowed()
|
||||
{
|
||||
var d = McpQuotaService.BuildDecision("community", 100, 3000, dailyUsed: 99, monthlyUsed: 500, Now);
|
||||
var d = Decide(Limits(), dailyUsed: 99, monthlyUsed: 500, tenantDailyUsed: 299, tenantMonthlyUsed: 8999);
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Null(d.Scope);
|
||||
Assert.Null(d.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDecision_DailyExceeded_BlocksWithMidnightReset()
|
||||
{
|
||||
var d = McpQuotaService.BuildDecision("community", 100, 3000, dailyUsed: 100, monthlyUsed: 500, Now);
|
||||
var d = Decide(Limits(), dailyUsed: 100, monthlyUsed: 500);
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("daily", d.Scope);
|
||||
Assert.Equal("user", d.Level);
|
||||
Assert.Equal(100, d.ExceededLimit);
|
||||
Assert.Equal(new DateTime(2026, 7, 8, 0, 0, 0, DateTimeKind.Utc), d.ResetUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDecision_MonthlyExceeded_BlocksWithFirstOfNextMonthReset()
|
||||
{
|
||||
var d = McpQuotaService.BuildDecision("community", 100, 3000, dailyUsed: 10, monthlyUsed: 3000, Now);
|
||||
var d = Decide(Limits(), dailyUsed: 10, monthlyUsed: 3000);
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("monthly", d.Scope);
|
||||
Assert.Equal("user", d.Level);
|
||||
Assert.Equal(new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc), d.ResetUtc);
|
||||
}
|
||||
|
||||
@@ -52,17 +64,59 @@ public class McpQuotaServiceTests
|
||||
public void BuildDecision_MonthlyTakesPrecedenceOverDaily()
|
||||
{
|
||||
// Both exceeded → report the longer (monthly) window so Retry-After is honest.
|
||||
var d = McpQuotaService.BuildDecision("community", 100, 3000, dailyUsed: 100, monthlyUsed: 3000, Now);
|
||||
var d = Decide(Limits(), dailyUsed: 100, monthlyUsed: 3000);
|
||||
Assert.Equal("monthly", d.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDecision_ZeroLimit_MeansUnlimitedForThatScope()
|
||||
public void BuildDecision_ZeroLimit_MeansUnlimitedForThatWindow()
|
||||
{
|
||||
var d = McpQuotaService.BuildDecision("custom", 0, 0, dailyUsed: 999999, monthlyUsed: 999999, Now);
|
||||
var d = Decide(Limits(0, 0, 0, 0, "custom"), dailyUsed: 999999, monthlyUsed: 999999, tenantDailyUsed: 999999, tenantMonthlyUsed: 999999);
|
||||
Assert.True(d.Allowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDecision_TenantDailyExceeded_BlocksAMemberInsideTheirOwnPlan()
|
||||
{
|
||||
// The organization's window is exhausted by OTHER members — this caller has used 5 of 100.
|
||||
var d = Decide(Limits(), dailyUsed: 5, monthlyUsed: 5, tenantDailyUsed: 300, tenantMonthlyUsed: 300);
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("daily", d.Scope);
|
||||
Assert.Equal("tenant", d.Level);
|
||||
Assert.Equal(300, d.ExceededLimit);
|
||||
Assert.Equal(300, d.ExceededUsed);
|
||||
Assert.Equal(new DateTime(2026, 7, 8, 0, 0, 0, DateTimeKind.Utc), d.ResetUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDecision_TenantMonthlyExceeded_ResetsOnTheFirst()
|
||||
{
|
||||
var d = Decide(Limits(), dailyUsed: 5, monthlyUsed: 50, tenantDailyUsed: 10, tenantMonthlyUsed: 9000);
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("monthly", d.Scope);
|
||||
Assert.Equal("tenant", d.Level);
|
||||
Assert.Equal(9000, d.ExceededLimit);
|
||||
Assert.Equal(new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc), d.ResetUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDecision_UserMonthly_IsNamedBeforeTenantMonthly()
|
||||
{
|
||||
// Same reset either way; the caller's OWN exhausted budget is the more actionable message.
|
||||
var d = Decide(Limits(), dailyUsed: 10, monthlyUsed: 3000, tenantDailyUsed: 10, tenantMonthlyUsed: 9000);
|
||||
Assert.Equal("monthly", d.Scope);
|
||||
Assert.Equal("user", d.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDecision_TenantMonthly_IsNamedBeforeUserDaily()
|
||||
{
|
||||
// Longest exceeded window first: a tenant-monthly block outranks the caller's daily block.
|
||||
var d = Decide(Limits(), dailyUsed: 100, monthlyUsed: 500, tenantDailyUsed: 10, tenantMonthlyUsed: 9000);
|
||||
Assert.Equal("monthly", d.Scope);
|
||||
Assert.Equal("tenant", d.Level);
|
||||
}
|
||||
|
||||
// ── CheckAsync (integration over mocked deps) ────────────────────────────────
|
||||
|
||||
private static McpQuotaService Build(
|
||||
@@ -105,6 +159,9 @@ public class McpQuotaServiceTests
|
||||
}
|
||||
|
||||
private static Mock<IUserUsageRepository> UsageRepo(params (string Date, long Count)[] rows)
|
||||
=> UsageRepo(rows, tenantRows: Array.Empty<(string, long)>());
|
||||
|
||||
private static Mock<IUserUsageRepository> UsageRepo((string Date, long Count)[] rows, (string Date, long Count)[] tenantRows)
|
||||
{
|
||||
var repo = new Mock<IUserUsageRepository>();
|
||||
repo.Setup(r => r.GetUsageByUserAsync(Oid, It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
@@ -114,6 +171,14 @@ public class McpQuotaServiceTests
|
||||
Date = r.Date,
|
||||
RequestCount = r.Count
|
||||
}).ToList());
|
||||
repo.Setup(r => r.GetTenantUsageAsync(TenantId, It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(tenantRows.Select(r => new TenantUsageRecord
|
||||
{
|
||||
TenantId = TenantId,
|
||||
UserId = "some-other-member",
|
||||
Date = r.Date,
|
||||
RequestCount = r.Count
|
||||
}).ToList());
|
||||
return repo;
|
||||
}
|
||||
|
||||
@@ -300,4 +365,116 @@ public class McpQuotaServiceTests
|
||||
Assert.Equal("daily", decision.Scope);
|
||||
Assert.Equal(150, decision.DailyUsed);
|
||||
}
|
||||
|
||||
// ── Tenant (organization-wide) quota ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Check_TenantDailyExceeded_BlocksAMemberUnderTheirOwnLimit()
|
||||
{
|
||||
// Community catalog tenant window = 300/day; other members burned it, this caller made 5 requests.
|
||||
var svc = Build(UsageRepo(new[] { ("20260707", 5L) }, tenantRows: new[] { ("20260707", 300L) }));
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("tenant", d.Level);
|
||||
Assert.Equal("daily", d.Scope);
|
||||
Assert.Equal("community", d.TenantPlan);
|
||||
Assert.Equal(300, d.TenantDailyLimit);
|
||||
Assert.Equal(300, d.TenantDailyUsed);
|
||||
Assert.Equal(5, d.DailyUsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_TenantMonthlySum_SpansAllMembersAndDaysOfTheMonth()
|
||||
{
|
||||
var svc = Build(UsageRepo(
|
||||
new[] { ("20260707", 1L) },
|
||||
tenantRows: new[] { ("20260701", 4000L), ("20260703", 4990L), ("20260707", 10L) }));
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("tenant", d.Level);
|
||||
Assert.Equal("monthly", d.Scope);
|
||||
Assert.Equal(9000, d.TenantMonthlyUsed);
|
||||
Assert.Equal(10, d.TenantDailyUsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_TenantLimits_FollowTheTenantPlan_NeverThePerUserOverride()
|
||||
{
|
||||
// The caller's override plan is huge; the tenant's edition plan (community) carries a tiny tenant
|
||||
// window. The override lifts only the caller's OWN budget — the organization's window still bites.
|
||||
var json = """[{"name":"power","dailyRequestLimit":10000,"monthlyRequestLimit":100000,"tenantDailyRequestLimit":1000000,"tenantMonthlyRequestLimit":1000000,"description":""},{"name":"community","dailyRequestLimit":100,"monthlyRequestLimit":3000,"tenantDailyRequestLimit":10,"tenantMonthlyRequestLimit":100,"description":""}]""";
|
||||
var svc = Build(UsageRepo(new[] { ("20260707", 1L) }, tenantRows: new[] { ("20260707", 10L) }),
|
||||
planDefinitionsJson: json, mcpUserPlanOverride: "power");
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("power", d.Plan);
|
||||
Assert.Equal(10000, d.DailyLimit);
|
||||
Assert.Equal("community", d.TenantPlan);
|
||||
Assert.Equal(10, d.TenantDailyLimit);
|
||||
Assert.Equal("tenant", d.Level);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_DefinitionWithoutTenantLimits_FallsBackToTheEditionCatalog()
|
||||
{
|
||||
// Pre-existing SectionUsagePlans rows carry no tenant fields → the edition's catalog tenant windows.
|
||||
var json = """[{"name":"pro","dailyRequestLimit":5,"monthlyRequestLimit":50,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 1)), planDefinitionsJson: json, edition: TenantEdition.Pro);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal(5, d.DailyLimit);
|
||||
Assert.Equal(3000, d.TenantDailyLimit);
|
||||
Assert.Equal(60000, d.TenantMonthlyLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_TenantWindowsLiftedToZero_SkipTheTenantRead()
|
||||
{
|
||||
var json = """[{"name":"community","dailyRequestLimit":100,"monthlyRequestLimit":3000,"tenantDailyRequestLimit":0,"tenantMonthlyRequestLimit":0,"description":""}]""";
|
||||
var repo = UsageRepo(new[] { ("20260707", 1L) }, tenantRows: new[] { ("20260707", 999999L) });
|
||||
var svc = Build(repo, planDefinitionsJson: json);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal(0, d.TenantDailyLimit);
|
||||
repo.Verify(r => r.GetTenantUsageAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_WithoutTenantId_SkipsTheTenantRead()
|
||||
{
|
||||
var repo = UsageRepo(("20260707", 1));
|
||||
var svc = Build(repo);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, tenantId: null);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
repo.Verify(r => r.GetTenantUsageAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_TenantLookupThrows_FailsOpen_AndDoesNotCache()
|
||||
{
|
||||
var repo = UsageRepo(("20260707", 1));
|
||||
repo.Setup(r => r.GetTenantUsageAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
var svc = Build(repo);
|
||||
|
||||
var first = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
var second = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(first.Allowed);
|
||||
Assert.True(second.Allowed);
|
||||
Assert.Equal(-1, first.TenantDailyUsed);
|
||||
repo.Verify(r => r.GetTenantUsageAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()), Times.Exactly(2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,6 +402,11 @@ public class MetricsWireParityTests
|
||||
long dailyUsed = 42;
|
||||
long monthlyUsed = 950;
|
||||
var resetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc);
|
||||
var tenantPlan = "community";
|
||||
var tenantDailyLimit = 300;
|
||||
var tenantMonthlyLimit = 9000;
|
||||
long tenantDailyUsed = 120;
|
||||
long tenantMonthlyUsed = 4100;
|
||||
var records = SampleUsageRecords(userId);
|
||||
|
||||
AssertParity(
|
||||
@@ -417,7 +422,12 @@ public class MetricsWireParityTests
|
||||
monthlyLimit,
|
||||
dailyUsed,
|
||||
monthlyUsed,
|
||||
resetUtc
|
||||
resetUtc,
|
||||
tenantPlan,
|
||||
tenantDailyLimit,
|
||||
tenantMonthlyLimit,
|
||||
tenantDailyUsed,
|
||||
tenantMonthlyUsed
|
||||
},
|
||||
records
|
||||
},
|
||||
@@ -433,7 +443,12 @@ public class MetricsWireParityTests
|
||||
MonthlyLimit = monthlyLimit,
|
||||
DailyUsed = dailyUsed,
|
||||
MonthlyUsed = monthlyUsed,
|
||||
ResetUtc = resetUtc
|
||||
ResetUtc = resetUtc,
|
||||
TenantPlan = tenantPlan,
|
||||
TenantDailyLimit = tenantDailyLimit,
|
||||
TenantMonthlyLimit = tenantMonthlyLimit,
|
||||
TenantDailyUsed = tenantDailyUsed,
|
||||
TenantMonthlyUsed = tenantMonthlyUsed
|
||||
},
|
||||
Records = records
|
||||
});
|
||||
@@ -461,7 +476,12 @@ public class MetricsWireParityTests
|
||||
monthlyLimit = 1000,
|
||||
dailyUsed = 0L,
|
||||
monthlyUsed = 0L,
|
||||
resetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc)
|
||||
resetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc),
|
||||
tenantPlan = "free",
|
||||
tenantDailyLimit = 0,
|
||||
tenantMonthlyLimit = 0,
|
||||
tenantDailyUsed = 0L,
|
||||
tenantMonthlyUsed = 0L
|
||||
},
|
||||
records
|
||||
},
|
||||
@@ -477,7 +497,12 @@ public class MetricsWireParityTests
|
||||
MonthlyLimit = 1000,
|
||||
DailyUsed = 0,
|
||||
MonthlyUsed = 0,
|
||||
ResetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc)
|
||||
ResetUtc = new DateTime(2026, 8, 31, 0, 0, 0, DateTimeKind.Utc),
|
||||
TenantPlan = "free",
|
||||
TenantDailyLimit = 0,
|
||||
TenantMonthlyLimit = 0,
|
||||
TenantDailyUsed = 0,
|
||||
TenantMonthlyUsed = 0
|
||||
},
|
||||
Records = records
|
||||
});
|
||||
|
||||
+116
-1
@@ -11,11 +11,14 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Table Storage implementation of IUserUsageRepository.
|
||||
/// PartitionKey: userId (oid claim), RowKey: {yyyyMMdd}_{normalizedEndpoint}
|
||||
/// UserUsageLog — PartitionKey: userId (oid claim), RowKey: {yyyyMMdd}_{normalizedEndpoint}.
|
||||
/// McpTenantUsage — PartitionKey: tenantId, RowKey: {yyyyMMdd}_{userId} (the organization-wide quota
|
||||
/// counter; per user so concurrent members never contend on one row, read as one partition range).
|
||||
/// </summary>
|
||||
public class TableUserUsageRepository : IUserUsageRepository
|
||||
{
|
||||
private readonly TableClient _tableClient;
|
||||
private readonly TableClient _tenantTableClient;
|
||||
private readonly ILogger<TableUserUsageRepository> _logger;
|
||||
|
||||
public TableUserUsageRepository(
|
||||
@@ -24,6 +27,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{
|
||||
_logger = logger;
|
||||
_tableClient = storage.GetTableClient(Constants.TableNames.UserUsageLog);
|
||||
_tenantTableClient = storage.GetTableClient(Constants.TableNames.McpTenantUsage);
|
||||
}
|
||||
|
||||
public async Task IncrementUsageAsync(string userId, string userPrincipalName, string tenantId, string endpoint)
|
||||
@@ -202,6 +206,117 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// ---- McpTenantUsage (organization-wide quota counters) ----
|
||||
|
||||
public async Task IncrementTenantUsageAsync(string tenantId, string userId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId) || string.IsNullOrWhiteSpace(userId))
|
||||
return;
|
||||
|
||||
var date = DateTime.UtcNow.ToString("yyyyMMdd");
|
||||
var rowKey = $"{date}_{userId}";
|
||||
|
||||
const int maxRetries = 3;
|
||||
for (var attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _tenantTableClient.GetEntityAsync<TableEntity>(tenantId, rowKey);
|
||||
var entity = result.Value;
|
||||
var count = entity.TryGetValue("RequestCount", out var c) ? Convert.ToInt64(c) : 0L;
|
||||
entity["RequestCount"] = count + 1;
|
||||
entity["LastRequestAt"] = DateTimeOffset.UtcNow;
|
||||
await _tenantTableClient.UpdateEntityAsync(entity, entity.ETag);
|
||||
return;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
var entity = new TableEntity(tenantId, rowKey)
|
||||
{
|
||||
["Date"] = date,
|
||||
["UserId"] = userId,
|
||||
["RequestCount"] = 1L,
|
||||
["LastRequestAt"] = DateTimeOffset.UtcNow,
|
||||
};
|
||||
try
|
||||
{
|
||||
await _tenantTableClient.AddEntityAsync(entity);
|
||||
return;
|
||||
}
|
||||
catch (RequestFailedException addEx) when (addEx.Status == 409)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 412)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogWarning("Failed to increment tenant usage after {MaxRetries} retries: tenant={TenantId}, user={UserId}",
|
||||
maxRetries, LogSanitizer.Clean(tenantId), LogSanitizer.Clean(userId));
|
||||
}
|
||||
|
||||
public async Task<List<TenantUsageRecord>> GetTenantUsageAsync(string tenantId, string? dateFrom = null, string? dateTo = null)
|
||||
{
|
||||
var records = new List<TenantUsageRecord>();
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
return records;
|
||||
|
||||
await foreach (var entity in _tenantTableClient.QueryAsync<TableEntity>(filter: BuildTenantCounterFilter(tenantId, dateFrom, dateTo)))
|
||||
{
|
||||
records.Add(new TenantUsageRecord
|
||||
{
|
||||
TenantId = entity.PartitionKey,
|
||||
UserId = entity.GetString("UserId") ?? string.Empty,
|
||||
Date = entity.GetString("Date") ?? entity.RowKey.Split('_')[0],
|
||||
RequestCount = entity.TryGetValue("RequestCount", out var rc) ? Convert.ToInt64(rc) : 0L,
|
||||
});
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Partition + RowKey range filter for McpTenantUsage: the RowKey starts with the yyyyMMdd day, so a
|
||||
/// date window is a key range ("{to}~" sorts after every "{to}_{oid}"); no property scan.
|
||||
/// </summary>
|
||||
internal static string BuildTenantCounterFilter(string tenantId, string? dateFrom, string? dateTo)
|
||||
{
|
||||
var filter = $"PartitionKey eq '{ODataSanitizer.EscapeValue(tenantId)}'";
|
||||
if (!string.IsNullOrEmpty(dateFrom))
|
||||
filter += $" and RowKey ge '{ODataSanitizer.EscapeValue(dateFrom.Replace("-", ""))}'";
|
||||
if (!string.IsNullOrEmpty(dateTo))
|
||||
filter += $" and RowKey lt '{ODataSanitizer.EscapeValue(dateTo.Replace("-", ""))}~'";
|
||||
return filter;
|
||||
}
|
||||
|
||||
public async Task<int> DeleteTenantRecordsOlderThanAsync(string dateCutoff)
|
||||
{
|
||||
var filter = $"RowKey lt '{ODataSanitizer.EscapeValue(dateCutoff)}'";
|
||||
var toDelete = new List<(string partitionKey, string rowKey)>();
|
||||
await foreach (var entity in _tenantTableClient.QueryAsync<TableEntity>(
|
||||
filter: filter, select: new[] { "PartitionKey", "RowKey" }))
|
||||
{
|
||||
toDelete.Add((entity.PartitionKey, entity.RowKey));
|
||||
}
|
||||
|
||||
int deleted = 0;
|
||||
foreach (var (pk, rk) in toDelete)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _tenantTableClient.DeleteEntityAsync(pk, rk);
|
||||
deleted++;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Already deleted, skip
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
private static UserUsageRecord MapToRecord(TableEntity entity)
|
||||
{
|
||||
return new UserUsageRecord
|
||||
|
||||
@@ -82,7 +82,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
MonthlyLimit = quota.MonthlyLimit,
|
||||
DailyUsed = quota.DailyUsed,
|
||||
MonthlyUsed = quota.MonthlyUsed,
|
||||
ResetUtc = quota.ResetUtc
|
||||
ResetUtc = quota.ResetUtc,
|
||||
TenantPlan = quota.TenantPlan,
|
||||
TenantDailyLimit = quota.TenantDailyLimit,
|
||||
TenantMonthlyLimit = quota.TenantMonthlyLimit,
|
||||
TenantDailyUsed = quota.TenantDailyUsed,
|
||||
TenantMonthlyUsed = quota.TenantMonthlyUsed
|
||||
},
|
||||
Records = records
|
||||
});
|
||||
|
||||
@@ -12,7 +12,8 @@ using Microsoft.Extensions.Logging;
|
||||
namespace AutopilotMonitor.Functions.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Enforces the per-user MCP daily/monthly request quota. Runs after
|
||||
/// Enforces the MCP daily/monthly request quota — the caller's own budget AND the organization-wide
|
||||
/// budget of the token's tenant (both counted here, both must be free). Runs after
|
||||
/// <see cref="UserRateLimitMiddleware"/> (per-minute burst control) — this is the budget layer on
|
||||
/// top. Applies ONLY to HTTP requests marked <c>X-Client-Source: mcp</c> with an authenticated
|
||||
/// principal (oid); everything else passes through untouched. Global Admins are exempt from the
|
||||
@@ -118,22 +119,38 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
|
||||
var retryAfterSeconds = Math.Max(1, (int)(decision.ResetUtc - DateTime.UtcNow).TotalSeconds);
|
||||
_logger.LogWarning(
|
||||
"[McpQuota] BLOCKED oid={Oid} plan={Plan} scope={Scope} daily={DailyUsed}/{DailyLimit} monthly={MonthlyUsed}/{MonthlyLimit}",
|
||||
oid, decision.Plan, decision.Scope, decision.DailyUsed, decision.DailyLimit, decision.MonthlyUsed, decision.MonthlyLimit);
|
||||
"[McpQuota] BLOCKED oid={Oid} tenant={TenantId} level={Level} scope={Scope} plan={Plan} daily={DailyUsed}/{DailyLimit} monthly={MonthlyUsed}/{MonthlyLimit} tenantDaily={TenantDailyUsed}/{TenantDailyLimit} tenantMonthly={TenantMonthlyUsed}/{TenantMonthlyLimit}",
|
||||
oid, tenantId, decision.Level, decision.Scope, decision.Plan, decision.DailyUsed, decision.DailyLimit, decision.MonthlyUsed, decision.MonthlyLimit,
|
||||
decision.TenantDailyUsed, decision.TenantDailyLimit, decision.TenantMonthlyUsed, decision.TenantMonthlyLimit);
|
||||
|
||||
httpContext.Response.StatusCode = (int)HttpStatusCode.TooManyRequests;
|
||||
httpContext.Response.Headers["Retry-After"] = retryAfterSeconds.ToString();
|
||||
httpContext.Response.ContentType = "application/json";
|
||||
await httpContext.Response.WriteAsJsonAsync(new McpQuotaExceededResponse
|
||||
await httpContext.Response.WriteAsJsonAsync(BuildExceededResponse(decision));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The 429 body for a blocked decision. The message names WHOSE budget is exhausted — a member hitting the
|
||||
/// organization-wide window must not conclude their own plan is too small.
|
||||
/// </summary>
|
||||
internal static McpQuotaExceededResponse BuildExceededResponse(McpQuotaDecision decision)
|
||||
{
|
||||
var reset = $"Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z.";
|
||||
var message = decision.Level == McpQuotaLevel.Tenant
|
||||
? $"MCP {decision.Scope} request quota of your organization exceeded (tenant plan '{decision.TenantPlan}', shared by all its members). {reset}"
|
||||
: $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. {reset}";
|
||||
|
||||
return new McpQuotaExceededResponse
|
||||
{
|
||||
QuotaExceeded = true,
|
||||
Plan = decision.Plan,
|
||||
Scope = decision.Scope,
|
||||
Limit = decision.Scope == "monthly" ? decision.MonthlyLimit : decision.DailyLimit,
|
||||
Used = decision.Scope == "monthly" ? decision.MonthlyUsed : decision.DailyUsed,
|
||||
Level = decision.Level ?? McpQuotaLevel.User,
|
||||
Limit = decision.ExceededLimit,
|
||||
Used = decision.ExceededUsed,
|
||||
ResetUtc = decision.ResetUtc.ToString("yyyy-MM-ddTHH:mm:ssZ"),
|
||||
Message = $"MCP {decision.Scope} request quota exceeded for plan '{decision.Plan}'. Resets at {decision.ResetUtc:yyyy-MM-ddTHH:mm:ss}Z."
|
||||
});
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -162,6 +179,19 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
{
|
||||
logger.LogWarning(ex, "[McpQuota] Failed to record usage: user={UserId}, endpoint={Endpoint}", LogSanitizer.Clean(oid), LogSanitizer.Clean(normalizedEndpoint));
|
||||
}
|
||||
|
||||
// Organization-wide counter (the tenant quota's source). Separate try so a failure on one
|
||||
// table never starves the other; GA usage lands here too (exempt from the check, not from the count).
|
||||
if (tidValue.Length == 0)
|
||||
return;
|
||||
try
|
||||
{
|
||||
await repo.IncrementTenantUsageAsync(tidValue, oid);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "[McpQuota] Failed to record tenant usage: tenant={TenantId}, user={UserId}", LogSanitizer.Clean(tidValue), LogSanitizer.Clean(oid));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,10 +201,15 @@ public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
httpContext.Response.Headers["X-MCP-Quota-Plan"] = decision.Plan;
|
||||
httpContext.Response.Headers["X-MCP-Quota-Daily-Limit"] = decision.DailyLimit.ToString();
|
||||
httpContext.Response.Headers["X-MCP-Quota-Monthly-Limit"] = decision.MonthlyLimit.ToString();
|
||||
httpContext.Response.Headers["X-MCP-Quota-Tenant-Plan"] = decision.TenantPlan;
|
||||
httpContext.Response.Headers["X-MCP-Quota-Tenant-Daily-Limit"] = decision.TenantDailyLimit.ToString();
|
||||
httpContext.Response.Headers["X-MCP-Quota-Tenant-Monthly-Limit"] = decision.TenantMonthlyLimit.ToString();
|
||||
if (decision.DailyUsed >= 0)
|
||||
{
|
||||
httpContext.Response.Headers["X-MCP-Quota-Daily-Used"] = decision.DailyUsed.ToString();
|
||||
httpContext.Response.Headers["X-MCP-Quota-Monthly-Used"] = decision.MonthlyUsed.ToString();
|
||||
httpContext.Response.Headers["X-MCP-Quota-Tenant-Daily-Used"] = decision.TenantDailyUsed.ToString();
|
||||
httpContext.Response.Headers["X-MCP-Quota-Tenant-Monthly-Used"] = decision.TenantMonthlyUsed.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +72,15 @@ namespace AutopilotMonitor.Functions.Security
|
||||
|
||||
/// <summary>Fallback MCP monthly request limit when no SectionUsagePlans row matches the plan name.</summary>
|
||||
public int McpMonthlyRequestLimit { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Organization-wide MCP daily limit all members of a tenant on this edition share (per-user
|
||||
/// overrides never lift it). Fallback when the plan's SectionUsagePlans row sets no tenant limit.
|
||||
/// </summary>
|
||||
public int McpTenantDailyRequestLimit { get; init; }
|
||||
|
||||
/// <summary>Organization-wide MCP monthly limit — see <see cref="McpTenantDailyRequestLimit"/>.</summary>
|
||||
public int McpTenantMonthlyRequestLimit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -116,7 +125,9 @@ namespace AutopilotMonitor.Functions.Security
|
||||
UnrestrictedModeAvailable = false,
|
||||
McpUsagePlanName = CommunityTierName,
|
||||
McpDailyRequestLimit = 100,
|
||||
McpMonthlyRequestLimit = 3000
|
||||
McpMonthlyRequestLimit = 3000,
|
||||
McpTenantDailyRequestLimit = 300,
|
||||
McpTenantMonthlyRequestLimit = 9000
|
||||
};
|
||||
|
||||
private static readonly EditionEntitlements Pro = new()
|
||||
@@ -130,7 +141,9 @@ namespace AutopilotMonitor.Functions.Security
|
||||
UnrestrictedModeAvailable = true,
|
||||
McpUsagePlanName = ProTierName,
|
||||
McpDailyRequestLimit = 1000,
|
||||
McpMonthlyRequestLimit = 20000
|
||||
McpMonthlyRequestLimit = 20000,
|
||||
McpTenantDailyRequestLimit = 3000,
|
||||
McpTenantMonthlyRequestLimit = 60000
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -552,13 +552,13 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes usage tracking records older than 90 days from UserUsageLog.
|
||||
/// Deletes usage tracking records older than 90 days from UserUsageLog and McpTenantUsage.
|
||||
/// </summary>
|
||||
private async Task CleanupOldUsageDataAsync()
|
||||
{
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-90).ToString("yyyyMMdd");
|
||||
try
|
||||
{
|
||||
var cutoffDate = DateTime.UtcNow.AddDays(-90).ToString("yyyyMMdd");
|
||||
var deleted = await _userUsageRepo.DeleteRecordsOlderThanAsync(cutoffDate);
|
||||
|
||||
if (deleted > 0)
|
||||
@@ -569,6 +569,18 @@ namespace AutopilotMonitor.Functions.Services
|
||||
_logger.LogWarning(ex, "Failed to cleanup old usage data");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var deleted = await _userUsageRepo.DeleteTenantRecordsOlderThanAsync(cutoffDate);
|
||||
|
||||
if (deleted > 0)
|
||||
_logger.LogInformation("Tenant MCP usage cleanup: deleted {Count} counters older than 90 days (cutoff: {Cutoff})", deleted, cutoffDate);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to cleanup old tenant MCP usage counters");
|
||||
}
|
||||
|
||||
// Rule stats retention: delete entries older than 90 days
|
||||
try
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
@@ -10,20 +11,27 @@ using Microsoft.Extensions.Logging;
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves and enforces the per-user MCP request quota (daily + monthly).
|
||||
/// Resolves and enforces the MCP request quota: a per-USER budget (daily + monthly) AND a per-TENANT
|
||||
/// budget (daily + monthly) that every member's requests count against. Both must be free for a
|
||||
/// request to pass; the tenant budget is what makes "just create ten more accounts" pointless.
|
||||
///
|
||||
/// Plan precedence: explicit per-user override (McpUsers.UsagePlan — honoured only for the identity
|
||||
/// User plan precedence: explicit per-user override (McpUsers.UsagePlan — honoured only for the identity
|
||||
/// the UPN is bound to, tid + oid, see McpUserService.GetBoundMcpUserAsync) → the caller's home-tenant
|
||||
/// edition default (FeatureEntitlementCatalog.McpUsagePlanName). Limits come from the
|
||||
/// admin-editable SectionUsagePlans definitions (AdminConfiguration.PlanTierDefinitionsJson);
|
||||
/// when no definition matches the plan name, the static catalog fallbacks apply. An override
|
||||
/// naming a plan that exists nowhere resolves to the Community fallback (fail-closed).
|
||||
/// edition default (FeatureEntitlementCatalog.McpUsagePlanName). Limits come from the admin-editable
|
||||
/// SectionUsagePlans definitions (AdminConfiguration.PlanTierDefinitionsJson); when no definition matches
|
||||
/// the plan name, the static catalog fallbacks apply. An override naming a plan that exists nowhere
|
||||
/// resolves to the Community fallback (fail-closed).
|
||||
///
|
||||
/// Counters ride on the existing UserUsage table (written fire-and-forget by
|
||||
/// AuthenticationMiddleware for X-Client-Source: mcp requests): one partition-bounded query
|
||||
/// per check covering the current month; daily = today's rows, monthly = the sum. The
|
||||
/// decision is cached per instance for 60 seconds, so the worst-case overshoot is bounded
|
||||
/// (limit + 60s × request-rate) — the same posture as the sliding-window rate limiter.
|
||||
/// Tenant plan: ALWAYS the tenant's edition plan — a per-user override lifts that person's own budget,
|
||||
/// never the organization's. A definition without tenant limits falls back to the edition's catalog
|
||||
/// tenant limits; an explicit 0 lifts that window.
|
||||
///
|
||||
/// Counters: user counters ride on the UserUsageLog table (PK = oid), tenant counters on the
|
||||
/// McpTenantUsage table (PK = tenantId, RK = {yyyyMMdd}_{oid} — one partition read per check, no hot
|
||||
/// row); both are written fire-and-forget by McpQuotaEnforcementMiddleware for X-Client-Source: mcp
|
||||
/// requests. Daily = today's rows, monthly = the sum over the month. The decision is cached per user
|
||||
/// for 60 seconds, so the worst-case overshoot is bounded (limit + 60s × request-rate) — the same
|
||||
/// posture as the sliding-window rate limiter.
|
||||
/// </summary>
|
||||
public class McpQuotaService
|
||||
{
|
||||
@@ -68,9 +76,10 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the caller's effective plan + limits and checks the current usage against them.
|
||||
/// Fail-open on counter/storage errors (a broken quota check must not take down MCP);
|
||||
/// fail-closed on plan resolution (unknown plan → Community limits).
|
||||
/// Resolves the caller's effective plans + limits and checks the current user AND tenant usage against
|
||||
/// them. <paramref name="tenantId"/> is the token's tid — a delegated (MSP) admin therefore draws on
|
||||
/// their HOME tenant's budget, never on a managed customer's. Fail-open on counter/storage errors (a
|
||||
/// broken quota check must not take down MCP); fail-closed on plan resolution (unknown plan → Community).
|
||||
/// </summary>
|
||||
public virtual async Task<McpQuotaDecision> CheckAsync(string oid, string? upn, string? tenantId)
|
||||
{
|
||||
@@ -78,29 +87,36 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (_cache.TryGetValue<McpQuotaDecision>(cacheKey, out var cached) && cached != null)
|
||||
return cached;
|
||||
|
||||
var (planName, dailyLimit, monthlyLimit) = await ResolvePlanAsync(AdminIdentity.Create(upn, tenantId, oid), tenantId);
|
||||
var limits = await ResolvePlanAsync(AdminIdentity.Create(upn, tenantId, oid), tenantId);
|
||||
|
||||
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
||||
long dailyUsed = 0, monthlyUsed = 0;
|
||||
long dailyUsed = 0, monthlyUsed = 0, tenantDailyUsed = 0, tenantMonthlyUsed = 0;
|
||||
try
|
||||
{
|
||||
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc).ToString("yyyyMMdd");
|
||||
var today = nowUtc.ToString("yyyyMMdd");
|
||||
var records = await _usageRepo.GetUsageByUserAsync(
|
||||
oid, monthStart.ToString("yyyyMMdd"), today);
|
||||
|
||||
var records = await _usageRepo.GetUsageByUserAsync(oid, monthStart, today);
|
||||
monthlyUsed = records.Sum(r => r.RequestCount);
|
||||
dailyUsed = records.Where(r => r.Date == today).Sum(r => r.RequestCount);
|
||||
|
||||
// The tenant read is skipped when nothing could ever block on it (no tenant, or both windows lifted).
|
||||
if (!string.IsNullOrWhiteSpace(tenantId) && (limits.TenantDailyLimit > 0 || limits.TenantMonthlyLimit > 0))
|
||||
{
|
||||
var tenantRecords = await _usageRepo.GetTenantUsageAsync(tenantId, monthStart, today);
|
||||
tenantMonthlyUsed = tenantRecords.Sum(r => r.RequestCount);
|
||||
tenantDailyUsed = tenantRecords.Where(r => r.Date == today).Sum(r => r.RequestCount);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Fail-open: usage counters unavailable → allow. Do NOT cache the fail-open
|
||||
// decision — the next request retries the read.
|
||||
_logger.LogWarning(ex, "[McpQuota] Usage lookup failed for oid={Oid} — allowing (fail-open)", oid);
|
||||
return McpQuotaDecision.FailOpen(planName, dailyLimit, monthlyLimit);
|
||||
return McpQuotaDecision.FailOpen(limits);
|
||||
}
|
||||
|
||||
var decision = BuildDecision(planName, dailyLimit, monthlyLimit, dailyUsed, monthlyUsed, nowUtc);
|
||||
var decision = BuildDecision(limits, dailyUsed, monthlyUsed, tenantDailyUsed, tenantMonthlyUsed, nowUtc);
|
||||
_cache.Set(cacheKey, decision, CacheDuration);
|
||||
return decision;
|
||||
}
|
||||
@@ -109,9 +125,9 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// Plan resolution only (no counter read) — used by the self-service usage endpoint.
|
||||
/// <paramref name="identity"/> is the caller's validated (upn, tid, oid); the per-user override applies
|
||||
/// only when that identity IS the one the McpUsers UPN is bound to — a null / unbound identity gets the
|
||||
/// tenant edition default.
|
||||
/// tenant edition default. The tenant limits always follow the tenant's edition plan.
|
||||
/// </summary>
|
||||
public virtual async Task<(string PlanName, int DailyLimit, int MonthlyLimit)> ResolvePlanAsync(AdminIdentity? identity, string? tenantId)
|
||||
public virtual async Task<McpPlanLimits> ResolvePlanAsync(AdminIdentity? identity, string? tenantId)
|
||||
{
|
||||
// 1. Per-user override wins when set — for the BOUND identity only.
|
||||
string? overridePlan = null;
|
||||
@@ -130,55 +146,96 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
// 2. Tenant edition default (fail-closed → Community inside the entitlement service).
|
||||
var entitlements = await _entitlementService.GetEntitlementsAsync(tenantId);
|
||||
var planName = overridePlan ?? entitlements.McpUsagePlanName;
|
||||
var tenantPlan = entitlements.McpUsagePlanName;
|
||||
var planName = overridePlan ?? tenantPlan;
|
||||
|
||||
// 3. Limits: admin-edited SectionUsagePlans definition for that name, else catalog
|
||||
// fallback for the edition plans, else Community fallback (fail-closed for
|
||||
// overrides naming a plan that exists nowhere).
|
||||
// 3. Limits: admin-edited SectionUsagePlans definitions, else catalog fallbacks.
|
||||
var definitions = new List<PlanTierDefinition>();
|
||||
try
|
||||
{
|
||||
var adminConfig = await _adminConfigService.GetConfigurationAsync();
|
||||
var definition = PlanTierDefinitionParser.Parse(adminConfig.PlanTierDefinitionsJson)
|
||||
.FirstOrDefault(t => string.Equals(t.Name, planName, StringComparison.OrdinalIgnoreCase));
|
||||
if (definition != null)
|
||||
return (planName, definition.DailyRequestLimit, definition.MonthlyRequestLimit);
|
||||
definitions = PlanTierDefinitionParser.Parse(adminConfig.PlanTierDefinitionsJson);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[McpQuota] Plan definitions unavailable — using catalog fallback for plan {Plan}", planName);
|
||||
}
|
||||
|
||||
var fallback = FeatureEntitlementCatalog.IsPermanentProTier(planName)
|
||||
// User limits: the definition for the (possibly overridden) plan name, else the catalog fallback
|
||||
// for the edition plans, else Community (fail-closed for overrides naming a plan that exists nowhere).
|
||||
var userDefinition = Find(definitions, planName);
|
||||
var userFallback = FeatureEntitlementCatalog.IsPermanentProTier(planName)
|
||||
? FeatureEntitlementCatalog.Get(TenantEdition.Pro)
|
||||
: FeatureEntitlementCatalog.Get(TenantEdition.Community);
|
||||
return (planName, fallback.McpDailyRequestLimit, fallback.McpMonthlyRequestLimit);
|
||||
var dailyLimit = userDefinition?.DailyRequestLimit ?? userFallback.McpDailyRequestLimit;
|
||||
var monthlyLimit = userDefinition?.MonthlyRequestLimit ?? userFallback.McpMonthlyRequestLimit;
|
||||
|
||||
// Tenant limits: the TENANT plan's definition when it carries tenant limits (null = not set → the
|
||||
// edition's catalog tenant limits; an explicit 0 lifts the window), never the per-user override.
|
||||
var tenantDefinition = Find(definitions, tenantPlan);
|
||||
var tenantDailyLimit = tenantDefinition?.TenantDailyRequestLimit ?? entitlements.McpTenantDailyRequestLimit;
|
||||
var tenantMonthlyLimit = tenantDefinition?.TenantMonthlyRequestLimit ?? entitlements.McpTenantMonthlyRequestLimit;
|
||||
|
||||
return new McpPlanLimits(planName, dailyLimit, monthlyLimit, tenantPlan, tenantDailyLimit, tenantMonthlyLimit);
|
||||
}
|
||||
|
||||
internal static McpQuotaDecision BuildDecision(
|
||||
string planName, int dailyLimit, int monthlyLimit, long dailyUsed, long monthlyUsed, DateTime nowUtc)
|
||||
{
|
||||
// Daily window resets at midnight UTC; monthly on the 1st. 0/negative limit = unlimited
|
||||
// for that scope (an operator can deliberately lift a scope via SectionUsagePlans).
|
||||
var dailyExceeded = dailyLimit > 0 && dailyUsed >= dailyLimit;
|
||||
var monthlyExceeded = monthlyLimit > 0 && monthlyUsed >= monthlyLimit;
|
||||
private static PlanTierDefinition? Find(List<PlanTierDefinition> definitions, string planName)
|
||||
=> definitions.FirstOrDefault(t => string.Equals(t.Name, planName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
string? exceededScope = monthlyExceeded ? "monthly" : dailyExceeded ? "daily" : null;
|
||||
var resetUtc = exceededScope == "monthly"
|
||||
internal static McpQuotaDecision BuildDecision(
|
||||
McpPlanLimits limits,
|
||||
long dailyUsed, long monthlyUsed, long tenantDailyUsed, long tenantMonthlyUsed,
|
||||
DateTime nowUtc)
|
||||
{
|
||||
// Daily windows reset at midnight UTC; monthly on the 1st. 0/negative limit = unlimited
|
||||
// for that window (an operator can deliberately lift a window via SectionUsagePlans).
|
||||
// Report the LONGEST exceeded window first so Retry-After is honest; within a window the
|
||||
// caller's own budget is named before the organization's.
|
||||
string? scope = null, level = null;
|
||||
if (Exceeded(limits.MonthlyLimit, monthlyUsed)) (scope, level) = ("monthly", McpQuotaLevel.User);
|
||||
else if (Exceeded(limits.TenantMonthlyLimit, tenantMonthlyUsed)) (scope, level) = ("monthly", McpQuotaLevel.Tenant);
|
||||
else if (Exceeded(limits.DailyLimit, dailyUsed)) (scope, level) = ("daily", McpQuotaLevel.User);
|
||||
else if (Exceeded(limits.TenantDailyLimit, tenantDailyUsed)) (scope, level) = ("daily", McpQuotaLevel.Tenant);
|
||||
|
||||
var resetUtc = scope == "monthly"
|
||||
? new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(1)
|
||||
: nowUtc.Date.AddDays(1);
|
||||
|
||||
return new McpQuotaDecision
|
||||
{
|
||||
Allowed = exceededScope == null,
|
||||
Plan = planName,
|
||||
Scope = exceededScope,
|
||||
DailyLimit = dailyLimit,
|
||||
MonthlyLimit = monthlyLimit,
|
||||
Allowed = scope == null,
|
||||
Plan = limits.PlanName,
|
||||
Scope = scope,
|
||||
Level = level,
|
||||
DailyLimit = limits.DailyLimit,
|
||||
MonthlyLimit = limits.MonthlyLimit,
|
||||
DailyUsed = dailyUsed,
|
||||
MonthlyUsed = monthlyUsed,
|
||||
TenantPlan = limits.TenantPlan,
|
||||
TenantDailyLimit = limits.TenantDailyLimit,
|
||||
TenantMonthlyLimit = limits.TenantMonthlyLimit,
|
||||
TenantDailyUsed = tenantDailyUsed,
|
||||
TenantMonthlyUsed = tenantMonthlyUsed,
|
||||
ResetUtc = resetUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static bool Exceeded(int limit, long used) => limit > 0 && used >= limit;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolved plan names and limits for one caller: the user's plan (override or tenant edition) with the
|
||||
/// user windows, and the tenant's edition plan with the organization-wide windows. 0 = unlimited.
|
||||
/// </summary>
|
||||
public sealed record McpPlanLimits(
|
||||
string PlanName, int DailyLimit, int MonthlyLimit,
|
||||
string TenantPlan, int TenantDailyLimit, int TenantMonthlyLimit);
|
||||
|
||||
/// <summary>Whose budget a blocked decision names — wire vocabulary of <c>level</c>.</summary>
|
||||
public static class McpQuotaLevel
|
||||
{
|
||||
public const string User = "user";
|
||||
public const string Tenant = "tenant";
|
||||
}
|
||||
|
||||
/// <summary>Outcome of an MCP quota check.</summary>
|
||||
@@ -188,21 +245,44 @@ namespace AutopilotMonitor.Functions.Services
|
||||
public string Plan { get; init; } = string.Empty;
|
||||
/// <summary>Which window was exceeded ("daily"/"monthly"), null when allowed.</summary>
|
||||
public string? Scope { get; init; }
|
||||
/// <summary>Whose budget was exceeded (<see cref="McpQuotaLevel"/>), null when allowed.</summary>
|
||||
public string? Level { get; init; }
|
||||
public int DailyLimit { get; init; }
|
||||
public int MonthlyLimit { get; init; }
|
||||
public long DailyUsed { get; init; }
|
||||
public long MonthlyUsed { get; init; }
|
||||
/// <summary>The tenant's edition plan — the organization-wide windows follow it, never the override.</summary>
|
||||
public string TenantPlan { get; init; } = string.Empty;
|
||||
public int TenantDailyLimit { get; init; }
|
||||
public int TenantMonthlyLimit { get; init; }
|
||||
public long TenantDailyUsed { get; init; }
|
||||
public long TenantMonthlyUsed { get; init; }
|
||||
/// <summary>When the exceeded (or daily, when allowed) window resets.</summary>
|
||||
public DateTime ResetUtc { get; init; }
|
||||
|
||||
public static McpQuotaDecision FailOpen(string plan, int dailyLimit, int monthlyLimit) => new()
|
||||
/// <summary>Limit of the exceeded window (0 when allowed).</summary>
|
||||
public int ExceededLimit => Level == McpQuotaLevel.Tenant
|
||||
? (Scope == "monthly" ? TenantMonthlyLimit : TenantDailyLimit)
|
||||
: (Scope == "monthly" ? MonthlyLimit : DailyLimit);
|
||||
|
||||
/// <summary>Used count of the exceeded window.</summary>
|
||||
public long ExceededUsed => Level == McpQuotaLevel.Tenant
|
||||
? (Scope == "monthly" ? TenantMonthlyUsed : TenantDailyUsed)
|
||||
: (Scope == "monthly" ? MonthlyUsed : DailyUsed);
|
||||
|
||||
public static McpQuotaDecision FailOpen(McpPlanLimits limits) => new()
|
||||
{
|
||||
Allowed = true,
|
||||
Plan = plan,
|
||||
DailyLimit = dailyLimit,
|
||||
MonthlyLimit = monthlyLimit,
|
||||
Plan = limits.PlanName,
|
||||
DailyLimit = limits.DailyLimit,
|
||||
MonthlyLimit = limits.MonthlyLimit,
|
||||
DailyUsed = -1,
|
||||
MonthlyUsed = -1,
|
||||
TenantPlan = limits.TenantPlan,
|
||||
TenantDailyLimit = limits.TenantDailyLimit,
|
||||
TenantMonthlyLimit = limits.TenantMonthlyLimit,
|
||||
TenantDailyUsed = -1,
|
||||
TenantMonthlyUsed = -1,
|
||||
ResetUtc = DateTime.MinValue
|
||||
};
|
||||
}
|
||||
|
||||
+2
@@ -101,6 +101,8 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
|
||||
// Intune script display-name cache (PK=tenantId). Only had TTL eviction before;
|
||||
// tenant script names must not outlive the tenant.
|
||||
Constants.TableNames.ScriptNameCache,
|
||||
// Organization-wide MCP quota counters (PK=tenantId, RK={yyyyMMdd}_{oid}); also 90d retention.
|
||||
Constants.TableNames.McpTenantUsage,
|
||||
};
|
||||
|
||||
// Variant D — RowKey-anchored wipes for tables whose ROW key is the tenant id.
|
||||
|
||||
Reference in New Issue
Block a user