mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat(entitlements): Community/Enterprise editions with self-service trial
Fail-closed entitlement layer (FeatureEntitlementCatalog + TenantEntitlementService): edition resolves at read time (PlanTier == 'enterprise' OR active trial; legacy free/pro => Community, no migration).
- Plan/trial endpoints: PATCH config/{tid}/plan (GA: tier + grant/extend/end trial, audited, cache-safe) and POST config/{tid}/trial (tenant-admin self-service 30d, once per tenant, 409 after). Generic PUT preserves plan/trial fields for all callers. Fixes SetPlanTier writing past the config cache without audit/UpdatedBy.
- Rate limits: Enterprise floor 150/min for BOTH user (portal/JWT) and device (agent/cert) paths - floor raises the global default, an explicit per-tenant override still wins; MSP users follow their home tenant.
- Retention cap: Community 90d / Enterprise 365d, clamped in the retention fanout (warn log) + non-GA write validation 7..cap (only on changed values; 0=infinite stays GA-only).
- Delegated admin (MSP): Enterprise gate on the delegated admin's HOME tenant (JWT tid, fail-closed on unknown) - managed target tenants may be any edition. Grant-time target check removed; inert grants are suppressed at resolve time (single choke point in GetScopeAsync, applied after the UPN-keyed cache).
- MCP quota: McpQuotaService + McpQuotaEnforcementMiddleware (X-Client-Source: mcp only, GA exempt) enforce daily/monthly budgets - per-user plan override > tenant edition plan; limits from SectionUsagePlans with catalog fallbacks (community 100/3000, enterprise 1000/20000); 429 with X-MCP-Quota-* headers + Retry-After; fail-open on counter reads, fail-closed on plan resolution. /api/metrics/mcp-usage/me reports effective plan + counters.
- Trial visibility: TrialExpirySweepFunction (daily 03:30 UTC, informational) emits TenantTrialExpired (Warning, 24h look-back) + TenantTrialExpiring (Info, <=3d heads-up); dual-registered in web OPS_EVENT_TYPES.
- Web: EditionBadge + one-time trial CTA in settings header, Plan & Trial card in the GA tenant edit modal (own PATCH save), retention cap hints from entitlements, pure lib/edition.ts (vitest), feature-flags surface edition/trial/entitlements; MCP TENANT_SAFE_FIELDS + trial fields.
Verified: solution build clean; backend 2653/2653 tests; web tsc + 516/516 vitest; MCP server tsc + tests green.
This commit is contained in:
@@ -43,9 +43,11 @@ public class AuthFunctionSideEffectTests
|
||||
{ CallBase = false };
|
||||
|
||||
var delegatedAdminMock = new Mock<DelegatedAdminService>(
|
||||
adminRepo, cache, Mock.Of<ILogger<DelegatedAdminService>>())
|
||||
adminRepo,
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
|
||||
cache, Mock.Of<ILogger<DelegatedAdminService>>())
|
||||
{ CallBase = false };
|
||||
delegatedAdminMock.Setup(x => x.GetScopeAsync(It.IsAny<string>()))
|
||||
delegatedAdminMock.Setup(x => x.GetScopeAsync(It.IsAny<string>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(DelegatedScope.Empty);
|
||||
|
||||
_tenantAdminsMock = new Mock<TenantAdminsService>(
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the Enterprise entitlement gate in <see cref="DelegatedAdminService.GetScopeAsync"/>:
|
||||
/// the delegated ("MSP") admin's HOME tenant (JWT tid) must be Enterprise for their scope to
|
||||
/// take effect — the MANAGED (target) tenants may be any edition (an Enterprise MSP may manage
|
||||
/// Community customers). Null/unknown home tenant fails closed to an empty scope. This single
|
||||
/// choke-point gates ALL delegated-scope consumers; grant rows stay stored but become inert.
|
||||
/// </summary>
|
||||
public class DelegatedAdminEditionGateTests
|
||||
{
|
||||
private const string Upn = "msp-admin@partner.example";
|
||||
private const string EnterpriseHomeTenant = "11111111-1111-1111-1111-111111111111";
|
||||
private const string CommunityHomeTenant = "22222222-2222-2222-2222-222222222222";
|
||||
private const string ManagedTenantA = "33333333-3333-3333-3333-333333333333";
|
||||
private const string ManagedTenantB = "44444444-4444-4444-4444-444444444444";
|
||||
|
||||
private static DelegatedAdminEntry Row(string tenantId) => new()
|
||||
{
|
||||
Upn = Upn,
|
||||
TenantId = tenantId,
|
||||
Role = Constants.DelegatedRoles.DelegatedAdmin,
|
||||
IsEnabled = true,
|
||||
Status = Constants.DelegatedStatus.Active,
|
||||
Source = Constants.DelegatedSource.OperatorGranted,
|
||||
};
|
||||
|
||||
/// <summary>Entitlements: ONLY the Enterprise home tenant resolves Enterprise — managed targets are Community.</summary>
|
||||
private static DelegatedAdminService Build(params DelegatedAdminEntry[] rows)
|
||||
{
|
||||
var repo = new Mock<IAdminRepository>();
|
||||
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>())).ReturnsAsync(rows.ToList());
|
||||
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(new List<TenantGroupAssignment>());
|
||||
return new DelegatedAdminService(
|
||||
repo.Object,
|
||||
new StubTenantEntitlementService(
|
||||
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Enterprise : TenantEdition.Community),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnterpriseHomeTenant_KeepsFullScope_EvenOverCommunityTargets()
|
||||
{
|
||||
// Managed targets resolve Community in the stub — they must NOT be dropped.
|
||||
var svc = Build(Row(ManagedTenantA), Row(ManagedTenantB));
|
||||
|
||||
var scope = await svc.GetScopeAsync(Upn, EnterpriseHomeTenant);
|
||||
|
||||
Assert.True(scope.Covers(ManagedTenantA));
|
||||
Assert.True(scope.Covers(ManagedTenantB));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CommunityHomeTenant_SuppressesEntireScope()
|
||||
{
|
||||
var svc = Build(Row(ManagedTenantA), Row(ManagedTenantB));
|
||||
|
||||
var scope = await svc.GetScopeAsync(Upn, CommunityHomeTenant);
|
||||
|
||||
Assert.True(scope.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UnknownHomeTenant_FailsClosedToEmptyScope()
|
||||
{
|
||||
var svc = Build(Row(ManagedTenantA));
|
||||
|
||||
Assert.True((await svc.GetScopeAsync(Upn, null)).IsEmpty);
|
||||
Assert.True((await svc.GetScopeAsync(Upn, "")).IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GateAppliesAfterCacheRead_NotBakedIntoCachedScope()
|
||||
{
|
||||
// The scope cache is keyed on UPN only — the gate must be re-evaluated per call, so a
|
||||
// Community-homed call must not poison the cache for a later Enterprise-homed call
|
||||
// (and vice versa). Same UPN, different declared home tid (defensive; tid is stable in
|
||||
// practice but the cache must stay pure either way).
|
||||
var svc = Build(Row(ManagedTenantA));
|
||||
|
||||
Assert.True((await svc.GetScopeAsync(Upn, CommunityHomeTenant)).IsEmpty);
|
||||
Assert.True((await svc.GetScopeAsync(Upn, EnterpriseHomeTenant)).Covers(ManagedTenantA));
|
||||
Assert.True((await svc.GetScopeAsync(Upn, CommunityHomeTenant)).IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GroupDerivedScope_AlsoGatedByHomeTenant()
|
||||
{
|
||||
var repo = new Mock<IAdminRepository>();
|
||||
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(new List<DelegatedAdminEntry>());
|
||||
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(new List<TenantGroupAssignment>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Upn = Upn,
|
||||
GroupId = "tpl-aaaaaaaa",
|
||||
Role = Constants.DelegatedRoles.DelegatedReader,
|
||||
IsEnabled = true,
|
||||
AssignedBy = "ga@vendor.example",
|
||||
}
|
||||
});
|
||||
repo.Setup(r => r.GetGroupTenantsAsync("tpl-aaaaaaaa"))
|
||||
.ReturnsAsync(new List<string> { ManagedTenantA, ManagedTenantB });
|
||||
|
||||
var svc = new DelegatedAdminService(
|
||||
repo.Object,
|
||||
new StubTenantEntitlementService(
|
||||
tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Enterprise : TenantEdition.Community),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
|
||||
Assert.True((await svc.GetScopeAsync(Upn, CommunityHomeTenant)).IsEmpty);
|
||||
|
||||
var enterpriseScope = await svc.GetScopeAsync(Upn, EnterpriseHomeTenant);
|
||||
Assert.True(enterpriseScope.Covers(ManagedTenantA));
|
||||
Assert.True(enterpriseScope.Covers(ManagedTenantB));
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,13 @@ public class DelegatedAdminServiceTests
|
||||
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(new List<TenantGroupAssignment>());
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var svc = new DelegatedAdminService(repo.Object, cache, NullLogger<DelegatedAdminService>.Instance);
|
||||
// Entitlements stubbed to Enterprise for every tenant — these tests pin ROLE resolution;
|
||||
// the edition filter has its own dedicated tests (DelegatedAdminEditionGateTests).
|
||||
var svc = new DelegatedAdminService(
|
||||
repo.Object,
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
|
||||
cache,
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
return (svc, repo);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Pins the edition-resolution matrix and the per-edition entitlement values of
|
||||
/// <see cref="FeatureEntitlementCatalog"/>. Fail-closed contract: ONLY the exact tier
|
||||
/// "enterprise" (or an active trial) yields Enterprise — legacy stored tiers ("free", "pro"),
|
||||
/// null/empty and unknown values all resolve to Community without any data migration.
|
||||
/// </summary>
|
||||
public class FeatureEntitlementCatalogTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 7, 7, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// ── ResolveEdition matrix ────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("enterprise")]
|
||||
[InlineData("Enterprise")]
|
||||
[InlineData("ENTERPRISE")]
|
||||
[InlineData(" enterprise ")]
|
||||
public void ResolveEdition_EnterpriseTier_IsEnterprise(string tier)
|
||||
{
|
||||
Assert.Equal(TenantEdition.Enterprise, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("free")]
|
||||
[InlineData("pro")]
|
||||
[InlineData("community")]
|
||||
[InlineData("")]
|
||||
[InlineData(null)]
|
||||
[InlineData("premium")] // unknown value → fail-closed
|
||||
public void ResolveEdition_NonEnterpriseTier_IsCommunity(string? tier)
|
||||
{
|
||||
Assert.Equal(TenantEdition.Community, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEdition_ActiveTrial_IsEnterprise_EvenOnFreeTier()
|
||||
{
|
||||
Assert.Equal(TenantEdition.Enterprise,
|
||||
FeatureEntitlementCatalog.ResolveEdition("free", Now.AddSeconds(1), Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEdition_TrialExpiringExactlyNow_IsCommunity()
|
||||
{
|
||||
// Strict '>' — a trial ending exactly now is already over.
|
||||
Assert.Equal(TenantEdition.Community,
|
||||
FeatureEntitlementCatalog.ResolveEdition("free", Now, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEdition_ExpiredTrial_IsCommunity()
|
||||
{
|
||||
Assert.Equal(TenantEdition.Community,
|
||||
FeatureEntitlementCatalog.ResolveEdition(null, Now.AddDays(-1), Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveEdition_ExpiredTrial_ButEnterpriseTier_StaysEnterprise()
|
||||
{
|
||||
Assert.Equal(TenantEdition.Enterprise,
|
||||
FeatureEntitlementCatalog.ResolveEdition("enterprise", Now.AddDays(-1), Now));
|
||||
}
|
||||
|
||||
// ── Entitlement values ───────────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Community_Entitlements_MatchMatrix()
|
||||
{
|
||||
var e = FeatureEntitlementCatalog.Get(TenantEdition.Community);
|
||||
Assert.Equal(90, e.RetentionCapDays);
|
||||
Assert.Null(e.UserRateLimitPerMinute);
|
||||
Assert.Null(e.DeviceRateLimitPerMinute);
|
||||
Assert.False(e.DelegatedAdminAllowed);
|
||||
Assert.Equal("community", e.McpUsagePlanName);
|
||||
Assert.Equal(100, e.McpDailyRequestLimit);
|
||||
Assert.Equal(3000, e.McpMonthlyRequestLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Enterprise_Entitlements_MatchMatrix()
|
||||
{
|
||||
var e = FeatureEntitlementCatalog.Get(TenantEdition.Enterprise);
|
||||
Assert.Equal(365, e.RetentionCapDays);
|
||||
Assert.Equal(150, e.UserRateLimitPerMinute);
|
||||
Assert.Equal(150, e.DeviceRateLimitPerMinute);
|
||||
Assert.True(e.DelegatedAdminAllowed);
|
||||
Assert.Equal("enterprise", e.McpUsagePlanName);
|
||||
Assert.Equal(1000, e.McpDailyRequestLimit);
|
||||
Assert.Equal(20000, e.McpMonthlyRequestLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Get_UnknownEnumValue_FallsBackToCommunity()
|
||||
{
|
||||
var e = FeatureEntitlementCatalog.Get((TenantEdition)42);
|
||||
Assert.Equal(TenantEdition.Community, e.Edition);
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,11 @@ namespace AutopilotMonitor.Functions.Tests;
|
||||
/// </summary>
|
||||
public class GetTenantFeatureFlagsPayloadTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 7, 7, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static JsonElement Serialize(TenantConfiguration config)
|
||||
{
|
||||
var payload = GetTenantFeatureFlagsFunction.BuildPayload(config);
|
||||
var payload = GetTenantFeatureFlagsFunction.BuildPayload(config, Now);
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
@@ -33,9 +35,14 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
Assert.Equal(new[]
|
||||
{
|
||||
"bootstrapTokenEnabled",
|
||||
"edition",
|
||||
"enableIntegrityBypassAnalyzer",
|
||||
"enableSoftwareInventoryAnalyzer",
|
||||
"entitlements",
|
||||
"isTrial",
|
||||
"showScriptOutput",
|
||||
"trialAvailable",
|
||||
"trialExpiresUtc",
|
||||
"unrestrictedMode",
|
||||
"validateAutopilotDevice",
|
||||
}, fieldNames);
|
||||
@@ -97,7 +104,7 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
WebhookUrl = "https://hooks.example.com/services/secret-generic-hook",
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config));
|
||||
var json = JsonSerializer.Serialize(GetTenantFeatureFlagsFunction.BuildPayload(config, Now));
|
||||
|
||||
Assert.DoesNotContain("secret-sas-token", json);
|
||||
Assert.DoesNotContain("secret-team-hook", json);
|
||||
@@ -106,4 +113,54 @@ public class GetTenantFeatureFlagsPayloadTests
|
||||
Assert.DoesNotContain("teamsWebhookUrl", json, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("webhookUrl", json, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// ── Edition / trial surface ─────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void Payload_CommunityDefault_EditionAndTrialAvailability()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration()); // PlanTier default "free" → Community
|
||||
|
||||
Assert.Equal("community", element.GetProperty("edition").GetString());
|
||||
Assert.False(element.GetProperty("isTrial").GetBoolean());
|
||||
Assert.True(element.GetProperty("trialAvailable").GetBoolean());
|
||||
Assert.Equal(90, element.GetProperty("entitlements").GetProperty("retentionCapDays").GetInt32());
|
||||
Assert.False(element.GetProperty("entitlements").GetProperty("delegatedAdminAllowed").GetBoolean());
|
||||
Assert.Equal("community", element.GetProperty("entitlements").GetProperty("mcpUsagePlan").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_EnterpriseTier_NotTrial()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration { PlanTier = "enterprise" });
|
||||
|
||||
Assert.Equal("enterprise", element.GetProperty("edition").GetString());
|
||||
Assert.False(element.GetProperty("isTrial").GetBoolean());
|
||||
Assert.False(element.GetProperty("trialAvailable").GetBoolean());
|
||||
Assert.Equal(365, element.GetProperty("entitlements").GetProperty("retentionCapDays").GetInt32());
|
||||
Assert.Equal(150, element.GetProperty("entitlements").GetProperty("userRateLimitPerMinute").GetInt32());
|
||||
Assert.True(element.GetProperty("entitlements").GetProperty("delegatedAdminAllowed").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_ActiveTrial_IsTrialWithExpiry()
|
||||
{
|
||||
var expiry = Now.AddDays(10);
|
||||
var element = Serialize(new TenantConfiguration { TrialExpiresUtc = expiry, TrialConsumed = true });
|
||||
|
||||
Assert.Equal("enterprise", element.GetProperty("edition").GetString());
|
||||
Assert.True(element.GetProperty("isTrial").GetBoolean());
|
||||
Assert.False(element.GetProperty("trialAvailable").GetBoolean());
|
||||
Assert.Equal(expiry, element.GetProperty("trialExpiresUtc").GetDateTime());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Payload_ExpiredTrial_DegradesToCommunity_NoTrialAvailable()
|
||||
{
|
||||
var element = Serialize(new TenantConfiguration { TrialExpiresUtc = Now.AddDays(-1), TrialConsumed = true });
|
||||
|
||||
Assert.Equal("community", element.GetProperty("edition").GetString());
|
||||
Assert.False(element.GetProperty("isTrial").GetBoolean());
|
||||
Assert.False(element.GetProperty("trialAvailable").GetBoolean(), "consumed trial must not be offered again");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="McpQuotaService"/>: window math (daily/monthly, reset times), plan
|
||||
/// precedence (per-user override → tenant edition; SectionUsagePlans definition → catalog
|
||||
/// fallback), and the fail-open contract on counter errors.
|
||||
/// </summary>
|
||||
public class McpQuotaServiceTests
|
||||
{
|
||||
private const string Oid = "00000000-0000-0000-0000-000000000001";
|
||||
private const string Upn = "alice@contoso.com";
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private static readonly DateTime Now = new(2026, 7, 7, 15, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
// ── BuildDecision (pure window math) ─────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void BuildDecision_UnderBothLimits_Allowed()
|
||||
{
|
||||
var d = McpQuotaService.BuildDecision("community", 100, 3000, dailyUsed: 99, monthlyUsed: 500, Now);
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Null(d.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDecision_DailyExceeded_BlocksWithMidnightReset()
|
||||
{
|
||||
var d = McpQuotaService.BuildDecision("community", 100, 3000, dailyUsed: 100, monthlyUsed: 500, Now);
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("daily", d.Scope);
|
||||
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);
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("monthly", d.Scope);
|
||||
Assert.Equal(new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc), d.ResetUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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);
|
||||
Assert.Equal("monthly", d.Scope);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDecision_ZeroLimit_MeansUnlimitedForThatScope()
|
||||
{
|
||||
var d = McpQuotaService.BuildDecision("custom", 0, 0, dailyUsed: 999999, monthlyUsed: 999999, Now);
|
||||
Assert.True(d.Allowed);
|
||||
}
|
||||
|
||||
// ── CheckAsync (integration over mocked deps) ────────────────────────────────
|
||||
|
||||
private static McpQuotaService Build(
|
||||
Mock<IUserUsageRepository> usageRepo,
|
||||
string? planDefinitionsJson = null,
|
||||
string? mcpUserPlanOverride = null,
|
||||
TenantEdition edition = TenantEdition.Community)
|
||||
{
|
||||
var adminRepo = new Mock<IAdminRepository>();
|
||||
adminRepo.Setup(r => r.GetMcpUserAsync(It.IsAny<string>()))
|
||||
.ReturnsAsync(mcpUserPlanOverride == null
|
||||
? null
|
||||
: new McpUserEntry { Upn = Upn, IsEnabled = true, UsagePlan = mcpUserPlanOverride });
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var mcpUserService = new McpUserService(
|
||||
adminRepo.Object, cache, NullLogger<McpUserService>.Instance,
|
||||
globalAdminService: null!, delegatedAdminService: null!, adminConfigService: null!);
|
||||
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
configRepo.Setup(r => r.GetAdminConfigurationAsync())
|
||||
.ReturnsAsync(new AutopilotMonitor.Shared.Models.AdminConfiguration
|
||||
{
|
||||
UpdatedBy = "test",
|
||||
PlanTierDefinitionsJson = planDefinitionsJson
|
||||
});
|
||||
var adminConfigService = new AdminConfigurationService(
|
||||
configRepo.Object, NullLogger<AdminConfigurationService>.Instance, cache);
|
||||
|
||||
return new McpQuotaService(
|
||||
usageRepo.Object,
|
||||
mcpUserService,
|
||||
adminConfigService,
|
||||
new StubTenantEntitlementService(edition),
|
||||
cache,
|
||||
NullLogger<McpQuotaService>.Instance,
|
||||
new TestTimeProvider(Now));
|
||||
}
|
||||
|
||||
private static Mock<IUserUsageRepository> UsageRepo(params (string Date, long Count)[] rows)
|
||||
{
|
||||
var repo = new Mock<IUserUsageRepository>();
|
||||
repo.Setup(r => r.GetUsageByUserAsync(Oid, It.IsAny<string?>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(rows.Select(r => new UserUsageRecord
|
||||
{
|
||||
UserId = Oid,
|
||||
Date = r.Date,
|
||||
RequestCount = r.Count
|
||||
}).ToList());
|
||||
return repo;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_CommunityFallback_DailyLimitEnforced()
|
||||
{
|
||||
// 100 requests today (catalog Community fallback: 100/day) → blocked.
|
||||
var svc = Build(UsageRepo(("20260707", 100)));
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("community", d.Plan);
|
||||
Assert.Equal("daily", d.Scope);
|
||||
Assert.Equal(100, d.DailyLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_EnterpriseEdition_UsesEnterpriseFallbackLimits()
|
||||
{
|
||||
var svc = Build(UsageRepo(("20260707", 100)), edition: TenantEdition.Enterprise);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal("enterprise", d.Plan);
|
||||
Assert.Equal(1000, d.DailyLimit);
|
||||
Assert.Equal(20000, d.MonthlyLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_MonthlySum_SpansAllRowsOfTheMonth()
|
||||
{
|
||||
// 2990 across the month + 20 today = 3010 ≥ 3000 monthly Community limit.
|
||||
var svc = Build(UsageRepo(("20260701", 1500), ("20260703", 1490), ("20260707", 20)));
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("monthly", d.Scope);
|
||||
Assert.Equal(3010, d.MonthlyUsed);
|
||||
Assert.Equal(20, d.DailyUsed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_AdminDefinedPlan_OverridesCatalogFallback()
|
||||
{
|
||||
var json = """[{"name":"community","dailyRequestLimit":5,"monthlyRequestLimit":50,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 5)), planDefinitionsJson: json);
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal(5, d.DailyLimit);
|
||||
Assert.Equal(50, d.MonthlyLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_PerUserOverride_WinsOverTenantEdition()
|
||||
{
|
||||
var json = """[{"name":"power","dailyRequestLimit":10000,"monthlyRequestLimit":100000,"description":""}]""";
|
||||
var svc = Build(UsageRepo(("20260707", 500)), planDefinitionsJson: json, mcpUserPlanOverride: "power");
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.True(d.Allowed);
|
||||
Assert.Equal("power", d.Plan);
|
||||
Assert.Equal(10000, d.DailyLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_UnknownOverridePlan_FailsClosedToCommunityLimits()
|
||||
{
|
||||
// Override names a plan that exists nowhere → Community fallback limits.
|
||||
var svc = Build(UsageRepo(("20260707", 100)), mcpUserPlanOverride: "no-such-plan");
|
||||
|
||||
var d = await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
|
||||
Assert.False(d.Allowed);
|
||||
Assert.Equal("no-such-plan", d.Plan);
|
||||
Assert.Equal(100, d.DailyLimit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_UsageLookupThrows_FailsOpen_AndDoesNotCache()
|
||||
{
|
||||
var repo = new Mock<IUserUsageRepository>();
|
||||
repo.Setup(r => r.GetUsageByUserAsync(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);
|
||||
// Fail-open decisions are not cached — the counter read is retried every request.
|
||||
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()),
|
||||
Times.Exactly(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Check_Decision_IsCachedPerUser()
|
||||
{
|
||||
var repo = UsageRepo(("20260707", 1));
|
||||
var svc = Build(repo);
|
||||
|
||||
await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
await svc.CheckAsync(Oid, Upn, TenantId);
|
||||
|
||||
repo.Verify(r => r.GetUsageByUserAsync(It.IsAny<string>(), It.IsAny<string?>(), It.IsAny<string?>()),
|
||||
Times.Once);
|
||||
}
|
||||
}
|
||||
@@ -35,13 +35,15 @@ public class McpUserServiceDelegatedTests
|
||||
_globalAdmin = new Mock<GlobalAdminService>(
|
||||
_adminRepo.Object, cache, NullLogger<GlobalAdminService>.Instance) { CallBase = false };
|
||||
_delegatedAdmin = new Mock<DelegatedAdminService>(
|
||||
_adminRepo.Object, cache, NullLogger<DelegatedAdminService>.Instance) { CallBase = false };
|
||||
_adminRepo.Object,
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
|
||||
cache, NullLogger<DelegatedAdminService>.Instance) { CallBase = false };
|
||||
_adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache) { CallBase = false };
|
||||
|
||||
// Defaults: no platform role, no delegated scope, WhitelistOnly, no McpUsers row (NotPresent).
|
||||
_globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny<string>())).ReturnsAsync((string?)null);
|
||||
_delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<string>())).ReturnsAsync(DelegatedScope.Empty);
|
||||
_delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<string>(), It.IsAny<string?>())).ReturnsAsync(DelegatedScope.Empty);
|
||||
_adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny<string>())).ReturnsAsync((McpUserEntry?)null);
|
||||
SetPolicy(McpAccessPolicy.WhitelistOnly);
|
||||
|
||||
@@ -59,7 +61,7 @@ public class McpUserServiceDelegatedTests
|
||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (tenant, role) in assignments)
|
||||
map[tenant.ToLowerInvariant()] = role;
|
||||
_delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<string>()))
|
||||
_delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny<string>(), It.IsAny<string?>()))
|
||||
.ReturnsAsync(new DelegatedScope(map));
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,13 @@ public class PolicyEnforcementMiddlewareTests
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var globalAdmin = new GlobalAdminService(repo.Object, cache, NullLogger<GlobalAdminService>.Instance);
|
||||
var delegatedAdmin = new DelegatedAdminService(repo.Object, cache, NullLogger<DelegatedAdminService>.Instance);
|
||||
// Entitlements stubbed to Enterprise — the delegated-rescue tests here pin AUTHORIZATION
|
||||
// mechanics; the Enterprise-only edition gate is covered by DelegatedAdminEditionGateTests.
|
||||
var delegatedAdmin = new DelegatedAdminService(
|
||||
repo.Object,
|
||||
new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise),
|
||||
cache,
|
||||
NullLogger<DelegatedAdminService>.Instance);
|
||||
var tenantAdmins = new TenantAdminsService(repo.Object, cache, NullLogger<TenantAdminsService>.Instance);
|
||||
var tenantConfig = new TenantConfigurationService(
|
||||
configRepo.Object, NullLogger<TenantConfigurationService>.Instance, cache);
|
||||
|
||||
@@ -46,4 +46,63 @@ public class RateLimitResolverTests
|
||||
isGlobalAdmin: true, tenantUserOverride: 300, globalUserDefault: 120, globalAdminDefault: 600);
|
||||
Assert.Equal(600, limit);
|
||||
}
|
||||
|
||||
// ── Edition entitlement floor (Enterprise: raises the DEFAULT, never beats an override) ──
|
||||
|
||||
[Fact]
|
||||
public void ResolveUserLimit_EnterpriseFloor_RaisesDefault()
|
||||
{
|
||||
var limit = RateLimitResolver.ResolveUserLimit(
|
||||
isGlobalAdmin: false, tenantUserOverride: null, globalUserDefault: 120, globalAdminDefault: 600,
|
||||
entitlementFloor: 150);
|
||||
Assert.Equal(150, limit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveUserLimit_FloorBelowAdminRaisedDefault_DefaultWins()
|
||||
{
|
||||
// An admin-raised global default (200) must never be LOWERED by the entitlement floor.
|
||||
var limit = RateLimitResolver.ResolveUserLimit(
|
||||
isGlobalAdmin: false, tenantUserOverride: null, globalUserDefault: 200, globalAdminDefault: 600,
|
||||
entitlementFloor: 150);
|
||||
Assert.Equal(200, limit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveUserLimit_ExplicitOverride_BeatsEnterpriseFloor()
|
||||
{
|
||||
// A GA-set per-tenant override (e.g. deliberate throttle to 50) wins outright — the floor
|
||||
// only raises the default, it must not undo a targeted override.
|
||||
var limit = RateLimitResolver.ResolveUserLimit(
|
||||
isGlobalAdmin: false, tenantUserOverride: 50, globalUserDefault: 120, globalAdminDefault: 600,
|
||||
entitlementFloor: 150);
|
||||
Assert.Equal(50, limit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveUserLimit_GlobalAdmin_FloorNeverApplies()
|
||||
{
|
||||
var limit = RateLimitResolver.ResolveUserLimit(
|
||||
isGlobalAdmin: true, tenantUserOverride: null, globalUserDefault: 120, globalAdminDefault: 600,
|
||||
entitlementFloor: 9999);
|
||||
Assert.Equal(600, limit);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDeviceLimit_EnterpriseFloor_RaisesDefault()
|
||||
{
|
||||
Assert.Equal(150, RateLimitResolver.ResolveDeviceLimit(tenantOverride: null, globalDefault: 100, entitlementFloor: 150));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDeviceLimit_ExplicitOverride_BeatsEnterpriseFloor()
|
||||
{
|
||||
Assert.Equal(50, RateLimitResolver.ResolveDeviceLimit(tenantOverride: 50, globalDefault: 100, entitlementFloor: 150));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDeviceLimit_NullFloor_CommunityUnchanged()
|
||||
{
|
||||
Assert.Equal(100, RateLimitResolver.ResolveDeviceLimit(tenantOverride: null, globalDefault: 100, entitlementFloor: null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,9 @@ public class SessionRetentionFanoutServiceTests
|
||||
var harness = new Harness();
|
||||
// Tenant A: 30d retention → eligible at 45d
|
||||
harness.WithTenant(TenantA, retentionDays: 30, sessions: new[] { Old("a1", 45) });
|
||||
// Tenant B: 120d retention → 45d-old session is NOT eligible (only 200d-old is)
|
||||
harness.WithTenant(TenantB, retentionDays: 120, sessions: new[] { Old("b1", 45), Old("b2", 200) });
|
||||
// Tenant B: 120d retention — above the Community cap (90), so B must be Enterprise for
|
||||
// the 120d cutoff to remain effective (Community would be clamped to 90).
|
||||
harness.WithTenant(TenantB, retentionDays: 120, sessions: new[] { Old("b1", 45), Old("b2", 200) }, planTier: "enterprise");
|
||||
|
||||
// The repo only returns sessions older than each cutoff. Wire each tenant's mock to honor that.
|
||||
harness.MaintenanceRepo.Setup(m => m.GetSessionsOlderThanAsync(TenantA, It.IsAny<DateTime>(), It.IsAny<int>(), It.IsAny<bool>()))
|
||||
@@ -119,6 +120,37 @@ public class SessionRetentionFanoutServiceTests
|
||||
harness.MaintenanceRepo.Verify(m => m.GetSessionsOlderThanAsync(TenantA, It.IsAny<DateTime>(), It.IsAny<int>(), It.IsAny<bool>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_clamps_community_retention_to_90_days()
|
||||
{
|
||||
// Edition retention cap (fail-closed backstop): a Community tenant with a stored value
|
||||
// above the cap (e.g. legacy 180d, or a trial that expired) is enforced at 90 — the
|
||||
// cutoff passed to storage must be ~90 days, not the stored 180.
|
||||
var harness = new Harness();
|
||||
harness.WithTenant(TenantA, retentionDays: 180, sessions: new[] { Old("s1", 100) });
|
||||
|
||||
await harness.Sut.RunAsync(CancellationToken.None);
|
||||
|
||||
harness.MaintenanceRepo.Verify(m => m.GetSessionsOlderThanAsync(
|
||||
TenantA,
|
||||
It.Is<DateTime>(d => d > DateTime.UtcNow.AddDays(-91) && d <= DateTime.UtcNow.AddDays(-89)),
|
||||
It.IsAny<int>(), It.IsAny<bool>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_does_not_clamp_enterprise_retention_within_365()
|
||||
{
|
||||
var harness = new Harness();
|
||||
harness.WithTenant(TenantA, retentionDays: 180, sessions: new[] { Old("s1", 200) }, planTier: "enterprise");
|
||||
|
||||
await harness.Sut.RunAsync(CancellationToken.None);
|
||||
|
||||
harness.MaintenanceRepo.Verify(m => m.GetSessionsOlderThanAsync(
|
||||
TenantA,
|
||||
It.Is<DateTime>(d => d > DateTime.UtcNow.AddDays(-181) && d <= DateTime.UtcNow.AddDays(-179)),
|
||||
It.IsAny<int>(), It.IsAny<bool>()), Times.Once);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────── PR6 follow-up F2 ─
|
||||
|
||||
[Fact]
|
||||
@@ -339,11 +371,11 @@ public class SessionRetentionFanoutServiceTests
|
||||
throttle: (_, _) => Task.CompletedTask);
|
||||
}
|
||||
|
||||
public void WithTenant(string tenantId, int retentionDays, SessionSummary[] sessions)
|
||||
public void WithTenant(string tenantId, int retentionDays, SessionSummary[] sessions, string planTier = "free")
|
||||
{
|
||||
_tenantIds.Add(tenantId);
|
||||
TenantConfig.Setup(t => t.GetConfigurationAsync(tenantId))
|
||||
.ReturnsAsync(new TenantConfiguration { TenantId = tenantId, DataRetentionDays = retentionDays });
|
||||
.ReturnsAsync(new TenantConfiguration { TenantId = tenantId, DataRetentionDays = retentionDays, PlanTier = planTier });
|
||||
MaintenanceRepo.Setup(m => m.GetSessionsOlderThanAsync(tenantId, It.IsAny<DateTime>(), It.IsAny<int>(), It.IsAny<bool>()))
|
||||
.ReturnsAsync(new List<SessionSummary>(WithTenantId(tenantId, sessions)));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Test stub for <see cref="TenantEntitlementService"/> — returns a fixed (or per-tenant) edition
|
||||
/// without any storage dependency. Used wherever a service under test consumes entitlements but
|
||||
/// the test's subject is something else (e.g. delegated-scope role resolution).
|
||||
/// </summary>
|
||||
internal sealed class StubTenantEntitlementService : TenantEntitlementService
|
||||
{
|
||||
private readonly Func<string?, TenantEdition> _resolver;
|
||||
|
||||
public StubTenantEntitlementService(TenantEdition edition) : this(_ => edition)
|
||||
{
|
||||
}
|
||||
|
||||
public StubTenantEntitlementService(Func<string?, TenantEdition> resolver)
|
||||
: base(configService: null!, logger: NullLogger<TenantEntitlementService>.Instance)
|
||||
{
|
||||
_resolver = resolver;
|
||||
}
|
||||
|
||||
public override Task<TenantEdition> GetEditionAsync(string? tenantId)
|
||||
=> Task.FromResult(_resolver(tenantId));
|
||||
|
||||
public override Task<EditionEntitlements> GetEntitlementsAsync(string? tenantId)
|
||||
=> Task.FromResult(FeatureEntitlementCatalog.Get(_resolver(tenantId)));
|
||||
}
|
||||
@@ -27,7 +27,8 @@ public class TenantConfigProjectionTests
|
||||
|
||||
private static readonly string[] AllSafeKeys =
|
||||
{
|
||||
"tenantId", "domainName", "planTier", "disabled", "disabledReason",
|
||||
"tenantId", "domainName", "planTier", "trialExpiresUtc", "trialConsumed",
|
||||
"disabled", "disabledReason",
|
||||
"onboardedAt", "onboardedBy", "lastUpdated", "dataRetentionDays",
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using AutopilotMonitor.Functions.DataAccess.TableStorage;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Azure.Data.Tables;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Store↔Map roundtrip for the tenant-config table serialization, focused on the plan/trial
|
||||
/// fields (project rule "table-serialization": every model field must appear in BOTH
|
||||
/// ConvertToTenantTableEntity and ConvertFromTenantTableEntity). Includes legacy-row shapes
|
||||
/// (rows written before the trial columns existed) which must map to safe defaults.
|
||||
/// </summary>
|
||||
public class TenantConfigTableSerializationTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_PlanAndTrialFields_SurviveStoreAndMap()
|
||||
{
|
||||
var expires = new DateTime(2026, 8, 6, 10, 30, 0, DateTimeKind.Utc);
|
||||
var started = new DateTime(2026, 7, 7, 10, 30, 0, DateTimeKind.Utc);
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
DomainName = "contoso.com",
|
||||
UpdatedBy = "admin@contoso.com",
|
||||
PlanTier = "enterprise",
|
||||
TrialExpiresUtc = expires,
|
||||
TrialStartedUtc = started,
|
||||
TrialConsumed = true,
|
||||
TrialGrantedBy = "alice@contoso.com"
|
||||
};
|
||||
|
||||
var entity = TableConfigRepository.ConvertToTenantTableEntity(config);
|
||||
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(entity);
|
||||
|
||||
Assert.Equal("enterprise", mapped.PlanTier);
|
||||
Assert.Equal(expires, mapped.TrialExpiresUtc);
|
||||
Assert.Equal(started, mapped.TrialStartedUtc);
|
||||
Assert.True(mapped.TrialConsumed);
|
||||
Assert.Equal("alice@contoso.com", mapped.TrialGrantedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Roundtrip_NoTrial_NullsStayNull()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
DomainName = "contoso.com",
|
||||
UpdatedBy = "admin@contoso.com"
|
||||
};
|
||||
|
||||
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(
|
||||
TableConfigRepository.ConvertToTenantTableEntity(config));
|
||||
|
||||
Assert.Null(mapped.TrialExpiresUtc);
|
||||
Assert.Null(mapped.TrialStartedUtc);
|
||||
Assert.False(mapped.TrialConsumed);
|
||||
Assert.Null(mapped.TrialGrantedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_LegacyRow_WithoutTrialColumns_DefaultsSafely()
|
||||
{
|
||||
// A row written before the trial fields existed: no Trial* columns at all, legacy tier.
|
||||
var entity = new TableEntity(TenantId, "config")
|
||||
{
|
||||
{ "DomainName", "fabrikam.com" },
|
||||
{ "PlanTier", "pro" }
|
||||
};
|
||||
|
||||
var mapped = TableConfigRepository.ConvertFromTenantTableEntity(entity);
|
||||
|
||||
Assert.Equal("pro", mapped.PlanTier); // legacy value preserved (resolves to Community at read time)
|
||||
Assert.Null(mapped.TrialExpiresUtc);
|
||||
Assert.False(mapped.TrialConsumed);
|
||||
Assert.Null(mapped.TrialGrantedBy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Map_LegacyRow_WithoutPlanTier_DefaultsToFree()
|
||||
{
|
||||
var entity = new TableEntity(TenantId, "config") { { "DomainName", "fabrikam.com" } };
|
||||
Assert.Equal("free", TableConfigRepository.ConvertFromTenantTableEntity(entity).PlanTier);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
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>
|
||||
/// Tests for <see cref="TenantEntitlementService"/>: read-time edition resolution on top of the
|
||||
/// tenant-config cache, the fail-closed contract (no row / storage error → Community), and the
|
||||
/// retention clamp (<see cref="TenantEntitlementService.GetEffectiveRetentionDays"/>).
|
||||
/// </summary>
|
||||
public class TenantEntitlementServiceTests
|
||||
{
|
||||
private const string TenantId = "11111111-1111-1111-1111-111111111111";
|
||||
private static readonly DateTime Now = new(2026, 7, 7, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
private static (TenantEntitlementService Svc, Mock<IConfigRepository> Repo) Build(TenantConfiguration? config)
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>())).ReturnsAsync(config);
|
||||
var configService = new TenantConfigurationService(
|
||||
repo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
var svc = new TenantEntitlementService(
|
||||
configService, NullLogger<TenantEntitlementService>.Instance, new TestTimeProvider(Now));
|
||||
return (svc, repo);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEdition_EnterpriseTier_ReturnsEnterprise()
|
||||
{
|
||||
var (svc, _) = Build(new TenantConfiguration { TenantId = TenantId, PlanTier = "enterprise" });
|
||||
Assert.Equal(TenantEdition.Enterprise, await svc.GetEditionAsync(TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEdition_ActiveTrial_ReturnsEnterprise()
|
||||
{
|
||||
var (svc, _) = Build(new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
PlanTier = "free",
|
||||
TrialExpiresUtc = Now.AddDays(3)
|
||||
});
|
||||
Assert.Equal(TenantEdition.Enterprise, await svc.GetEditionAsync(TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEdition_NoRow_FailsClosedToCommunity()
|
||||
{
|
||||
var (svc, _) = Build(config: null);
|
||||
Assert.Equal(TenantEdition.Community, await svc.GetEditionAsync(TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEdition_StorageError_FailsClosedToCommunity()
|
||||
{
|
||||
var repo = new Mock<IConfigRepository>();
|
||||
repo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny<string>()))
|
||||
.ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
var configService = new TenantConfigurationService(
|
||||
repo.Object, NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
|
||||
var svc = new TenantEntitlementService(
|
||||
configService, NullLogger<TenantEntitlementService>.Instance, new TestTimeProvider(Now));
|
||||
|
||||
Assert.Equal(TenantEdition.Community, await svc.GetEditionAsync(TenantId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEdition_EmptyTenantId_ReturnsCommunity()
|
||||
{
|
||||
var (svc, repo) = Build(new TenantConfiguration { TenantId = TenantId, PlanTier = "enterprise" });
|
||||
Assert.Equal(TenantEdition.Community, await svc.GetEditionAsync(""));
|
||||
Assert.Equal(TenantEdition.Community, await svc.GetEditionAsync(null));
|
||||
repo.Verify(r => r.GetTenantConfigurationAsync(It.IsAny<string>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEdition_NeverMaterializesAConfigRow()
|
||||
{
|
||||
// Uses the strict point-read: an entitlement check for an unregistered tenant must not
|
||||
// auto-create + persist a default config row.
|
||||
var (svc, repo) = Build(config: null);
|
||||
await svc.GetEditionAsync(TenantId);
|
||||
repo.Verify(r => r.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetEntitlements_ExpiredTrial_ReturnsCommunityValues()
|
||||
{
|
||||
var (svc, _) = Build(new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
PlanTier = "free",
|
||||
TrialExpiresUtc = Now.AddDays(-1),
|
||||
TrialConsumed = true
|
||||
});
|
||||
var e = await svc.GetEntitlementsAsync(TenantId);
|
||||
Assert.Equal(TenantEdition.Community, e.Edition);
|
||||
Assert.Equal(90, e.RetentionCapDays);
|
||||
}
|
||||
|
||||
// ── GetEffectiveRetentionDays ────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("free", 90, 90)] // at the cap — unchanged
|
||||
[InlineData("free", 60, 60)] // below the cap — unchanged
|
||||
[InlineData("free", 91, 90)] // above the Community cap — clamped
|
||||
[InlineData("free", 180, 90)] // legacy stored value above cap — clamped
|
||||
[InlineData("enterprise", 180, 180)]
|
||||
[InlineData("enterprise", 365, 365)]
|
||||
[InlineData("enterprise", 400, 365)] // above the Enterprise cap — clamped
|
||||
public void GetEffectiveRetentionDays_ClampsToEditionCap(string tier, int stored, int expected)
|
||||
{
|
||||
var config = new TenantConfiguration { TenantId = TenantId, PlanTier = tier, DataRetentionDays = stored };
|
||||
Assert.Equal(expected, TenantEntitlementService.GetEffectiveRetentionDays(config, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveRetentionDays_ZeroInfinite_IsNeverClamped()
|
||||
{
|
||||
// 0 = GA-only "infinite" escape hatch — passes through regardless of edition.
|
||||
var config = new TenantConfiguration { TenantId = TenantId, PlanTier = "free", DataRetentionDays = 0 };
|
||||
Assert.Equal(0, TenantEntitlementService.GetEffectiveRetentionDays(config, Now));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetEffectiveRetentionDays_TrialExpiry_DegradesCapFrom365To90()
|
||||
{
|
||||
var config = new TenantConfiguration
|
||||
{
|
||||
TenantId = TenantId,
|
||||
PlanTier = "free",
|
||||
DataRetentionDays = 365,
|
||||
TrialExpiresUtc = Now.AddDays(1)
|
||||
};
|
||||
Assert.Equal(365, TenantEntitlementService.GetEffectiveRetentionDays(config, Now));
|
||||
Assert.Equal(90, TenantEntitlementService.GetEffectiveRetentionDays(config, Now.AddDays(2)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace AutopilotMonitor.Functions.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Shared fixed-clock <see cref="TimeProvider"/> for tests that need deterministic time math
|
||||
/// (trial expiry, quota windows). Mutable via <see cref="SetUtcNow"/> to simulate passage of time.
|
||||
/// </summary>
|
||||
internal sealed class TestTimeProvider : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now;
|
||||
|
||||
public TestTimeProvider(DateTimeOffset now) => _now = now;
|
||||
|
||||
public void SetUtcNow(DateTimeOffset now) => _now = now;
|
||||
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using AutopilotMonitor.Functions.Functions.Maintenance;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Functions.Services.Notifications;
|
||||
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>
|
||||
/// Tests for <see cref="TrialExpirySweepFunction"/> — the informational daily sweep that surfaces
|
||||
/// trial transitions as ops events (TenantTrialExpired within the 24h look-back, TenantTrialExpiring
|
||||
/// within the 3-day heads-up). Enforcement is read-time; this timer is visibility only.
|
||||
/// </summary>
|
||||
public class TrialExpirySweepFunctionTests
|
||||
{
|
||||
private static readonly DateTime Now = new(2026, 7, 7, 3, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
private static TenantConfiguration Tenant(
|
||||
string id, DateTime? trialExpiresUtc, string planTier = "free", string domain = "contoso.com") => new()
|
||||
{
|
||||
TenantId = id,
|
||||
DomainName = domain,
|
||||
UpdatedBy = "test",
|
||||
PlanTier = planTier,
|
||||
TrialExpiresUtc = trialExpiresUtc,
|
||||
TrialConsumed = trialExpiresUtc.HasValue,
|
||||
};
|
||||
|
||||
private static (TrialExpirySweepFunction Sut, List<OpsEventEntry> Events) Build(params TenantConfiguration[] configs)
|
||||
{
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
configRepo.Setup(r => r.GetAllTenantConfigurationsAsync()).ReturnsAsync(configs.ToList());
|
||||
|
||||
var events = new List<OpsEventEntry>();
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>()))
|
||||
.Callback<OpsEventEntry>(events.Add)
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
adminConfig.Setup(a => a.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { UpdatedBy = "test" });
|
||||
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
|
||||
|
||||
var sut = new TrialExpirySweepFunction(
|
||||
configRepo.Object, opsService, NullLogger<TrialExpirySweepFunction>.Instance,
|
||||
new TestTimeProvider(Now));
|
||||
return (sut, events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpiredWithinLookBack_EmitsTenantTrialExpired()
|
||||
{
|
||||
var (sut, events) = Build(Tenant("t1", Now.AddHours(-2)));
|
||||
|
||||
var result = await sut.RunCoreAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.ExpiredEmitted);
|
||||
var e = Assert.Single(events);
|
||||
Assert.Equal("TenantTrialExpired", e.EventType);
|
||||
Assert.Equal("t1", e.TenantId);
|
||||
Assert.Equal(OpsEventSeverity.Warning, e.Severity);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpiredBeforeLookBack_StaysSilent()
|
||||
{
|
||||
// Reported by a previous daily run — must not re-emit forever.
|
||||
var (sut, events) = Build(Tenant("t1", Now.AddHours(-30)));
|
||||
|
||||
var result = await sut.RunCoreAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.ExpiredEmitted);
|
||||
Assert.Empty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpiringWithinHeadsUp_EmitsTenantTrialExpiring_WithDaysLeft()
|
||||
{
|
||||
var (sut, events) = Build(Tenant("t1", Now.AddDays(2)));
|
||||
|
||||
var result = await sut.RunCoreAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(1, result.ExpiringEmitted);
|
||||
var e = Assert.Single(events);
|
||||
Assert.Equal("TenantTrialExpiring", e.EventType);
|
||||
Assert.Equal(OpsEventSeverity.Info, e.Severity);
|
||||
Assert.Contains("2 day", e.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpiringBeyondHeadsUp_StaysSilent()
|
||||
{
|
||||
var (sut, events) = Build(Tenant("t1", Now.AddDays(5)));
|
||||
|
||||
await sut.RunCoreAsync(CancellationToken.None);
|
||||
|
||||
Assert.Empty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PermanentEnterpriseTenant_IsSkipped_TrialTimestampsAreInert()
|
||||
{
|
||||
// Upgraded mid-trial: PlanTier=enterprise — expiry changes nothing, no noise.
|
||||
var (sut, events) = Build(Tenant("t1", Now.AddHours(-1), planTier: "enterprise"));
|
||||
|
||||
var result = await sut.RunCoreAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.TrialsSeen);
|
||||
Assert.Empty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TenantsWithoutTrial_AreIgnored()
|
||||
{
|
||||
var (sut, events) = Build(Tenant("t1", null), Tenant("t2", null, planTier: "enterprise"));
|
||||
|
||||
var result = await sut.RunCoreAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.TrialsSeen);
|
||||
Assert.Empty(events);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MixedFleet_EmitsPerTenant()
|
||||
{
|
||||
var (sut, events) = Build(
|
||||
Tenant("expired", Now.AddHours(-3)),
|
||||
Tenant("expiring", Now.AddDays(1)),
|
||||
Tenant("healthy", Now.AddDays(20)),
|
||||
Tenant("no-trial", null));
|
||||
|
||||
var result = await sut.RunCoreAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(3, result.TrialsSeen);
|
||||
Assert.Equal(1, result.ExpiredEmitted);
|
||||
Assert.Equal(1, result.ExpiringEmitted);
|
||||
Assert.Equal(2, events.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfigLoadFailure_ReturnsEmptyResult_NeverThrows()
|
||||
{
|
||||
var configRepo = new Mock<IConfigRepository>();
|
||||
configRepo.Setup(r => r.GetAllTenantConfigurationsAsync())
|
||||
.ThrowsAsync(new InvalidOperationException("storage down"));
|
||||
|
||||
var opsRepo = new Mock<IOpsEventRepository>();
|
||||
var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var adminConfig = new Mock<AdminConfigurationService>(
|
||||
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache)
|
||||
{ CallBase = false };
|
||||
adminConfig.Setup(a => a.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { UpdatedBy = "test" });
|
||||
var alertDispatch = new OpsAlertDispatchService(
|
||||
adminConfig.Object,
|
||||
new TelegramNotificationService(new HttpClient(), Mock.Of<IConfigRepository>(), NullLogger<TelegramNotificationService>.Instance),
|
||||
new WebhookNotificationService(new HttpClient(), NullLogger<WebhookNotificationService>.Instance),
|
||||
NullLogger<OpsAlertDispatchService>.Instance);
|
||||
|
||||
var sut = new TrialExpirySweepFunction(
|
||||
configRepo.Object,
|
||||
new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch),
|
||||
NullLogger<TrialExpirySweepFunction>.Instance,
|
||||
new TestTimeProvider(Now));
|
||||
|
||||
var result = await sut.RunCoreAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, result.TrialsSeen);
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -324,7 +324,11 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
|
||||
// --- Tenant Configuration Entity Mapping ---
|
||||
|
||||
private TableEntity ConvertToTenantTableEntity(TenantConfiguration config)
|
||||
// Internal static (not private): the Store↔Map pair is a serialization CONTRACT — every
|
||||
// model field must appear in BOTH methods (project rule "table-serialization"). The
|
||||
// roundtrip tests (TenantConfigTableSerializationTests) pin that contract, incl. legacy
|
||||
// rows lacking the newer columns.
|
||||
internal static TableEntity ConvertToTenantTableEntity(TenantConfiguration config)
|
||||
{
|
||||
var entity = new TableEntity(config.TenantId, "config")
|
||||
{
|
||||
@@ -396,6 +400,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
{ "EntraAppRolesEnabled", config.EntraAppRolesEnabled },
|
||||
{ "OnboardedAt", config.OnboardedAt },
|
||||
{ "PlanTier", config.PlanTier },
|
||||
{ "TrialExpiresUtc", config.TrialExpiresUtc },
|
||||
{ "TrialStartedUtc", config.TrialStartedUtc },
|
||||
{ "TrialConsumed", config.TrialConsumed },
|
||||
{ "TrialGrantedBy", config.TrialGrantedBy },
|
||||
// SLA targets
|
||||
{ "SlaTargetSuccessRate", config.SlaTargetSuccessRate.HasValue ? (double)config.SlaTargetSuccessRate.Value : (double?)null },
|
||||
{ "SlaTargetMaxDurationMinutes", config.SlaTargetMaxDurationMinutes },
|
||||
@@ -412,7 +420,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
return entity;
|
||||
}
|
||||
|
||||
private TenantConfiguration ConvertFromTenantTableEntity(TableEntity entity)
|
||||
internal static TenantConfiguration ConvertFromTenantTableEntity(TableEntity entity)
|
||||
{
|
||||
return new TenantConfiguration
|
||||
{
|
||||
@@ -487,6 +495,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
EntraAppRolesEnabled = entity.GetBoolean("EntraAppRolesEnabled") ?? false,
|
||||
OnboardedAt = entity.GetDateTime("OnboardedAt"),
|
||||
PlanTier = entity.GetString("PlanTier") ?? "free",
|
||||
TrialExpiresUtc = entity.GetDateTime("TrialExpiresUtc"),
|
||||
TrialStartedUtc = entity.GetDateTime("TrialStartedUtc"),
|
||||
TrialConsumed = entity.GetBoolean("TrialConsumed") ?? false,
|
||||
TrialGrantedBy = entity.GetString("TrialGrantedBy"),
|
||||
// SLA targets
|
||||
SlaTargetSuccessRate = entity.GetDouble("SlaTargetSuccessRate") != null ? (decimal)entity.GetDouble("SlaTargetSuccessRate")! : null,
|
||||
SlaTargetMaxDurationMinutes = entity.GetInt32("SlaTargetMaxDurationMinutes"),
|
||||
|
||||
+5
@@ -78,6 +78,11 @@ public class DelegatedAdminManagementFunction
|
||||
if (role != Constants.DelegatedRoles.DelegatedReader && role != Constants.DelegatedRoles.DelegatedAdmin)
|
||||
return await Bad(req, $"role must be '{Constants.DelegatedRoles.DelegatedReader}' or '{Constants.DelegatedRoles.DelegatedAdmin}'");
|
||||
|
||||
// Edition note: TARGET tenants may be any edition — an MSP on Enterprise may manage Community
|
||||
// customers. The Enterprise requirement applies to the delegated admin's HOME tenant and is
|
||||
// enforced at resolve time (DelegatedAdminService.GetScopeAsync gates on the JWT tid). It cannot
|
||||
// be checked here: at grant time only the UPN is known, and UPN-domain → tenant mapping is not
|
||||
// reliable (multi-domain tenants). Grants to non-Enterprise-homed admins are simply inert.
|
||||
var entry = await _delegatedAdminService.UpsertAsync(
|
||||
body.Upn, body.TenantId, role,
|
||||
Constants.DelegatedStatus.Active, Constants.DelegatedSource.OperatorGranted, currentUpn ?? "");
|
||||
|
||||
+40
-17
@@ -2,6 +2,7 @@ using System;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
@@ -47,7 +48,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
var config = await _configService.GetConfigurationAsync(requestCtx.TargetTenantId);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(BuildPayload(config));
|
||||
await response.WriteAsJsonAsync(BuildPayload(config, DateTime.UtcNow));
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -62,23 +63,45 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
/// <summary>
|
||||
/// Projects the member-readable subset of <see cref="TenantConfiguration"/>.
|
||||
/// Pulled out as a static method so unit tests can verify field-level mapping
|
||||
/// without standing up an HttpRequestData mock.
|
||||
/// without standing up an HttpRequestData mock. <paramref name="nowUtc"/> feeds the
|
||||
/// read-time edition resolution (trial expiry degrades automatically).
|
||||
/// </summary>
|
||||
internal static object BuildPayload(TenantConfiguration config) => new
|
||||
internal static object BuildPayload(TenantConfiguration config, DateTime nowUtc)
|
||||
{
|
||||
bootstrapTokenEnabled = config.BootstrapTokenEnabled,
|
||||
// Drives the "Autopilot Device Validation disabled" dashboard banner
|
||||
// (useTenantSecurityConfig).
|
||||
validateAutopilotDevice = config.ValidateAutopilotDevice,
|
||||
// Session-detail UI flags (useSessionTenantConfig). Nullable in the model;
|
||||
// surface the agent-side defaults so the UI does not need a second nullable layer.
|
||||
showScriptOutput = config.ShowScriptOutput ?? true,
|
||||
enableSoftwareInventoryAnalyzer = config.EnableSoftwareInventoryAnalyzer ?? false,
|
||||
enableIntegrityBypassAnalyzer = config.EnableIntegrityBypassAnalyzer ?? true,
|
||||
// Gather-rules page validation indicator. UnrestrictedMode itself is just a display
|
||||
// hint — the privileged toggle is UnrestrictedModeEnabled (admin-only, stays in
|
||||
// the full config response).
|
||||
unrestrictedMode = config.UnrestrictedMode
|
||||
};
|
||||
var edition = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
|
||||
var entitlements = FeatureEntitlementCatalog.Get(edition);
|
||||
var isTrial = edition == TenantEdition.Enterprise &&
|
||||
!string.Equals(config.PlanTier?.Trim(), FeatureEntitlementCatalog.EnterpriseTierName, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return new
|
||||
{
|
||||
bootstrapTokenEnabled = config.BootstrapTokenEnabled,
|
||||
// Drives the "Autopilot Device Validation disabled" dashboard banner
|
||||
// (useTenantSecurityConfig).
|
||||
validateAutopilotDevice = config.ValidateAutopilotDevice,
|
||||
// Session-detail UI flags (useSessionTenantConfig). Nullable in the model;
|
||||
// surface the agent-side defaults so the UI does not need a second nullable layer.
|
||||
showScriptOutput = config.ShowScriptOutput ?? true,
|
||||
enableSoftwareInventoryAnalyzer = config.EnableSoftwareInventoryAnalyzer ?? false,
|
||||
enableIntegrityBypassAnalyzer = config.EnableIntegrityBypassAnalyzer ?? true,
|
||||
// Gather-rules page validation indicator. UnrestrictedMode itself is just a display
|
||||
// hint — the privileged toggle is UnrestrictedModeEnabled (admin-only, stays in
|
||||
// the full config response).
|
||||
unrestrictedMode = config.UnrestrictedMode,
|
||||
// Edition/entitlement surface (read-time resolution — non-sensitive by design):
|
||||
// drives the EditionBadge, trial CTA and retention hint in the web UI.
|
||||
edition = edition.ToString().ToLowerInvariant(),
|
||||
isTrial,
|
||||
trialExpiresUtc = isTrial ? config.TrialExpiresUtc : null,
|
||||
trialAvailable = !config.TrialConsumed && edition == TenantEdition.Community,
|
||||
entitlements = new
|
||||
{
|
||||
retentionCapDays = entitlements.RetentionCapDays,
|
||||
userRateLimitPerMinute = entitlements.UserRateLimitPerMinute,
|
||||
delegatedAdminAllowed = entitlements.DelegatedAdminAllowed,
|
||||
mcpUsagePlan = entitlements.McpUsagePlanName
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+239
-38
@@ -1,5 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models.Config;
|
||||
@@ -9,52 +11,121 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
/// <summary>
|
||||
/// Plan/edition management: GA-only plan+trial mutation (PATCH plan), tenant-admin self-service
|
||||
/// trial (POST trial), and the global usage-plan definitions (SectionUsagePlans). All writes go
|
||||
/// through <see cref="TenantConfigurationService"/> so the 5-minute config cache is invalidated,
|
||||
/// and every mutation is audited under the target tenant's trail.
|
||||
/// </summary>
|
||||
public class PlanManagementFunction
|
||||
{
|
||||
/// <summary>Self-service trial length. GA can grant arbitrary end dates via PATCH.</summary>
|
||||
internal const int SelfServiceTrialDays = 30;
|
||||
|
||||
private readonly ILogger<PlanManagementFunction> _logger;
|
||||
private readonly IConfigRepository _configRepo;
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly IMaintenanceRepository _maintenanceRepo;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public PlanManagementFunction(
|
||||
ILogger<PlanManagementFunction> logger,
|
||||
IConfigRepository configRepo,
|
||||
AdminConfigurationService adminConfigService)
|
||||
TenantConfigurationService configService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
IMaintenanceRepository maintenanceRepo)
|
||||
: this(logger, configService, adminConfigService, maintenanceRepo, TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam — inject a fake <see cref="TimeProvider"/> for deterministic trial math.</summary>
|
||||
public PlanManagementFunction(
|
||||
ILogger<PlanManagementFunction> logger,
|
||||
TenantConfigurationService configService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
IMaintenanceRepository maintenanceRepo,
|
||||
TimeProvider time)
|
||||
{
|
||||
_logger = logger;
|
||||
_configRepo = configRepo;
|
||||
_configService = configService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_maintenanceRepo = maintenanceRepo;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PATCH /api/config/{tenantId}/plan
|
||||
/// Sets the plan tier for a tenant. Body: { "planTier": "pro" }
|
||||
/// PATCH /api/config/{tenantId}/plan — GlobalAdminOnly (catalog-enforced).
|
||||
/// Body: { "planTier"?: "community"|"enterprise", "trialExpiresUtc"?: ISO-8601 | null }.
|
||||
/// Setting a trial date grants/extends the trial (TrialConsumed is NOT touched — GA
|
||||
/// re-grants stay possible); explicit null ends the trial. Absent properties are unchanged.
|
||||
/// </summary>
|
||||
[Function("SetTenantPlanTier")]
|
||||
public async Task<HttpResponseData> SetPlanTier(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "config/{tenantId}/plan")] HttpRequestData req,
|
||||
string tenantId)
|
||||
{
|
||||
_logger.LogInformation("SetTenantPlanTier: tenantId={TenantId}", tenantId);
|
||||
|
||||
try
|
||||
{
|
||||
var body = await req.ReadFromJsonAsync<SetPlanTierRequest>();
|
||||
if (body == null || string.IsNullOrWhiteSpace(body.PlanTier))
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var caller = requestCtx.UserPrincipalName ?? "Unknown";
|
||||
_logger.LogInformation("SetTenantPlanTier: tenantId={TenantId} by {User}", requestCtx.TargetTenantId, caller);
|
||||
|
||||
var body = await req.ReadAsStringAsync() ?? string.Empty;
|
||||
JsonDocument doc;
|
||||
try
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { error = "planTier is required" });
|
||||
return badRequest;
|
||||
doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return await BadRequestAsync(req, "Invalid JSON body");
|
||||
}
|
||||
|
||||
var validTiers = new[] { "free", "pro", "enterprise" };
|
||||
if (!validTiers.Contains(body.PlanTier.ToLowerInvariant()))
|
||||
string? newPlanTier = null;
|
||||
bool trialProvided = false;
|
||||
DateTime? newTrialExpiresUtc = null;
|
||||
|
||||
using (doc)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { error = $"Invalid planTier. Valid values: {string.Join(", ", validTiers)}" });
|
||||
return badRequest;
|
||||
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
||||
return await BadRequestAsync(req, "Body must be a JSON object");
|
||||
|
||||
if (doc.RootElement.TryGetProperty("planTier", out var tierProp))
|
||||
{
|
||||
if (tierProp.ValueKind != JsonValueKind.String)
|
||||
return await BadRequestAsync(req, "planTier must be a string");
|
||||
|
||||
newPlanTier = tierProp.GetString()!.Trim().ToLowerInvariant();
|
||||
if (newPlanTier != FeatureEntitlementCatalog.CommunityTierName &&
|
||||
newPlanTier != FeatureEntitlementCatalog.EnterpriseTierName)
|
||||
{
|
||||
return await BadRequestAsync(req,
|
||||
$"Invalid planTier. Valid values: {FeatureEntitlementCatalog.CommunityTierName}, {FeatureEntitlementCatalog.EnterpriseTierName}");
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.RootElement.TryGetProperty("trialExpiresUtc", out var trialProp))
|
||||
{
|
||||
trialProvided = true;
|
||||
if (trialProp.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
newTrialExpiresUtc = null; // explicit null = end trial
|
||||
}
|
||||
else if (trialProp.ValueKind == JsonValueKind.String &&
|
||||
trialProp.TryGetDateTime(out var parsed))
|
||||
{
|
||||
newTrialExpiresUtc = parsed.Kind == DateTimeKind.Utc ? parsed : parsed.ToUniversalTime();
|
||||
}
|
||||
else
|
||||
{
|
||||
return await BadRequestAsync(req, "trialExpiresUtc must be an ISO-8601 date-time string or null");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var config = await _configRepo.GetTenantConfigurationAsync(tenantId);
|
||||
if (newPlanTier == null && !trialProvided)
|
||||
return await BadRequestAsync(req, "Provide planTier and/or trialExpiresUtc");
|
||||
|
||||
var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
|
||||
if (config == null)
|
||||
{
|
||||
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
|
||||
@@ -62,11 +133,51 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
return notFound;
|
||||
}
|
||||
|
||||
config.PlanTier = body.PlanTier.ToLowerInvariant();
|
||||
await _configRepo.SaveTenantConfigurationAsync(config);
|
||||
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
||||
var changes = new Dictionary<string, string>();
|
||||
|
||||
if (newPlanTier != null && !string.Equals(config.PlanTier, newPlanTier, StringComparison.Ordinal))
|
||||
{
|
||||
changes["PlanTier"] = $"{config.PlanTier} -> {newPlanTier}";
|
||||
config.PlanTier = newPlanTier;
|
||||
}
|
||||
|
||||
if (trialProvided && config.TrialExpiresUtc != newTrialExpiresUtc)
|
||||
{
|
||||
changes["TrialExpiresUtc"] = $"{FormatUtc(config.TrialExpiresUtc)} -> {FormatUtc(newTrialExpiresUtc)}";
|
||||
config.TrialExpiresUtc = newTrialExpiresUtc;
|
||||
if (newTrialExpiresUtc.HasValue)
|
||||
{
|
||||
config.TrialStartedUtc ??= nowUtc;
|
||||
config.TrialGrantedBy = caller;
|
||||
}
|
||||
}
|
||||
|
||||
if (changes.Count > 0)
|
||||
{
|
||||
config.UpdatedBy = caller;
|
||||
await SaveInvalidatingOnFailureAsync(config);
|
||||
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
"UPDATE",
|
||||
"TenantPlan",
|
||||
requestCtx.TargetTenantId,
|
||||
caller,
|
||||
changes);
|
||||
}
|
||||
|
||||
var effectiveEdition = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { tenantId, planTier = config.PlanTier });
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
tenantId = requestCtx.TargetTenantId,
|
||||
planTier = config.PlanTier,
|
||||
trialExpiresUtc = config.TrialExpiresUtc,
|
||||
trialConsumed = config.TrialConsumed,
|
||||
effectiveEdition = effectiveEdition.ToString().ToLowerInvariant()
|
||||
});
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -78,6 +189,93 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/config/{tenantId}/trial — TenantAdminOrGA (catalog-enforced). Self-service
|
||||
/// 30-day Enterprise trial, exactly once per tenant. 409 when the trial was already
|
||||
/// consumed or the tenant is already effectively Enterprise.
|
||||
/// </summary>
|
||||
[Function("StartTenantTrial")]
|
||||
public async Task<HttpResponseData> StartTrial(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "config/{tenantId}/trial")] HttpRequestData req,
|
||||
string tenantId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var requestCtx = req.GetRequestContext();
|
||||
var caller = requestCtx.UserPrincipalName ?? "Unknown";
|
||||
_logger.LogInformation("StartTenantTrial: tenantId={TenantId} by {User}", requestCtx.TargetTenantId, caller);
|
||||
|
||||
var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
|
||||
if (config == null)
|
||||
{
|
||||
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
|
||||
await notFound.WriteAsJsonAsync(new { error = "Tenant not found" });
|
||||
return notFound;
|
||||
}
|
||||
|
||||
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
||||
|
||||
if (config.TrialConsumed)
|
||||
{
|
||||
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
|
||||
await conflict.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "TrialAlreadyConsumed",
|
||||
message = "This tenant has already used its one self-service Enterprise trial. Contact support to extend."
|
||||
});
|
||||
return conflict;
|
||||
}
|
||||
|
||||
if (FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc) == TenantEdition.Enterprise)
|
||||
{
|
||||
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
|
||||
await conflict.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "AlreadyEnterprise",
|
||||
message = "This tenant is already on the Enterprise edition."
|
||||
});
|
||||
return conflict;
|
||||
}
|
||||
|
||||
config.TrialStartedUtc = nowUtc;
|
||||
config.TrialExpiresUtc = nowUtc.AddDays(SelfServiceTrialDays);
|
||||
config.TrialConsumed = true;
|
||||
config.TrialGrantedBy = caller;
|
||||
config.UpdatedBy = caller;
|
||||
|
||||
await SaveInvalidatingOnFailureAsync(config);
|
||||
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
requestCtx.TargetTenantId,
|
||||
"CREATE",
|
||||
"TenantTrial",
|
||||
requestCtx.TargetTenantId,
|
||||
caller,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "TrialStartedUtc", FormatUtc(config.TrialStartedUtc) },
|
||||
{ "TrialExpiresUtc", FormatUtc(config.TrialExpiresUtc) }
|
||||
});
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
tenantId = requestCtx.TargetTenantId,
|
||||
trialStartedUtc = config.TrialStartedUtc,
|
||||
trialExpiresUtc = config.TrialExpiresUtc,
|
||||
effectiveEdition = FeatureEntitlementCatalog.EnterpriseTierName
|
||||
});
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error starting trial");
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(new { error = "Internal server error" });
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/config/plan-tiers
|
||||
/// Returns plan tier definitions from AdminConfiguration.PlanTierDefinitionsJson.
|
||||
@@ -89,7 +287,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
try
|
||||
{
|
||||
var config = await _adminConfigService.GetConfigurationAsync();
|
||||
var tiers = ParsePlanTierDefinitions(config.PlanTierDefinitionsJson);
|
||||
var tiers = PlanTierDefinitionParser.Parse(config.PlanTierDefinitionsJson);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { tiers });
|
||||
@@ -117,18 +315,14 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
var body = await req.ReadFromJsonAsync<SetPlanTierDefinitionsRequest>();
|
||||
if (body?.Tiers == null || body.Tiers.Count == 0)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { error = "At least one tier definition is required" });
|
||||
return badRequest;
|
||||
return await BadRequestAsync(req, "At least one tier definition is required");
|
||||
}
|
||||
|
||||
// Validate tier names are unique
|
||||
var names = body.Tiers.Select(t => t.Name.ToLowerInvariant()).ToList();
|
||||
if (names.Distinct().Count() != names.Count)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { error = "Tier names must be unique" });
|
||||
return badRequest;
|
||||
return await BadRequestAsync(req, "Tier names must be unique");
|
||||
}
|
||||
|
||||
// Normalize names to lowercase
|
||||
@@ -152,25 +346,32 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
}
|
||||
}
|
||||
|
||||
private static List<PlanTierDefinition> ParsePlanTierDefinitions(string? json)
|
||||
/// <summary>
|
||||
/// Saves via the service (which invalidates the cache on success). Because the mutated
|
||||
/// instance IS the cached instance, a failed save would otherwise leave the cache holding
|
||||
/// unsaved mutations for up to 5 minutes — invalidate before rethrowing.
|
||||
/// </summary>
|
||||
private async Task SaveInvalidatingOnFailureAsync(AutopilotMonitor.Shared.Models.TenantConfiguration config)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return new List<PlanTierDefinition>();
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<PlanTierDefinition>>(json,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new List<PlanTierDefinition>();
|
||||
await _configService.SaveConfigurationAsync(config);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<PlanTierDefinition>();
|
||||
_configService.InvalidateCache(config.TenantId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private class SetPlanTierRequest
|
||||
private static string FormatUtc(DateTime? value)
|
||||
=> value?.ToString("yyyy-MM-ddTHH:mm:ssZ") ?? "(none)";
|
||||
|
||||
private static async Task<HttpResponseData> BadRequestAsync(HttpRequestData req, string message)
|
||||
{
|
||||
public string PlanTier { get; set; } = string.Empty;
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { error = message });
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
private class SetPlanTierDefinitionsRequest
|
||||
|
||||
+32
@@ -175,6 +175,38 @@ namespace AutopilotMonitor.Functions.Functions.Config
|
||||
// MaxNdjsonPayloadSizeMB is table-only — always preserve existing value
|
||||
config.MaxNdjsonPayloadSizeMB = existingConfig.MaxNdjsonPayloadSizeMB;
|
||||
|
||||
// Plan/trial fields are mutable ONLY via the dedicated plan/trial endpoints
|
||||
// (PATCH config/{tenantId}/plan, POST config/{tenantId}/trial). The generic PUT
|
||||
// deserializes the full model, so without this preserve a round-tripped stale
|
||||
// view would silently reset the tenant's edition/trial — for ALL callers, GA included.
|
||||
config.PlanTier = existingConfig.PlanTier;
|
||||
config.TrialExpiresUtc = existingConfig.TrialExpiresUtc;
|
||||
config.TrialStartedUtc = existingConfig.TrialStartedUtc;
|
||||
config.TrialConsumed = existingConfig.TrialConsumed;
|
||||
config.TrialGrantedBy = existingConfig.TrialGrantedBy;
|
||||
|
||||
// Retention cap (edition entitlement): non-GA callers may only set 7..cap days.
|
||||
// Enforced only when the caller actually CHANGED the value, so a tenant whose
|
||||
// stored value predates the cap (e.g. 180 on Community) can still save unrelated
|
||||
// settings. 0 (= infinite) is a GA-only escape hatch. Edition resolves from the
|
||||
// STORED config — the client-sent plan fields were just discarded above.
|
||||
if (!requestCtx.IsGlobalAdmin && config.DataRetentionDays != existingConfig.DataRetentionDays)
|
||||
{
|
||||
var cap = FeatureEntitlementCatalog
|
||||
.Get(TenantEntitlementService.ResolveEdition(existingConfig, DateTime.UtcNow))
|
||||
.RetentionCapDays;
|
||||
if (config.DataRetentionDays < 7 || config.DataRetentionDays > cap)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new
|
||||
{
|
||||
success = false,
|
||||
message = $"Data retention must be between 7 and {cap} days for your plan. Upgrade to Enterprise for up to 365 days."
|
||||
});
|
||||
return badRequest;
|
||||
}
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
await _configService.SaveConfigurationAsync(config);
|
||||
|
||||
|
||||
@@ -94,10 +94,11 @@ public class AuthFunction
|
||||
// gate below). A genuine first-user still gets their config created by HandleNewTenantDomainAsync.
|
||||
var tenantConfigTask = _tenantConfigService.TryGetConfigurationAsync(tenantId);
|
||||
var globalRoleTask = _globalAdminService.GetGlobalRoleAsync(upn);
|
||||
var delegatedScopeTask = _delegatedAdminService.GetScopeAsync(upn);
|
||||
// tenantId = JWT tid = the caller's home tenant — gates the delegated (MSP) scope (Enterprise-only seat).
|
||||
var delegatedScopeTask = _delegatedAdminService.GetScopeAsync(upn, tenantId);
|
||||
var isApprovedTask = _previewWhitelistService.IsApprovedAsync(tenantId);
|
||||
var membershipTask = _tenantAdminsService.GetTableMembershipAsync(tenantId, upn);
|
||||
var mcpCheckTask = _mcpUserService.IsAllowedAsync(upn);
|
||||
var mcpCheckTask = _mcpUserService.IsAllowedAsync(upn, tenantId);
|
||||
var existingAdminsTask = _tenantAdminsService.GetTenantAdminsAsync(tenantId);
|
||||
|
||||
await Task.WhenAll(tenantConfigTask, globalRoleTask, delegatedScopeTask, isApprovedTask,
|
||||
|
||||
@@ -163,7 +163,8 @@ public class McpUserFunction
|
||||
var principal = context.GetUser();
|
||||
var upn = principal?.GetUserPrincipalName();
|
||||
|
||||
var result = await _mcpUserService.IsAllowedAsync(upn);
|
||||
// JWT tid = the caller's home tenant — gates the delegated (MSP) auto-grant (Enterprise-only seat).
|
||||
var result = await _mcpUserService.IsAllowedAsync(upn, principal?.GetTenantId());
|
||||
|
||||
var response = req.CreateResponse(result.IsAllowed ? HttpStatusCode.OK : HttpStatusCode.Forbidden);
|
||||
var payload = new Dictionary<string, object?>
|
||||
|
||||
+2
-2
@@ -100,8 +100,8 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
|
||||
{
|
||||
// realtime/groups/join is not a tenant-scoped route, so the middleware did not
|
||||
// resolve the delegated scope onto RequestContext — resolve it here, bounded to
|
||||
// the requested tenant only.
|
||||
var scope = await _delegatedAdminService.GetScopeAsync(userEmail);
|
||||
// the requested tenant only. Home tid gates the MSP seat (Enterprise-only).
|
||||
var scope = await _delegatedAdminService.GetScopeAsync(userEmail, userTenantId);
|
||||
allowedCrossTenant = scope.RoleFor(requestedTenantId) != null;
|
||||
if (allowedCrossTenant)
|
||||
_logger.LogInformation($"Delegated user {userEmail} joining managed cross-tenant group: {request.GroupName}");
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@ namespace AutopilotMonitor.Functions.Functions.Infrastructure
|
||||
var allowedCrossTenant = requestCtx.HasGlobalScope;
|
||||
if (!allowedCrossTenant && !string.IsNullOrEmpty(userEmail))
|
||||
{
|
||||
var scope = await _delegatedAdminService.GetScopeAsync(userEmail);
|
||||
var scope = await _delegatedAdminService.GetScopeAsync(userEmail, userTenantId);
|
||||
allowedCrossTenant = scope.RoleFor(requestedTenantId) != null;
|
||||
}
|
||||
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Maintenance;
|
||||
|
||||
/// <summary>
|
||||
/// Daily informational sweep over tenant Enterprise trials. NOT load-bearing — trial expiry
|
||||
/// degrades a tenant to Community at READ time (FeatureEntitlementCatalog.ResolveEdition);
|
||||
/// this timer only surfaces the transitions as ops events so the operator has visibility:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>TenantTrialExpired</c> (Warning) — the trial ended within the last sweep window
|
||||
/// (24h look-back matching the daily cadence).</item>
|
||||
/// <item><c>TenantTrialExpiring</c> (Info) — the trial ends within the next
|
||||
/// <see cref="ExpiringHeadsUpDays"/> days (re-emitted each daily run until expiry —
|
||||
/// acceptable for an Info-tier heads-up, no dedupe state to maintain).</item>
|
||||
/// </list>
|
||||
/// Tenants whose stored <c>PlanTier</c> is already the permanent "enterprise" are skipped —
|
||||
/// their trial timestamps are inert leftovers and expiry changes nothing.
|
||||
/// Failure never poisons the timer; the next run re-sweeps.
|
||||
/// </summary>
|
||||
public class TrialExpirySweepFunction
|
||||
{
|
||||
/// <summary>Heads-up window for TenantTrialExpiring.</summary>
|
||||
public static readonly TimeSpan ExpiringHeadsUp = TimeSpan.FromDays(3);
|
||||
private const int ExpiringHeadsUpDays = 3;
|
||||
|
||||
/// <summary>Look-back for TenantTrialExpired — matches the daily cadence so no expiry is skipped.</summary>
|
||||
public static readonly TimeSpan ExpiredLookBack = TimeSpan.FromHours(24);
|
||||
|
||||
/// <summary>03:30 UTC daily — off the busy maintenance window, after ScriptNameCacheCleanup (03:00).</summary>
|
||||
private const string Cron = "0 30 3 * * *";
|
||||
|
||||
private readonly IConfigRepository _configRepo;
|
||||
private readonly OpsEventService _opsEvents;
|
||||
private readonly ILogger<TrialExpirySweepFunction> _logger;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public TrialExpirySweepFunction(
|
||||
IConfigRepository configRepo,
|
||||
OpsEventService opsEvents,
|
||||
ILogger<TrialExpirySweepFunction> logger)
|
||||
: this(configRepo, opsEvents, logger, TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam — inject a fake <see cref="TimeProvider"/> for deterministic window math.</summary>
|
||||
internal TrialExpirySweepFunction(
|
||||
IConfigRepository configRepo,
|
||||
OpsEventService opsEvents,
|
||||
ILogger<TrialExpirySweepFunction> logger,
|
||||
TimeProvider time)
|
||||
{
|
||||
_configRepo = configRepo;
|
||||
_opsEvents = opsEvents;
|
||||
_logger = logger;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
[Function("TrialExpirySweep")]
|
||||
public async Task Run([TimerTrigger(Cron)] object timer, CancellationToken cancellationToken)
|
||||
{
|
||||
await RunCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Testable core — bypasses TimerInfo / FunctionContext.</summary>
|
||||
internal async Task<SweepResult> RunCoreAsync(CancellationToken ct)
|
||||
{
|
||||
var now = _time.GetUtcNow().UtcDateTime;
|
||||
var result = new SweepResult();
|
||||
|
||||
System.Collections.Generic.List<AutopilotMonitor.Shared.Models.TenantConfiguration> configs;
|
||||
try
|
||||
{
|
||||
configs = await _configRepo.GetAllTenantConfigurationsAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Informational sweep — never poison the timer; the next daily run re-sweeps.
|
||||
_logger.LogError(ex, "TrialExpirySweep: failed to load tenant configurations — skipping this run");
|
||||
return result;
|
||||
}
|
||||
|
||||
foreach (var config in configs)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
if (config.TrialExpiresUtc is not DateTime expiry)
|
||||
continue;
|
||||
|
||||
// Permanent Enterprise: the trial timestamps are inert leftovers — expiry changes nothing.
|
||||
if (string.Equals(config.PlanTier?.Trim(), FeatureEntitlementCatalog.EnterpriseTierName, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
result.TrialsSeen++;
|
||||
|
||||
if (expiry <= now)
|
||||
{
|
||||
if (expiry > now - ExpiredLookBack)
|
||||
{
|
||||
await _opsEvents.RecordTenantTrialExpiredAsync(config.TenantId, config.DomainName, expiry)
|
||||
.ConfigureAwait(false);
|
||||
result.ExpiredEmitted++;
|
||||
}
|
||||
// Older expiries were reported by a previous run — stay silent.
|
||||
}
|
||||
else if (expiry <= now + ExpiringHeadsUp)
|
||||
{
|
||||
var daysLeft = Math.Max(1, (int)Math.Ceiling((expiry - now).TotalDays));
|
||||
await _opsEvents.RecordTenantTrialExpiringAsync(config.TenantId, config.DomainName, expiry, daysLeft)
|
||||
.ConfigureAwait(false);
|
||||
result.ExpiringEmitted++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"TrialExpirySweep completed: trialsSeen={TrialsSeen} expiredEmitted={ExpiredEmitted} expiringEmitted={ExpiringEmitted}",
|
||||
result.TrialsSeen, result.ExpiredEmitted, result.ExpiringEmitted);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Summary counters returned from <see cref="RunCoreAsync"/> so tests can assert outcomes.</summary>
|
||||
internal sealed class SweepResult
|
||||
{
|
||||
public int TrialsSeen { get; set; }
|
||||
public int ExpiredEmitted { get; set; }
|
||||
public int ExpiringEmitted { get; set; }
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -19,15 +19,18 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
private readonly ILogger<McpUsageMetricsFunction> _logger;
|
||||
private readonly IUserUsageRepository _userUsageRepo;
|
||||
private readonly McpUserService _mcpUserService;
|
||||
private readonly McpQuotaService _quotaService;
|
||||
|
||||
public McpUsageMetricsFunction(
|
||||
ILogger<McpUsageMetricsFunction> logger,
|
||||
IUserUsageRepository userUsageRepo,
|
||||
McpUserService mcpUserService)
|
||||
McpUserService mcpUserService,
|
||||
McpQuotaService quotaService)
|
||||
{
|
||||
_logger = logger;
|
||||
_userUsageRepo = userUsageRepo;
|
||||
_mcpUserService = mcpUserService;
|
||||
_quotaService = quotaService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -57,12 +60,26 @@ namespace AutopilotMonitor.Functions.Functions.Metrics
|
||||
var records = await _userUsageRepo.GetUsageByUserAsync(userId, dateFrom, dateTo);
|
||||
var mcpUser = !string.IsNullOrEmpty(upn) ? await _mcpUserService.GetMcpUserAsync(upn) : null;
|
||||
|
||||
// Effective quota state: resolved plan (per-user override → tenant edition),
|
||||
// limits (SectionUsagePlans definition → catalog fallback) and current counters.
|
||||
var tenantId = principal?.GetTenantId();
|
||||
var quota = await _quotaService.CheckAsync(userId, upn, tenantId);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
userId,
|
||||
upn,
|
||||
usagePlan = mcpUser?.UsagePlan,
|
||||
effectivePlan = quota.Plan,
|
||||
quota = new
|
||||
{
|
||||
dailyLimit = quota.DailyLimit,
|
||||
monthlyLimit = quota.MonthlyLimit,
|
||||
dailyUsed = quota.DailyUsed,
|
||||
monthlyUsed = quota.MonthlyUsed,
|
||||
resetUtc = quota.ResetUtc
|
||||
},
|
||||
records
|
||||
});
|
||||
return response;
|
||||
|
||||
@@ -22,6 +22,8 @@ namespace AutopilotMonitor.Functions.Helpers
|
||||
("tenantId", c => c.TenantId),
|
||||
("domainName", c => c.DomainName),
|
||||
("planTier", c => c.PlanTier),
|
||||
("trialExpiresUtc", c => c.TrialExpiresUtc),
|
||||
("trialConsumed", c => c.TrialConsumed),
|
||||
("disabled", c => c.Disabled),
|
||||
("disabledReason", c => c.DisabledReason),
|
||||
("onboardedAt", c => c.OnboardedAt),
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Extensions;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Middleware;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// Enforces the per-user MCP daily/monthly request quota. 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.
|
||||
///
|
||||
/// Over-quota requests get 429 with a structured body, <c>Retry-After</c>, and
|
||||
/// <c>X-MCP-Quota-*</c> headers; allowed MCP requests get the quota headers too so the MCP
|
||||
/// client can surface remaining budget. Fail-open on storage/counter errors (handled inside
|
||||
/// <see cref="McpQuotaService"/>), fail-closed on plan resolution (unknown plan → Community).
|
||||
/// </summary>
|
||||
public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
{
|
||||
private readonly McpQuotaService _quotaService;
|
||||
private readonly ILogger<McpQuotaEnforcementMiddleware> _logger;
|
||||
|
||||
public McpQuotaEnforcementMiddleware(
|
||||
McpQuotaService quotaService,
|
||||
ILogger<McpQuotaEnforcementMiddleware> logger)
|
||||
{
|
||||
_quotaService = quotaService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
|
||||
{
|
||||
var httpContext = context.GetHttpContext();
|
||||
if (httpContext == null)
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var isMcpRequest = string.Equals(
|
||||
httpContext.Request.Headers["X-Client-Source"].FirstOrDefault(), "mcp", StringComparison.OrdinalIgnoreCase);
|
||||
if (!isMcpRequest)
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
var principal = context.GetUser();
|
||||
var oid = principal?.GetObjectId();
|
||||
if (string.IsNullOrEmpty(oid))
|
||||
{
|
||||
// Unauthenticated MCP probe — auth middleware / policy enforcement handle rejection.
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// GA exempt: platform operators are not customers of the quota.
|
||||
var requestContext = context.GetRequestContext();
|
||||
if (requestContext.IsGlobalAdmin)
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
McpQuotaDecision decision;
|
||||
try
|
||||
{
|
||||
var upn = principal!.GetUserPrincipalName();
|
||||
var tenantId = principal.GetTenantId();
|
||||
decision = await _quotaService.CheckAsync(oid, upn, tenantId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Belt-and-braces fail-open: the quota layer must never take MCP down.
|
||||
_logger.LogError(ex, "[McpQuota] Quota check threw for oid={Oid} — allowing request (fail-open)", oid);
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
StampQuotaHeaders(httpContext, decision);
|
||||
|
||||
if (decision.Allowed)
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
httpContext.Response.StatusCode = (int)HttpStatusCode.TooManyRequests;
|
||||
httpContext.Response.Headers["Retry-After"] = retryAfterSeconds.ToString();
|
||||
httpContext.Response.ContentType = "application/json";
|
||||
await httpContext.Response.WriteAsJsonAsync(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."
|
||||
});
|
||||
}
|
||||
|
||||
private static void StampQuotaHeaders(HttpContext httpContext, McpQuotaDecision decision)
|
||||
{
|
||||
// Direct-write pattern — same as UserRateLimitMiddleware's X-RateLimit-* headers.
|
||||
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();
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -149,7 +149,8 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
var isScopedRoute = catalogEntry.TenantScoping is TenantScoping.RouteParam or TenantScoping.QueryParam;
|
||||
if (!hasGlobalScope && !string.IsNullOrEmpty(upn) && isScopedRoute && crossTenant)
|
||||
{
|
||||
var scope = await _delegatedAdminService.GetScopeAsync(upn);
|
||||
// Home-tenant (JWT tid) gates the delegated scope: MSP seats require an Enterprise home tenant.
|
||||
var scope = await _delegatedAdminService.GetScopeAsync(upn, jwtTenantId);
|
||||
delegatedRole = scope.RoleFor(namedTarget);
|
||||
if (delegatedRole != null)
|
||||
allowedTenantIds = scope.TenantIds;
|
||||
@@ -314,7 +315,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
return await EvaluateGlobalReadOrAdminAsync(upn, userIdentifier);
|
||||
|
||||
case EndpointPolicy.GlobalReadOrDelegatedSubset:
|
||||
return await EvaluateGlobalReadOrDelegatedSubsetAsync(upn, userIdentifier);
|
||||
return await EvaluateGlobalReadOrDelegatedSubsetAsync(tenantId, upn, userIdentifier);
|
||||
|
||||
case EndpointPolicy.GlobalAdminOnly:
|
||||
return await EvaluateGlobalAdminOnlyAsync(upn, userIdentifier);
|
||||
@@ -513,7 +514,7 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
/// denied. Used by aggregate endpoints whose body can be filtered per tenant (e.g. config/all).
|
||||
/// </summary>
|
||||
private async Task<CatalogDecisionResult> EvaluateGlobalReadOrDelegatedSubsetAsync(
|
||||
string? upn, string userIdentifier)
|
||||
string? jwtTenantId, string? upn, string userIdentifier)
|
||||
{
|
||||
if (string.IsNullOrEmpty(upn))
|
||||
return CatalogDecisionResult.Deny(userIdentifier, "N/A", "MissingClaims");
|
||||
@@ -522,7 +523,8 @@ public class PolicyEnforcementMiddleware : IFunctionsWorkerMiddleware
|
||||
if (globalRole != null)
|
||||
return CatalogDecisionResult.Allow(userIdentifier, globalRole, "GlobalScope");
|
||||
|
||||
var scope = await _delegatedAdminService.GetScopeAsync(upn);
|
||||
// Home-tenant (JWT tid) gates the delegated scope: MSP seats require an Enterprise home tenant.
|
||||
var scope = await _delegatedAdminService.GetScopeAsync(upn, jwtTenantId);
|
||||
if (!scope.IsEmpty)
|
||||
return CatalogDecisionResult.Allow(
|
||||
userIdentifier, Constants.DelegatedRoles.DelegatedReader, "DelegatedSubset",
|
||||
|
||||
@@ -18,17 +18,20 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly TenantConfigurationService _tenantConfigService;
|
||||
private readonly TenantEntitlementService _entitlementService;
|
||||
private readonly ILogger<UserRateLimitMiddleware> _logger;
|
||||
|
||||
public UserRateLimitMiddleware(
|
||||
RateLimitService rateLimitService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
TenantConfigurationService tenantConfigService,
|
||||
TenantEntitlementService entitlementService,
|
||||
ILogger<UserRateLimitMiddleware> logger)
|
||||
{
|
||||
_rateLimitService = rateLimitService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_tenantConfigService = tenantConfigService;
|
||||
_entitlementService = entitlementService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -65,20 +68,33 @@ public class UserRateLimitMiddleware : IFunctionsWorkerMiddleware
|
||||
// default row. A valid JWT from an unregistered tenant hitting a read/self-service endpoint must
|
||||
// never materialize a tenant config row. No row → inherit global.
|
||||
int? tenantOverride = null;
|
||||
int? entitlementFloor = null;
|
||||
if (!requestContext.HasGlobalScope && !string.IsNullOrEmpty(requestContext.TargetTenantId))
|
||||
{
|
||||
var tenantConfig = await _tenantConfigService.GetConfigurationIfExistsAsync(requestContext.TargetTenantId);
|
||||
tenantOverride = tenantConfig?.CustomUserRateLimitRequestsPerMinute;
|
||||
}
|
||||
|
||||
// Edition entitlement floor (Enterprise: 150/min): follows the caller's HOME tenant
|
||||
// (JWT tid) — an MSP/delegated user rides on the edition of the tenant that pays for
|
||||
// their seat, not the tenant they happen to be viewing. Resolution is fail-closed
|
||||
// (any error → Community → null floor → admin default applies) and served from the
|
||||
// same 5-minute config cache. Global-scope callers have their own budgets — no floor.
|
||||
if (!requestContext.HasGlobalScope && !string.IsNullOrEmpty(requestContext.TenantId))
|
||||
{
|
||||
var entitlements = await _entitlementService.GetEntitlementsAsync(requestContext.TenantId);
|
||||
entitlementFloor = entitlements.UserRateLimitPerMinute;
|
||||
}
|
||||
|
||||
// Base limit: Global Admins get the GA budget; everyone else (standard users AND read-only
|
||||
// Global Readers) gets the standard user default (with the tenant override applied above only
|
||||
// for tenant-scoped callers).
|
||||
// for tenant-scoped callers, raised to the edition floor when no override is set).
|
||||
var limit = RateLimitResolver.ResolveUserLimit(
|
||||
requestContext.IsGlobalAdmin,
|
||||
tenantOverride,
|
||||
config.UserRateLimitRequestsPerMinute,
|
||||
config.GlobalAdminRateLimitRequestsPerMinute);
|
||||
config.GlobalAdminRateLimitRequestsPerMinute,
|
||||
entitlementFloor);
|
||||
|
||||
var key = $"user_ratelimit_{requestContext.UserPrincipalName.ToLowerInvariant()}";
|
||||
result = _rateLimitService.CheckRateLimit(key, limit);
|
||||
|
||||
@@ -41,6 +41,10 @@ builder.UseMiddleware<GlobalExceptionMiddleware>();
|
||||
builder.UseMiddleware<AuthenticationMiddleware>();
|
||||
builder.UseMiddleware<PolicyEnforcementMiddleware>();
|
||||
builder.UseMiddleware<UserRateLimitMiddleware>();
|
||||
// MCP quota (daily/monthly budget on top of the per-minute rate limit): only requests marked
|
||||
// X-Client-Source: mcp with an authenticated principal are checked; GA exempt; fail-open on
|
||||
// counter errors, fail-closed on plan resolution. See McpQuotaEnforcementMiddleware.
|
||||
builder.UseMiddleware<McpQuotaEnforcementMiddleware>();
|
||||
// After policy enforcement (RequestContext resolved): record best-effort "last seen" presence for
|
||||
// authenticated web users so a Global Admin can see who is active right now. Throttled + fail-open.
|
||||
builder.UseMiddleware<UserPresenceMiddleware>();
|
||||
@@ -214,6 +218,8 @@ builder.Services.AddHostedService<TableInitializerService>(); // Initialize all
|
||||
builder.Services.AddTableStorageDataAccess();
|
||||
builder.Services.AddSingleton<TenantConfigurationService>();
|
||||
builder.Services.AddSingleton<AdminConfigurationService>();
|
||||
builder.Services.AddSingleton<TenantEntitlementService>();
|
||||
builder.Services.AddSingleton<McpQuotaService>();
|
||||
builder.Services.AddSingleton<ILatestVersionsService, LatestVersionsService>();
|
||||
builder.Services.AddSingleton<RateLimitService>();
|
||||
builder.Services.AddSingleton<DistressRateLimitService>();
|
||||
|
||||
@@ -463,6 +463,9 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("POST", "global/tenant-groups/{groupId}/assignees", EndpointPolicy.GlobalAdminOnly),
|
||||
new("DELETE", "global/tenant-groups/{groupId}/assignees/{upn}", EndpointPolicy.GlobalAdminOnly),
|
||||
new("PATCH", "config/{tenantId}/plan", EndpointPolicy.GlobalAdminOnly, TenantScoping.RouteParam),
|
||||
// Self-service Enterprise trial — a tenant admin may start their own tenant's one-time
|
||||
// trial; the handler enforces the once-per-tenant + already-Enterprise conflicts (409).
|
||||
new("POST", "config/{tenantId}/trial", EndpointPolicy.TenantAdminOrGA, TenantScoping.RouteParam),
|
||||
new("GET", "global/config/plan-tiers", EndpointPolicy.GlobalReadOrAdmin),
|
||||
new("PUT", "global/config/plan-tiers", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "feedback/all", EndpointPolicy.GlobalReadOrAdmin),
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// The two effective tenant editions. Anything that is not provably Enterprise is Community
|
||||
/// (fail-closed) — including the legacy stored tiers "free" and "pro" and unknown values.
|
||||
/// </summary>
|
||||
public enum TenantEdition
|
||||
{
|
||||
Community,
|
||||
Enterprise
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The static per-edition entitlement values. Immutable code catalog — analogous to
|
||||
/// <see cref="EndpointAccessPolicyCatalog"/>: entitlements are defined here, not in storage,
|
||||
/// so a storage failure can never widen access. MCP daily/monthly limits are FALLBACKS only;
|
||||
/// admin-edited SectionUsagePlans rows (AdminConfiguration.PlanTierDefinitionsJson) take
|
||||
/// precedence when a matching plan name exists.
|
||||
/// </summary>
|
||||
public sealed class EditionEntitlements
|
||||
{
|
||||
public TenantEdition Edition { get; init; }
|
||||
|
||||
/// <summary>Maximum data retention a tenant admin may configure (days).</summary>
|
||||
public int RetentionCapDays { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entitlement floor for the per-user (portal/JWT) rate limit, requests/minute.
|
||||
/// Null = no floor (the AdminConfiguration default applies unchanged). When set, the
|
||||
/// effective limit is raised to at least this value — it never lowers an admin-raised
|
||||
/// default, and an explicit per-tenant override set by a Global Admin still wins.
|
||||
/// </summary>
|
||||
public int? UserRateLimitPerMinute { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Entitlement floor for the per-device (agent/cert) rate limit, requests/minute.
|
||||
/// Same semantics as <see cref="UserRateLimitPerMinute"/>.
|
||||
/// </summary>
|
||||
public int? DeviceRateLimitPerMinute { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether users HOMED in this tenant may hold delegated ("MSP") admin scopes over other
|
||||
/// tenants. The gate applies to the delegated admin's home tenant (JWT tid) — the managed
|
||||
/// TARGET tenants may be any edition (an Enterprise MSP may manage Community customers).
|
||||
/// Enforced at resolve time in DelegatedAdminService.GetScopeAsync.
|
||||
/// </summary>
|
||||
public bool DelegatedAdminAllowed { get; init; }
|
||||
|
||||
/// <summary>Default MCP usage plan name for users of this tenant (per-user override wins).</summary>
|
||||
public string McpUsagePlanName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Fallback MCP daily request limit when no SectionUsagePlans row matches the plan name.</summary>
|
||||
public int McpDailyRequestLimit { get; init; }
|
||||
|
||||
/// <summary>Fallback MCP monthly request limit when no SectionUsagePlans row matches the plan name.</summary>
|
||||
public int McpMonthlyRequestLimit { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for edition resolution and the per-edition entitlement matrix.
|
||||
///
|
||||
/// Resolution is computed at READ time: Enterprise ⇔ PlanTier == "enterprise" OR an active
|
||||
/// trial (TrialExpiresUtc strictly in the future). Trial expiry therefore degrades the tenant
|
||||
/// automatically without any timer or sweep. Everything else — null, empty, "free", "pro",
|
||||
/// unknown — is Community (fail-closed, no data migration required).
|
||||
/// </summary>
|
||||
public static class FeatureEntitlementCatalog
|
||||
{
|
||||
/// <summary>Write-side canonical tier names (the only values the plan endpoint accepts).</summary>
|
||||
public const string CommunityTierName = "community";
|
||||
public const string EnterpriseTierName = "enterprise";
|
||||
|
||||
private static readonly EditionEntitlements Community = new()
|
||||
{
|
||||
Edition = TenantEdition.Community,
|
||||
RetentionCapDays = 90,
|
||||
UserRateLimitPerMinute = null, // AdminConfiguration default (120) applies
|
||||
DeviceRateLimitPerMinute = null, // AdminConfiguration default (100) applies
|
||||
DelegatedAdminAllowed = false,
|
||||
McpUsagePlanName = CommunityTierName,
|
||||
McpDailyRequestLimit = 100,
|
||||
McpMonthlyRequestLimit = 3000
|
||||
};
|
||||
|
||||
private static readonly EditionEntitlements Enterprise = new()
|
||||
{
|
||||
Edition = TenantEdition.Enterprise,
|
||||
RetentionCapDays = 365,
|
||||
UserRateLimitPerMinute = 150,
|
||||
DeviceRateLimitPerMinute = 150,
|
||||
DelegatedAdminAllowed = true,
|
||||
McpUsagePlanName = EnterpriseTierName,
|
||||
McpDailyRequestLimit = 1000,
|
||||
McpMonthlyRequestLimit = 20000
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective edition from the stored plan tier and trial expiry.
|
||||
/// Enterprise ⇔ exact tier "enterprise" (case-insensitive) OR trialExpiresUtc > nowUtc.
|
||||
/// A trial expiring exactly at <paramref name="nowUtc"/> is already over (strict >).
|
||||
/// </summary>
|
||||
public static TenantEdition ResolveEdition(string? planTier, DateTime? trialExpiresUtc, DateTime nowUtc)
|
||||
{
|
||||
if (string.Equals(planTier?.Trim(), EnterpriseTierName, StringComparison.OrdinalIgnoreCase))
|
||||
return TenantEdition.Enterprise;
|
||||
|
||||
if (trialExpiresUtc.HasValue && trialExpiresUtc.Value > nowUtc)
|
||||
return TenantEdition.Enterprise;
|
||||
|
||||
return TenantEdition.Community;
|
||||
}
|
||||
|
||||
/// <summary>Returns the entitlement set for an edition. Unknown enum values → Community (fail-closed).</summary>
|
||||
public static EditionEntitlements Get(TenantEdition edition) =>
|
||||
edition == TenantEdition.Enterprise ? Enterprise : Community;
|
||||
}
|
||||
}
|
||||
@@ -72,17 +72,24 @@ namespace AutopilotMonitor.Functions.Security
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective device (agent/cert) rate limit for a tenant:
|
||||
/// the per-tenant override if set, otherwise the global default. The global
|
||||
/// AdminConfiguration read is served from a 5-minute in-memory cache.
|
||||
/// the per-tenant override if set, otherwise the global default raised to the tenant
|
||||
/// edition's entitlement floor (Enterprise: 150/min — see FeatureEntitlementCatalog).
|
||||
/// The global AdminConfiguration read is served from a 5-minute in-memory cache.
|
||||
/// </summary>
|
||||
private async Task<int> ResolveDeviceRateLimitAsync(TenantConfiguration config)
|
||||
{
|
||||
// The global read is served from a 5-minute in-memory cache (O(1) dictionary hit on the
|
||||
// hot path), so we always take it rather than branching on the override.
|
||||
var adminConfig = await _adminConfigService.GetConfigurationAsync();
|
||||
// Edition resolves purely from the config already in hand — no extra I/O on the
|
||||
// agent hot path. Fail-closed by construction (unknown tier → Community → null floor).
|
||||
var entitlementFloor = FeatureEntitlementCatalog
|
||||
.Get(Services.TenantEntitlementService.ResolveEdition(config, DateTime.UtcNow))
|
||||
.DeviceRateLimitPerMinute;
|
||||
return RateLimitResolver.ResolveDeviceLimit(
|
||||
config.CustomRateLimitRequestsPerMinute,
|
||||
adminConfig.GlobalRateLimitRequestsPerMinute);
|
||||
adminConfig.GlobalRateLimitRequestsPerMinute,
|
||||
entitlementFloor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -12,13 +12,16 @@ namespace AutopilotMonitor.Functions.Services;
|
||||
/// SUBSET of tenants: exactly the tenants for which it holds an Active, enabled DelegatedAdmins row.
|
||||
/// Externally this is surfaced as "MSP mode".
|
||||
///
|
||||
/// The scope is keyed on UPN and is <b>tid-agnostic</b> — identical to <see cref="GlobalAdminService"/>:
|
||||
/// the caller signs into their own home tenant, and their cross-tenant reach is resolved from this table
|
||||
/// regardless of the JWT's tid. Resolution is cached for 5 minutes; every mutation invalidates the cache.
|
||||
/// The scope is keyed on UPN; assignment RESOLUTION is tid-agnostic (identical to <see cref="GlobalAdminService"/>),
|
||||
/// but the effective scope is GATED on the caller's home tenant (JWT tid) being Enterprise — the MSP
|
||||
/// seat is an Enterprise capability of the tenant the admin is homed in, while the managed targets may
|
||||
/// be any edition. Resolution is cached briefly (UPN-keyed, gate applied per call); every mutation
|
||||
/// invalidates the cache.
|
||||
/// </summary>
|
||||
public class DelegatedAdminService
|
||||
{
|
||||
private readonly IAdminRepository _adminRepo;
|
||||
private readonly TenantEntitlementService _entitlementService;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<DelegatedAdminService> _logger;
|
||||
// Per-process cache: on scaled-out Flex Consumption, the scope invalidation in UpsertAsync/revoke
|
||||
@@ -30,10 +33,12 @@ public class DelegatedAdminService
|
||||
|
||||
public DelegatedAdminService(
|
||||
IAdminRepository adminRepo,
|
||||
TenantEntitlementService entitlementService,
|
||||
IMemoryCache cache,
|
||||
ILogger<DelegatedAdminService> logger)
|
||||
{
|
||||
_adminRepo = adminRepo;
|
||||
_entitlementService = entitlementService;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
@@ -43,8 +48,18 @@ public class DelegatedAdminService
|
||||
/// tenant. Only <see cref="Constants.DelegatedStatus.Active"/> + enabled rows with a recognized role
|
||||
/// contribute; pending/revoked/disabled/unknown-role rows are ignored (fail-closed). Cached briefly (see _cacheDuration).
|
||||
/// Returns an empty (never null) scope for a UPN with no effective assignments.
|
||||
/// <para>
|
||||
/// <b>Entitlement gate (single choke-point for ALL delegated-scope consumers):</b> delegated ("MSP")
|
||||
/// access is an Enterprise capability of the caller's HOME tenant — the tenant that pays for the MSP
|
||||
/// seat (<paramref name="callerHomeTenantId"/> = the validated JWT tid, unforgeable). The MANAGED
|
||||
/// (target) tenants may be any edition — an MSP on Enterprise may manage Community customers.
|
||||
/// Null/empty home tid or a non-Enterprise home tenant ⇒ empty scope (fail-closed). Grant rows stay
|
||||
/// untouched; they become inert and resurrect when the home tenant upgrades / trials.
|
||||
/// The gate sits AFTER the cache read so the cached scope stays pure (keyed on UPN only) and a
|
||||
/// home-tenant edition change converges within the entitlement service's own config-cache TTL.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public virtual async Task<DelegatedScope> GetScopeAsync(string? upn)
|
||||
public virtual async Task<DelegatedScope> GetScopeAsync(string? upn, string? callerHomeTenantId = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(upn))
|
||||
return DelegatedScope.Empty;
|
||||
@@ -52,7 +67,7 @@ public class DelegatedAdminService
|
||||
upn = upn.ToLowerInvariant();
|
||||
var cacheKey = $"delegated-scope:{upn}";
|
||||
if (_cache.TryGetValue<DelegatedScope>(cacheKey, out var cached) && cached != null)
|
||||
return cached;
|
||||
return await ApplyHomeTenantGateAsync(cached, upn, callerHomeTenantId);
|
||||
|
||||
var rows = await _adminRepo.GetDelegatedTenantsAsync(upn);
|
||||
var tenantRoles = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -98,9 +113,34 @@ public class DelegatedAdminService
|
||||
}
|
||||
}
|
||||
|
||||
// Entitlement gate (single choke-point for ALL delegated-scope consumers — policy rescue,
|
||||
// AllowedTenantIds, delegated config/all, MCP auto-grant): delegated ("MSP") access is an
|
||||
// Enterprise capability of the caller's HOME tenant (the tenant paying for the MSP seat) —
|
||||
// NOT of the managed targets. An Enterprise MSP may manage Community customers. Resolution
|
||||
// failure / unknown home tenant counts as Community (fail-closed inside the entitlement
|
||||
// service), which empties the scope. Grant rows stay untouched — inert until upgrade/trial.
|
||||
var scope = new DelegatedScope(tenantRoles);
|
||||
_cache.Set(cacheKey, scope, _cacheDuration);
|
||||
return scope;
|
||||
return await ApplyHomeTenantGateAsync(scope, upn, callerHomeTenantId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Empties a non-empty scope when the caller's home tenant is not Enterprise. Applied after
|
||||
/// every cache read/write so the cached scope stays pure (UPN-keyed) — the edition lookup has
|
||||
/// its own 5-minute config cache, so this adds no per-request storage I/O in the steady state.
|
||||
/// </summary>
|
||||
private async Task<DelegatedScope> ApplyHomeTenantGateAsync(DelegatedScope scope, string upn, string? callerHomeTenantId)
|
||||
{
|
||||
if (scope.IsEmpty)
|
||||
return scope;
|
||||
|
||||
if (await _entitlementService.GetEditionAsync(callerHomeTenantId) == Security.TenantEdition.Enterprise)
|
||||
return scope;
|
||||
|
||||
_logger.LogInformation(
|
||||
"[DelegatedAdmin] Suppressing delegated scope for {Upn} — home tenant {HomeTenantId} is not Enterprise (MSP seat requires Enterprise)",
|
||||
upn, string.IsNullOrWhiteSpace(callerHomeTenantId) ? "(unknown)" : callerHomeTenantId);
|
||||
return DelegatedScope.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Creates or replaces an assignment, then invalidates the UPN's cached scope.</summary>
|
||||
|
||||
+16
-1
@@ -136,7 +136,22 @@ namespace AutopilotMonitor.Functions.Services.Deletion
|
||||
private async Task RunForTenantAsync(string tenantId, FanoutResult result, CancellationToken cancellationToken)
|
||||
{
|
||||
var config = await _tenantConfig.GetConfigurationAsync(tenantId).ConfigureAwait(false);
|
||||
var retentionDays = config?.DataRetentionDays ?? 90;
|
||||
// Edition retention cap (fail-closed backstop): the stored value is clamped to the
|
||||
// effective edition's cap at read time (Community 90 / Enterprise 365). A tenant whose
|
||||
// trial expired, or whose stored value predates the cap, is enforced here even though
|
||||
// the stored DataRetentionDays is left untouched. days <= 0 stays the GA-only
|
||||
// "infinite" escape hatch (skipped below, never clamped).
|
||||
var storedDays = config?.DataRetentionDays ?? 90;
|
||||
var retentionDays = config == null
|
||||
? 90
|
||||
: TenantEntitlementService.GetEffectiveRetentionDays(config, DateTime.UtcNow);
|
||||
if (retentionDays > 0 && retentionDays < storedDays)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Tenant {TenantId}: DataRetentionDays={Stored} exceeds the {Edition}-edition cap — enforcing {Effective} days",
|
||||
tenantId, storedDays,
|
||||
TenantEntitlementService.ResolveEdition(config!, DateTime.UtcNow), retentionDays);
|
||||
}
|
||||
|
||||
if (retentionDays <= 0)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models.Config;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves and enforces the per-user MCP request quota (daily + monthly).
|
||||
///
|
||||
/// Plan precedence: explicit per-user override (McpUsers.UsagePlan) → 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).
|
||||
///
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class McpQuotaService
|
||||
{
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(60);
|
||||
|
||||
private readonly IUserUsageRepository _usageRepo;
|
||||
private readonly McpUserService _mcpUserService;
|
||||
private readonly AdminConfigurationService _adminConfigService;
|
||||
private readonly TenantEntitlementService _entitlementService;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<McpQuotaService> _logger;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public McpQuotaService(
|
||||
IUserUsageRepository usageRepo,
|
||||
McpUserService mcpUserService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
TenantEntitlementService entitlementService,
|
||||
IMemoryCache cache,
|
||||
ILogger<McpQuotaService> logger)
|
||||
: this(usageRepo, mcpUserService, adminConfigService, entitlementService, cache, logger, TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam — inject a fake <see cref="TimeProvider"/> for deterministic window math.</summary>
|
||||
public McpQuotaService(
|
||||
IUserUsageRepository usageRepo,
|
||||
McpUserService mcpUserService,
|
||||
AdminConfigurationService adminConfigService,
|
||||
TenantEntitlementService entitlementService,
|
||||
IMemoryCache cache,
|
||||
ILogger<McpQuotaService> logger,
|
||||
TimeProvider time)
|
||||
{
|
||||
_usageRepo = usageRepo;
|
||||
_mcpUserService = mcpUserService;
|
||||
_adminConfigService = adminConfigService;
|
||||
_entitlementService = entitlementService;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
/// <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).
|
||||
/// </summary>
|
||||
public virtual async Task<McpQuotaDecision> CheckAsync(string oid, string? upn, string? tenantId)
|
||||
{
|
||||
var cacheKey = $"mcp-quota:{oid}";
|
||||
if (_cache.TryGetValue<McpQuotaDecision>(cacheKey, out var cached) && cached != null)
|
||||
return cached;
|
||||
|
||||
var (planName, dailyLimit, monthlyLimit) = await ResolvePlanAsync(upn, tenantId);
|
||||
|
||||
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
||||
long dailyUsed = 0, monthlyUsed = 0;
|
||||
try
|
||||
{
|
||||
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var today = nowUtc.ToString("yyyyMMdd");
|
||||
var records = await _usageRepo.GetUsageByUserAsync(
|
||||
oid, monthStart.ToString("yyyyMMdd"), today);
|
||||
|
||||
monthlyUsed = records.Sum(r => r.RequestCount);
|
||||
dailyUsed = records.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);
|
||||
}
|
||||
|
||||
var decision = BuildDecision(planName, dailyLimit, monthlyLimit, dailyUsed, monthlyUsed, nowUtc);
|
||||
_cache.Set(cacheKey, decision, CacheDuration);
|
||||
return decision;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plan resolution only (no counter read) — used by the self-service usage endpoint.
|
||||
/// </summary>
|
||||
public virtual async Task<(string PlanName, int DailyLimit, int MonthlyLimit)> ResolvePlanAsync(string? upn, string? tenantId)
|
||||
{
|
||||
// 1. Per-user override wins when set.
|
||||
string? overridePlan = null;
|
||||
if (!string.IsNullOrWhiteSpace(upn))
|
||||
{
|
||||
try
|
||||
{
|
||||
var mcpUser = await _mcpUserService.GetMcpUserAsync(upn.ToLowerInvariant());
|
||||
overridePlan = string.IsNullOrWhiteSpace(mcpUser?.UsagePlan) ? null : mcpUser!.UsagePlan!.Trim().ToLowerInvariant();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[McpQuota] McpUser lookup failed for {Upn} — falling back to tenant edition plan", upn);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Tenant edition default (fail-closed → Community inside the entitlement service).
|
||||
var entitlements = await _entitlementService.GetEntitlementsAsync(tenantId);
|
||||
var planName = overridePlan ?? entitlements.McpUsagePlanName;
|
||||
|
||||
// 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).
|
||||
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);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[McpQuota] Plan definitions unavailable — using catalog fallback for plan {Plan}", planName);
|
||||
}
|
||||
|
||||
var fallback = string.Equals(planName, FeatureEntitlementCatalog.EnterpriseTierName, StringComparison.OrdinalIgnoreCase)
|
||||
? FeatureEntitlementCatalog.Get(TenantEdition.Enterprise)
|
||||
: FeatureEntitlementCatalog.Get(TenantEdition.Community);
|
||||
return (planName, fallback.McpDailyRequestLimit, fallback.McpMonthlyRequestLimit);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
string? exceededScope = monthlyExceeded ? "monthly" : dailyExceeded ? "daily" : null;
|
||||
var resetUtc = exceededScope == "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,
|
||||
DailyUsed = dailyUsed,
|
||||
MonthlyUsed = monthlyUsed,
|
||||
ResetUtc = resetUtc
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Outcome of an MCP quota check.</summary>
|
||||
public sealed class McpQuotaDecision
|
||||
{
|
||||
public bool Allowed { get; init; }
|
||||
public string Plan { get; init; } = string.Empty;
|
||||
/// <summary>Which window was exceeded ("daily"/"monthly"), null when allowed.</summary>
|
||||
public string? Scope { get; init; }
|
||||
public int DailyLimit { get; init; }
|
||||
public int MonthlyLimit { get; init; }
|
||||
public long DailyUsed { get; init; }
|
||||
public long MonthlyUsed { 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()
|
||||
{
|
||||
Allowed = true,
|
||||
Plan = plan,
|
||||
DailyLimit = dailyLimit,
|
||||
MonthlyLimit = monthlyLimit,
|
||||
DailyUsed = -1,
|
||||
MonthlyUsed = -1,
|
||||
ResetUtc = DateTime.MinValue
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -49,8 +49,11 @@ public class McpUserService
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a user is allowed to access the MCP server based on current policy.
|
||||
/// <paramref name="homeTenantId"/> is the caller's JWT tid (home tenant) — it gates the
|
||||
/// delegated (MSP) auto-grant path, which requires an Enterprise home tenant. Null (unknown)
|
||||
/// fails closed to an empty delegated scope; the other grant paths are unaffected.
|
||||
/// </summary>
|
||||
public virtual async Task<McpAccessCheckResult> IsAllowedAsync(string? upn)
|
||||
public virtual async Task<McpAccessCheckResult> IsAllowedAsync(string? upn, string? homeTenantId = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(upn))
|
||||
return McpAccessCheckResult.Denied("Missing UPN");
|
||||
@@ -70,7 +73,7 @@ public class McpUserService
|
||||
// a delegated assignment; both are reported. The MCP server uses delegatedTenantIds to route a
|
||||
// delegated caller to /api/global/*?tenantId=<managed> (the path the auth middleware authorizes)
|
||||
// and to reject any tool call that does not name one of the managed tenants.
|
||||
var delegatedScope = await _delegatedAdminService.GetScopeAsync(upn);
|
||||
var delegatedScope = await _delegatedAdminService.GetScopeAsync(upn, homeTenantId);
|
||||
var delegatedTenantIds = delegatedScope.IsEmpty ? null : delegatedScope.TenantIds;
|
||||
var delegatedRole = delegatedScope.IsEmpty ? null : StrongestDelegatedRole(delegatedScope);
|
||||
|
||||
|
||||
@@ -430,6 +430,32 @@ namespace AutopilotMonitor.Functions.Services
|
||||
new { domainName, failedPhase, errorMessage, retryCount });
|
||||
}
|
||||
|
||||
// ── Tenant trial lifecycle (informational — enforcement is read-time) ──
|
||||
// Both types are dual-registered in OpsAlertRulesSection.tsx OPS_EVENT_TYPES
|
||||
// (memory feedback_ops_event_types_dual_register). Dispatched by TrialExpirySweepFunction.
|
||||
|
||||
/// <summary>Heads-up: an Enterprise trial ends within the next few days. Info-tier visibility signal.</summary>
|
||||
public Task RecordTenantTrialExpiringAsync(string tenantId, string? domainName, DateTime trialExpiresUtc, int daysLeft)
|
||||
{
|
||||
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
|
||||
return WriteAsync(OpsEventCategory.Tenant, "TenantTrialExpiring", OpsEventSeverity.Info,
|
||||
$"Enterprise trial for tenant {tenantLabel} expires in {daysLeft} day(s) ({trialExpiresUtc:yyyy-MM-dd HH:mm}Z)",
|
||||
tenantId, "System.TrialSweep", new { domainName, trialExpiresUtc, daysLeft });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An Enterprise trial expired within the last sweep window — the tenant silently degraded
|
||||
/// to Community at read time (retention cap, rate limits, MSP delegation, MCP plan).
|
||||
/// Warning-tier: a conversion moment an operator likely wants a Telegram ping for.
|
||||
/// </summary>
|
||||
public Task RecordTenantTrialExpiredAsync(string tenantId, string? domainName, DateTime trialExpiredUtc)
|
||||
{
|
||||
var tenantLabel = string.IsNullOrWhiteSpace(domainName) ? tenantId : $"{domainName} ({tenantId})";
|
||||
return WriteAsync(OpsEventCategory.Tenant, "TenantTrialExpired", OpsEventSeverity.Warning,
|
||||
$"Enterprise trial for tenant {tenantLabel} expired ({trialExpiredUtc:yyyy-MM-dd HH:mm}Z) — tenant is now Community",
|
||||
tenantId, "System.TrialSweep", new { domainName, trialExpiredUtc });
|
||||
}
|
||||
|
||||
// ── Agent ──────────────────────────────────────────────────────────────
|
||||
|
||||
public Task RecordSessionTimeoutsAsync(string tenantId, int sessionCount, int timeoutHours)
|
||||
|
||||
@@ -6,23 +6,32 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// the effective value is computed at read time — there is no denormalized per-tenant mirror and
|
||||
/// no background sync job. Both the device path (<see cref="SecurityValidator"/>) and the user path
|
||||
/// (<c>UserRateLimitMiddleware</c>) resolve through here so the precedence stays consistent.
|
||||
///
|
||||
/// Edition entitlement floor (<c>FeatureEntitlementCatalog</c>): Enterprise raises the DEFAULT to
|
||||
/// at least the floor — it never lowers an admin-raised global default, and an explicit per-tenant
|
||||
/// override still wins outright (a Global Admin must stay able to throttle a specific tenant
|
||||
/// below the floor). Precedence: override ?? max(globalDefault, entitlementFloor).
|
||||
/// </summary>
|
||||
public static class RateLimitResolver
|
||||
{
|
||||
/// <summary>
|
||||
/// Device (agent/cert) limit: per-tenant override if set, otherwise the global default.
|
||||
/// Device (agent/cert) limit: per-tenant override if set, otherwise the global default
|
||||
/// raised to the edition's entitlement floor (null floor = Community, default unchanged).
|
||||
/// </summary>
|
||||
public static int ResolveDeviceLimit(int? tenantOverride, int globalDefault)
|
||||
=> tenantOverride ?? globalDefault;
|
||||
public static int ResolveDeviceLimit(int? tenantOverride, int globalDefault, int? entitlementFloor = null)
|
||||
=> tenantOverride ?? ApplyFloor(globalDefault, entitlementFloor);
|
||||
|
||||
/// <summary>
|
||||
/// User (portal/JWT) limit. Global Admins are limited by the global GA budget (cross-tenant;
|
||||
/// no per-tenant override applies). Standard users get the per-tenant override if set,
|
||||
/// otherwise the global user default.
|
||||
/// no per-tenant override and no entitlement floor applies). Standard users get the per-tenant
|
||||
/// override if set, otherwise the global user default raised to the edition's entitlement floor.
|
||||
/// </summary>
|
||||
public static int ResolveUserLimit(bool isGlobalAdmin, int? tenantUserOverride, int globalUserDefault, int globalAdminDefault)
|
||||
public static int ResolveUserLimit(bool isGlobalAdmin, int? tenantUserOverride, int globalUserDefault, int globalAdminDefault, int? entitlementFloor = null)
|
||||
=> isGlobalAdmin
|
||||
? globalAdminDefault
|
||||
: (tenantUserOverride ?? globalUserDefault);
|
||||
: (tenantUserOverride ?? ApplyFloor(globalUserDefault, entitlementFloor));
|
||||
|
||||
private static int ApplyFloor(int globalDefault, int? entitlementFloor)
|
||||
=> entitlementFloor is int floor && floor > globalDefault ? floor : globalDefault;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AutopilotMonitor.Functions.Security;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves a tenant's effective edition (Community/Enterprise) and its entitlements at read
|
||||
/// time. Rides on <see cref="TenantConfigurationService"/>'s 5-minute config cache — no cache
|
||||
/// of its own, so plan/trial mutations become visible within the existing staleness budget.
|
||||
///
|
||||
/// Fail-closed: any storage/resolution failure yields Community. A broken entitlement lookup
|
||||
/// must never grant Enterprise capabilities.
|
||||
/// </summary>
|
||||
public class TenantEntitlementService
|
||||
{
|
||||
private readonly TenantConfigurationService _configService;
|
||||
private readonly ILogger<TenantEntitlementService> _logger;
|
||||
private readonly TimeProvider _time;
|
||||
|
||||
public TenantEntitlementService(
|
||||
TenantConfigurationService configService,
|
||||
ILogger<TenantEntitlementService> logger)
|
||||
: this(configService, logger, TimeProvider.System)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam — inject a fake <see cref="TimeProvider"/> for deterministic trial math.</summary>
|
||||
public TenantEntitlementService(
|
||||
TenantConfigurationService configService,
|
||||
ILogger<TenantEntitlementService> logger,
|
||||
TimeProvider time)
|
||||
{
|
||||
_configService = configService;
|
||||
_logger = logger;
|
||||
_time = time;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the tenant's effective edition. Uses the strict point-read
|
||||
/// (<see cref="TenantConfigurationService.GetConfigurationIfExistsAsync"/>) so an
|
||||
/// entitlement check can never materialize a tenant-config row. No row / any error →
|
||||
/// Community (fail-closed).
|
||||
/// </summary>
|
||||
public virtual async Task<TenantEdition> GetEditionAsync(string? tenantId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tenantId))
|
||||
return TenantEdition.Community;
|
||||
|
||||
try
|
||||
{
|
||||
var config = await _configService.GetConfigurationIfExistsAsync(tenantId);
|
||||
if (config == null)
|
||||
return TenantEdition.Community;
|
||||
|
||||
return ResolveEdition(config, _time.GetUtcNow().UtcDateTime);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"[Entitlement] Edition resolution failed for tenant {TenantId} — treating as Community (fail-closed)",
|
||||
tenantId);
|
||||
return TenantEdition.Community;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Resolves the tenant's effective entitlement set (fail-closed → Community values).</summary>
|
||||
public virtual async Task<EditionEntitlements> GetEntitlementsAsync(string? tenantId)
|
||||
=> FeatureEntitlementCatalog.Get(await GetEditionAsync(tenantId));
|
||||
|
||||
/// <summary>Pure edition resolution for callers that already hold the config.</summary>
|
||||
public static TenantEdition ResolveEdition(TenantConfiguration config, DateTime nowUtc)
|
||||
=> FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
|
||||
|
||||
/// <summary>
|
||||
/// The retention days the platform actually enforces for this config: the stored value
|
||||
/// clamped to the edition's cap. <c>days <= 0</c> is the GA-only "infinite" escape hatch
|
||||
/// and is passed through unclamped (the fanout skips those tenants entirely).
|
||||
/// </summary>
|
||||
public static int GetEffectiveRetentionDays(TenantConfiguration config, DateTime nowUtc)
|
||||
{
|
||||
var days = config.DataRetentionDays;
|
||||
if (days <= 0)
|
||||
return 0;
|
||||
|
||||
var cap = FeatureEntitlementCatalog.Get(ResolveEdition(config, nowUtc)).RetentionCapDays;
|
||||
return Math.Min(days, cap);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,8 @@ import { toolError } from './error-handler.js';
|
||||
* is dropped by default rather than leaking by omission.
|
||||
*/
|
||||
export const TENANT_SAFE_FIELDS: ReadonlySet<string> = new Set([
|
||||
'tenantId', 'domainName', 'planTier', 'disabled', 'disabledReason',
|
||||
'tenantId', 'domainName', 'planTier', 'trialExpiresUtc', 'trialConsumed',
|
||||
'disabled', 'disabledReason',
|
||||
'onboardedAt', 'onboardedBy', 'lastUpdated', 'dataRetentionDays',
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AutopilotMonitor.Shared.Models.Config
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses <see cref="AdminConfiguration.PlanTierDefinitionsJson"/> into typed definitions.
|
||||
/// Shared between the plan management surface and MCP quota enforcement so both read the
|
||||
/// exact same shape. Malformed/empty JSON → empty list (callers fall back to code defaults).
|
||||
/// </summary>
|
||||
public static class PlanTierDefinitionParser
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public static List<PlanTierDefinition> Parse(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return new List<PlanTierDefinition>();
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<PlanTierDefinition>>(json, Options)
|
||||
?? new List<PlanTierDefinition>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<PlanTierDefinition>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,11 +93,36 @@ namespace AutopilotMonitor.Shared.Models
|
||||
|
||||
/// <summary>
|
||||
/// Tenant plan tier. Determines default API rate limits and feature gates.
|
||||
/// Values: "free", "pro", "enterprise". Default: "free".
|
||||
/// Write-side values: "community", "enterprise". Legacy stored values ("free", "pro")
|
||||
/// remain readable and resolve to Community (fail-closed — see FeatureEntitlementCatalog).
|
||||
/// Managed by Global Admins.
|
||||
/// </summary>
|
||||
public string PlanTier { get; set; } = "free";
|
||||
|
||||
/// <summary>
|
||||
/// End of the tenant's Enterprise trial (UTC). While this is in the future the tenant's
|
||||
/// effective edition is Enterprise regardless of <see cref="PlanTier"/>. Null = no trial.
|
||||
/// Expiry degrades the tenant to Community at read time — no timer involved.
|
||||
/// </summary>
|
||||
public DateTime? TrialExpiresUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the tenant's Enterprise trial was started (UTC). Informational/audit only.
|
||||
/// </summary>
|
||||
public DateTime? TrialStartedUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the tenant has used its one self-service trial. Once true, further trials can
|
||||
/// only be granted by a Global Admin via the plan management endpoint (which does not
|
||||
/// reset this flag).
|
||||
/// </summary>
|
||||
public bool TrialConsumed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Who granted/started the trial (UPN of the self-service caller or the Global Admin).
|
||||
/// </summary>
|
||||
public string? TrialGrantedBy { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*")
|
||||
/// Comma-separated list
|
||||
|
||||
@@ -77,7 +77,17 @@ const OPS_EVENT_TYPES: Record<string, string[]> = {
|
||||
// OffboardingFeedbackReceived fires when a departing admin submits free-form feedback
|
||||
// in the drain-barrier banner. Information-tier — wire Telegram to get pinged when
|
||||
// someone leaves a comment so you can read it promptly.
|
||||
Tenant: ["TenantOffboarded", "TenantOffboardingFailed", "OffboardingFeedbackReceived"],
|
||||
// TenantTrialExpiring/Expired — dual-register per memory feedback_ops_event_types_dual_register.
|
||||
// Dispatched by TrialExpirySweepFunction (daily 03:30 UTC, informational — enforcement is
|
||||
// read-time). Expired is Warning (conversion moment: tenant degraded to Community); Expiring
|
||||
// is Info (≤3-day heads-up, re-emitted daily until expiry).
|
||||
Tenant: [
|
||||
"TenantOffboarded",
|
||||
"TenantOffboardingFailed",
|
||||
"OffboardingFeedbackReceived",
|
||||
"TenantTrialExpiring",
|
||||
"TenantTrialExpired",
|
||||
],
|
||||
Agent: ["BlobStorageMissing", "BlobStorageUnreachable", "NewImeVersionDetected", "ExcessiveSessionEvents"],
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,10 @@ export interface TenantConfiguration {
|
||||
dataRetentionDays: number;
|
||||
sessionTimeoutHours: number;
|
||||
planTier?: string;
|
||||
/** Enterprise-trial end (ISO, UTC). Null/undefined = no trial. Managed via PATCH plan. */
|
||||
trialExpiresUtc?: string | null;
|
||||
/** Whether the tenant has used its one self-service trial. */
|
||||
trialConsumed?: boolean;
|
||||
}
|
||||
|
||||
export interface TenantManagementSectionProps {
|
||||
@@ -61,6 +65,7 @@ export function TenantManagementSection({
|
||||
const [tenantSectionExpanded, setTenantSectionExpanded] = useState(false);
|
||||
const [editingTenant, setEditingTenant] = useState<TenantConfiguration | null>(null);
|
||||
const [savingTenant, setSavingTenant] = useState(false);
|
||||
const [savingPlan, setSavingPlan] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const tenantsPerPage = tenantSectionExpanded ? 7 : 3;
|
||||
|
||||
@@ -133,6 +138,52 @@ export function TenantManagementSection({
|
||||
}
|
||||
};
|
||||
|
||||
// Plan & trial have their OWN save path (PATCH /config/{id}/plan) — the generic PUT above
|
||||
// preserves these fields server-side, so they can only be mutated here.
|
||||
const handleSavePlan = async (tenant: TenantConfiguration) => {
|
||||
if (!canMutate) return; // read-only Global Reader
|
||||
try {
|
||||
setSavingPlan(true);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
const response = await authenticatedFetch(api.config.plan(tenant.tenantId), getAccessToken, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
// Legacy stored tiers (free/pro) resolve to Community server-side; the select only
|
||||
// offers the two canonical values, so normalize on save.
|
||||
planTier: tenant.planTier === "enterprise" ? "enterprise" : "community",
|
||||
trialExpiresUtc: tenant.trialExpiresUtc ?? null,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `Failed to save plan: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const apply = (t: TenantConfiguration): TenantConfiguration => ({
|
||||
...t,
|
||||
planTier: result.planTier,
|
||||
trialExpiresUtc: result.trialExpiresUtc ?? null,
|
||||
trialConsumed: result.trialConsumed ?? t.trialConsumed,
|
||||
});
|
||||
setTenants(prev => prev.map(t => (t.tenantId === tenant.tenantId ? apply(t) : t)));
|
||||
setEditingTenant(prev => (prev && prev.tenantId === tenant.tenantId ? apply(prev) : prev));
|
||||
setSuccessMessage(`Plan saved — effective edition: ${result.effectiveEdition}`);
|
||||
setTimeout(() => setSuccessMessage(null), 4000);
|
||||
} catch (err) {
|
||||
if (err instanceof TokenExpiredError) {
|
||||
console.error("Session expired while saving plan");
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Failed to save plan");
|
||||
} finally {
|
||||
setSavingPlan(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendWelcomeEmail = async (tenantId: string, email?: string) => {
|
||||
if (!canMutate) return; // read-only Global Reader
|
||||
try {
|
||||
@@ -502,6 +553,94 @@ export function TenantManagementSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Plan & Trial (own save path — PATCH plan endpoint; the modal's generic Save
|
||||
does not touch these fields, the backend preserves them on PUT) */}
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-purple-900">Plan & Trial</h3>
|
||||
{(() => {
|
||||
const isEnterpriseTier = editingTenant.planTier === "enterprise";
|
||||
const trialActive = !!editingTenant.trialExpiresUtc &&
|
||||
new Date(editingTenant.trialExpiresUtc).getTime() > Date.now();
|
||||
const effective = isEnterpriseTier || trialActive ? "Enterprise" : "Community";
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
effective === "Enterprise"
|
||||
? "bg-purple-100 text-purple-800"
|
||||
: "bg-gray-100 text-gray-700"
|
||||
}`}>
|
||||
Effective: {effective}{!isEnterpriseTier && trialActive ? " (Trial)" : ""}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Plan Tier</label>
|
||||
<select
|
||||
value={editingTenant.planTier === "enterprise" ? "enterprise" : "community"}
|
||||
onChange={(e) => setEditingTenant({ ...editingTenant, planTier: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm text-gray-900 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
>
|
||||
<option value="community">Community</option>
|
||||
<option value="enterprise">Enterprise</option>
|
||||
</select>
|
||||
{editingTenant.planTier && editingTenant.planTier !== "enterprise" && editingTenant.planTier !== "community" && (
|
||||
<p className="text-xs text-amber-600 mt-1">
|
||||
Stored legacy tier "{editingTenant.planTier}" resolves to Community. Saving normalizes it.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Trial Ends (UTC)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={editingTenant.trialExpiresUtc ? new Date(editingTenant.trialExpiresUtc).toISOString().slice(0, 16) : ""}
|
||||
onChange={(e) => setEditingTenant({
|
||||
...editingTenant,
|
||||
trialExpiresUtc: e.target.value ? new Date(e.target.value + "Z").toISOString() : null,
|
||||
})}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
{editingTenant.trialExpiresUtc && (
|
||||
<button
|
||||
onClick={() => setEditingTenant({ ...editingTenant, trialExpiresUtc: null })}
|
||||
className="px-3 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
title="End the trial (saves as no trial)"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Set a date to grant/extend an Enterprise trial; clear to end it. Saving does not reset trial consumption.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">
|
||||
{editingTenant.trialConsumed
|
||||
? "Self-service trial: already consumed (re-grants only via this panel)."
|
||||
: "Self-service trial: still available to the tenant."}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleSavePlan(editingTenant)}
|
||||
disabled={!canMutate || savingPlan}
|
||||
className="px-3 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
{savingPlan ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-white"></div>
|
||||
<span>Saving…</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Save Plan</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Users Info */}
|
||||
<TenantAdminSection
|
||||
tenantId={editingTenant.tenantId}
|
||||
@@ -633,10 +772,10 @@ export function TenantManagementSection({
|
||||
/>
|
||||
{editingTenant.dataRetentionDays === 0 ? (
|
||||
<p className="text-xs text-amber-600 mt-1 font-medium">⚠ Infinite retention — data will never be automatically deleted</p>
|
||||
) : (editingTenant.dataRetentionDays < 7 || editingTenant.dataRetentionDays > 180) ? (
|
||||
<p className="text-xs text-amber-600 mt-1 font-medium">⚠ Outside tenant range (7–180) — field will be locked for tenant admins</p>
|
||||
) : (editingTenant.dataRetentionDays < 7 || editingTenant.dataRetentionDays > 365) ? (
|
||||
<p className="text-xs text-amber-600 mt-1 font-medium">⚠ Outside tenant range (7–365) — field will be locked for tenant admins</p>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 mt-1">Tenant range: 7–180. Set 0 for infinite retention. No upper limit for Global.</p>
|
||||
<p className="text-xs text-gray-400 mt-1">Tenant range: 7–90 (Community) / 7–365 (Enterprise). Values above the plan cap are enforced at the cap. Set 0 for infinite retention (Global only).</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
+8
-1
@@ -74,6 +74,8 @@ interface TenantConfig {
|
||||
|
||||
// Plan tier
|
||||
planTier?: string;
|
||||
trialExpiresUtc?: string | null;
|
||||
trialConsumed?: boolean;
|
||||
|
||||
// Bootstrap & unrestricted
|
||||
bootstrapTokenEnabled?: boolean;
|
||||
@@ -515,7 +517,12 @@ export function SectionTenantConfigReport() {
|
||||
<ConfigRow label="Disabled Reason" value={config.disabledReason} />
|
||||
<ConfigRow label="Disabled Until" value={config.disabledUntil} isDate />
|
||||
<ConfigRow label="Onboarded At" value={config.onboardedAt} isDate />
|
||||
<ConfigRow label="Plan Tier" value={config.planTier || 'free'} configKey="planTier" defaults={DEFAULTS} />
|
||||
<ConfigRow
|
||||
label="Plan Tier"
|
||||
value={`${config.planTier || 'free'}${config.trialExpiresUtc && new Date(config.trialExpiresUtc).getTime() > Date.now() ? ` (trial until ${formatDate(config.trialExpiresUtc)})` : ''}`}
|
||||
configKey="planTier"
|
||||
defaults={DEFAULTS}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Security & Validation">
|
||||
|
||||
@@ -25,6 +25,7 @@ const validationEnabledSuffix = (t: ValidationTrigger) =>
|
||||
? " enabled (shadow mode — does not block enrollment)."
|
||||
: " enabled. Backend agent endpoints are now unlocked for this tenant.";
|
||||
import { parseSasExpiry } from "./components/DiagnosticsSection";
|
||||
import { COMMUNITY_DEFAULT, parseEditionInfo, type EditionInfo } from "@/lib/edition";
|
||||
import { TenantConfiguration, TenantAdmin, DiagnosticsLogPath } from "./types";
|
||||
import { type BootstrapSessionItem } from "./components/BootstrapSessionsSection";
|
||||
|
||||
@@ -53,6 +54,12 @@ interface TenantConfigContextValue {
|
||||
successMessage: string | null;
|
||||
setSuccessMessage: (m: string | null) => void;
|
||||
|
||||
// Edition / trial (read-time server resolution via feature-flags; fail-closed Community)
|
||||
editionInfo: EditionInfo;
|
||||
startingTrial: boolean;
|
||||
/** Self-service 30-day Enterprise trial (once per tenant). Returns success. */
|
||||
startTrial: () => Promise<boolean>;
|
||||
|
||||
// Validation
|
||||
validateAutopilotDevice: boolean;
|
||||
setValidateAutopilotDevice: (v: boolean) => void;
|
||||
@@ -375,6 +382,10 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
// Unrestricted mode
|
||||
const [unrestrictedMode, setUnrestrictedMode] = useState(false);
|
||||
|
||||
// Edition / trial surface (from feature-flags; fail-closed Community until loaded)
|
||||
const [editionInfo, setEditionInfo] = useState<EditionInfo>(COMMUNITY_DEFAULT);
|
||||
const [startingTrial, setStartingTrial] = useState(false);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fetch configuration
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -398,10 +409,22 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
const flags = await response.json();
|
||||
// Create a minimal config object with just the feature flag
|
||||
setConfig({ bootstrapTokenEnabled: flags.bootstrapTokenEnabled } as TenantConfiguration);
|
||||
setEditionInfo(parseEditionInfo(flags));
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await authenticatedFetch(api.config.tenant(tenantId), getAccessToken);
|
||||
// Admin path: full config + feature flags in parallel — the edition surface is
|
||||
// resolved SERVER-side (trial expiry math), so it comes from flags, not the raw config.
|
||||
const [response, flagsResponse] = await Promise.all([
|
||||
authenticatedFetch(api.config.tenant(tenantId), getAccessToken),
|
||||
authenticatedFetch(api.config.featureFlags(tenantId), getAccessToken),
|
||||
]);
|
||||
|
||||
if (flagsResponse.ok) {
|
||||
try {
|
||||
setEditionInfo(parseEditionInfo(await flagsResponse.json()));
|
||||
} catch { /* fail-closed: keep Community default */ }
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load configuration: ${response.statusText}`);
|
||||
@@ -1408,6 +1431,45 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
logout();
|
||||
}, [logout]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Self-service Enterprise trial (once per tenant — backend enforces via 409)
|
||||
// -----------------------------------------------------------------------
|
||||
const startTrial = useCallback(async (): Promise<boolean> => {
|
||||
if (!tenantId) return false;
|
||||
try {
|
||||
setStartingTrial(true);
|
||||
setError(null);
|
||||
|
||||
const response = await authenticatedFetch(api.config.trial(tenantId), getAccessToken, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.message || data.error || `Failed to start trial: ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Refetch the authoritative edition surface (server-resolved).
|
||||
const flagsResponse = await authenticatedFetch(api.config.featureFlags(tenantId), getAccessToken);
|
||||
if (flagsResponse.ok) {
|
||||
setEditionInfo(parseEditionInfo(await flagsResponse.json()));
|
||||
}
|
||||
setSuccessMessage("Enterprise trial started — all Enterprise features are now active for 30 days.");
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
trackEvent("EnterpriseTrialStarted", { tenantId });
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof TokenExpiredError) {
|
||||
addNotification('error', 'Session Expired', err.message, 'session-expired-error');
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : "Failed to start trial");
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
setStartingTrial(false);
|
||||
}
|
||||
}, [tenantId, getAccessToken, addNotification]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Provider value
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1416,6 +1478,9 @@ export function TenantConfigProvider({ children }: { children: React.ReactNode }
|
||||
config, loading, savingSection,
|
||||
error, setError, successMessage, setSuccessMessage,
|
||||
|
||||
// Edition / trial
|
||||
editionInfo, startingTrial, startTrial,
|
||||
|
||||
// Validation
|
||||
validateAutopilotDevice, setValidateAutopilotDevice,
|
||||
validateCorporateIdentifier, setValidateCorporateIdentifier,
|
||||
|
||||
@@ -8,6 +8,8 @@ interface DataManagementSectionProps {
|
||||
sessionTimeoutHours: number;
|
||||
setSessionTimeoutHours: (value: number) => void;
|
||||
isGlobalAdmin?: boolean;
|
||||
/** Edition retention cap (Community 90 / Enterprise 365) — from feature-flags entitlements. */
|
||||
retentionCapDays?: number;
|
||||
onSave: () => Promise<void> | void;
|
||||
onReset: () => void;
|
||||
saving: boolean;
|
||||
@@ -19,11 +21,12 @@ export default function DataManagementSection({
|
||||
sessionTimeoutHours,
|
||||
setSessionTimeoutHours,
|
||||
isGlobalAdmin,
|
||||
retentionCapDays = 90,
|
||||
onSave,
|
||||
onReset,
|
||||
saving,
|
||||
}: DataManagementSectionProps) {
|
||||
const isOverridden = dataRetentionDays === 0 || dataRetentionDays < 7 || dataRetentionDays > 180;
|
||||
const isOverridden = dataRetentionDays === 0 || dataRetentionDays < 7 || dataRetentionDays > retentionCapDays;
|
||||
const isRetentionDisabled = isOverridden && !isGlobalAdmin;
|
||||
|
||||
return (
|
||||
@@ -50,7 +53,7 @@ export default function DataManagementSection({
|
||||
<input
|
||||
type="number"
|
||||
min="7"
|
||||
max="180"
|
||||
max={retentionCapDays}
|
||||
value={dataRetentionDays}
|
||||
disabled={isRetentionDisabled}
|
||||
onChange={(e) => setDataRetentionDays(parseInt(e.target.value) || 90)}
|
||||
@@ -67,12 +70,14 @@ export default function DataManagementSection({
|
||||
<span className="font-semibold">Overridden by administrator</span> —{" "}
|
||||
{dataRetentionDays === 0
|
||||
? "Infinite retention is active. Data will never be automatically deleted."
|
||||
: `Retention is set to ${dataRetentionDays} days (outside the standard 7–180 day range).`}
|
||||
: `Retention is set to ${dataRetentionDays} days (outside the standard 7–${retentionCapDays} day range for your plan).`}
|
||||
{" "}Contact your Global admin to change this value.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-400 mt-1">Minimum: 7 days, Maximum: 180 days</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Minimum: 7 days, Maximum: {retentionCapDays} days{retentionCapDays < 365 ? " (up to 365 with Enterprise)" : ""}
|
||||
</p>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { editionLabel } from "@/lib/edition";
|
||||
import { useTenantConfig } from "../TenantConfigContext";
|
||||
|
||||
/**
|
||||
* Edition badge + self-service trial CTA for the settings header.
|
||||
* - "Enterprise" / "Enterprise Trial — X days left" / "Community"
|
||||
* - "Start 30-day Enterprise trial" button only when the backend reports the trial as
|
||||
* still available (never consumed + currently Community) AND the caller is a tenant admin.
|
||||
* One-time action → guarded by an inline confirm step.
|
||||
*/
|
||||
export default function EditionBadge() {
|
||||
const { editionInfo, startTrial, startingTrial, user } = useTenantConfig();
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
|
||||
const isEnterprise = editionInfo.edition === "enterprise";
|
||||
const label = editionLabel(editionInfo);
|
||||
const canStartTrial =
|
||||
editionInfo.trialAvailable && (user?.isTenantAdmin === true || user?.isGlobalAdmin === true);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold ${
|
||||
isEnterprise
|
||||
? "bg-purple-100 text-purple-800 border border-purple-300"
|
||||
: "bg-gray-100 text-gray-700 border border-gray-300"
|
||||
}`}
|
||||
title={
|
||||
isEnterprise
|
||||
? editionInfo.isTrial
|
||||
? "Enterprise trial is active — all Enterprise features are unlocked."
|
||||
: "This tenant is on the Enterprise edition."
|
||||
: "This tenant is on the Community edition."
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
|
||||
{canStartTrial && !confirming && (
|
||||
<button
|
||||
onClick={() => setConfirming(true)}
|
||||
className="text-xs font-medium text-purple-700 border border-purple-300 rounded-full px-3 py-1 hover:bg-purple-50 transition-colors"
|
||||
>
|
||||
Start 30-day Enterprise trial
|
||||
</button>
|
||||
)}
|
||||
|
||||
{canStartTrial && confirming && (
|
||||
<span className="flex items-center gap-2 text-xs">
|
||||
<span className="text-gray-600">One-time trial — start now?</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const ok = await startTrial();
|
||||
if (ok) setConfirming(false);
|
||||
}}
|
||||
disabled={startingTrial}
|
||||
className="font-medium text-white bg-purple-600 rounded-full px-3 py-1 hover:bg-purple-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{startingTrial ? "Starting…" : "Confirm"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirming(false)}
|
||||
disabled={startingTrial}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+2
-1
@@ -8,7 +8,7 @@ export function SectionDataManagement() {
|
||||
const {
|
||||
dataRetentionDays, setDataRetentionDays,
|
||||
sessionTimeoutHours, setSessionTimeoutHours,
|
||||
user,
|
||||
user, editionInfo,
|
||||
handleSaveDataManagement, handleResetDataManagement,
|
||||
savingSection,
|
||||
} = useTenantConfig();
|
||||
@@ -22,6 +22,7 @@ export function SectionDataManagement() {
|
||||
sessionTimeoutHours={sessionTimeoutHours}
|
||||
setSessionTimeoutHours={setSessionTimeoutHours}
|
||||
isGlobalAdmin={user?.isGlobalAdmin}
|
||||
retentionCapDays={editionInfo.entitlements.retentionCapDays}
|
||||
onSave={handleSaveDataManagement}
|
||||
onReset={handleResetDataManagement}
|
||||
saving={savingSection === "dataManagement"}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import EditionBadge from "../components/EditionBadge";
|
||||
|
||||
export function TenantSidebar({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<header className="bg-white shadow">
|
||||
<div className="py-6 px-4 sm:px-6 lg:px-8">
|
||||
<h1 className="text-2xl font-normal text-gray-900">Tenant Configuration</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Validation, notifications, and access management</p>
|
||||
<div className="py-6 px-4 sm:px-6 lg:px-8 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-normal text-gray-900">Tenant Configuration</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Validation, notifications, and access management</p>
|
||||
</div>
|
||||
<EditionBadge />
|
||||
</div>
|
||||
</header>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-8">
|
||||
|
||||
@@ -82,6 +82,10 @@ export interface TenantConfiguration {
|
||||
// Unrestricted mode
|
||||
unrestrictedModeEnabled?: boolean;
|
||||
unrestrictedMode?: boolean;
|
||||
// Plan / edition (read-only here — mutated only via the dedicated plan/trial endpoints)
|
||||
planTier?: string;
|
||||
trialExpiresUtc?: string | null;
|
||||
trialConsumed?: boolean;
|
||||
}
|
||||
|
||||
export interface TenantAdmin {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
COMMUNITY_DEFAULT,
|
||||
editionLabel,
|
||||
parseEditionInfo,
|
||||
trialDaysLeft,
|
||||
} from "../edition";
|
||||
|
||||
const NOW = new Date("2026-07-07T12:00:00Z");
|
||||
|
||||
describe("parseEditionInfo", () => {
|
||||
it("parses a full enterprise-trial payload", () => {
|
||||
const info = parseEditionInfo({
|
||||
edition: "enterprise",
|
||||
isTrial: true,
|
||||
trialExpiresUtc: "2026-07-20T12:00:00Z",
|
||||
trialAvailable: false,
|
||||
entitlements: {
|
||||
retentionCapDays: 365,
|
||||
userRateLimitPerMinute: 150,
|
||||
delegatedAdminAllowed: true,
|
||||
mcpUsagePlan: "enterprise",
|
||||
},
|
||||
});
|
||||
|
||||
expect(info.edition).toBe("enterprise");
|
||||
expect(info.isTrial).toBe(true);
|
||||
expect(info.trialExpiresUtc).toBe("2026-07-20T12:00:00Z");
|
||||
expect(info.entitlements.retentionCapDays).toBe(365);
|
||||
expect(info.entitlements.userRateLimitPerMinute).toBe(150);
|
||||
expect(info.entitlements.delegatedAdminAllowed).toBe(true);
|
||||
});
|
||||
|
||||
it("fails closed to Community for malformed payloads", () => {
|
||||
expect(parseEditionInfo(null)).toEqual(COMMUNITY_DEFAULT);
|
||||
expect(parseEditionInfo(undefined)).toEqual(COMMUNITY_DEFAULT);
|
||||
expect(parseEditionInfo("nope")).toEqual(COMMUNITY_DEFAULT);
|
||||
expect(parseEditionInfo({})).toEqual(COMMUNITY_DEFAULT);
|
||||
});
|
||||
|
||||
it("treats unknown edition strings as community (fail-closed)", () => {
|
||||
const info = parseEditionInfo({ edition: "platinum", trialAvailable: true });
|
||||
expect(info.edition).toBe("community");
|
||||
expect(info.trialAvailable).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults missing entitlements to Community values", () => {
|
||||
const info = parseEditionInfo({ edition: "enterprise" });
|
||||
expect(info.entitlements.retentionCapDays).toBe(90);
|
||||
expect(info.entitlements.userRateLimitPerMinute).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("trialDaysLeft", () => {
|
||||
it("returns 0 for unset/expired/invalid values", () => {
|
||||
expect(trialDaysLeft(null, NOW)).toBe(0);
|
||||
expect(trialDaysLeft(undefined, NOW)).toBe(0);
|
||||
expect(trialDaysLeft("2026-07-07T11:59:59Z", NOW)).toBe(0);
|
||||
expect(trialDaysLeft("not-a-date", NOW)).toBe(0);
|
||||
});
|
||||
|
||||
it("ceils partial days — expiring later today counts as 1", () => {
|
||||
expect(trialDaysLeft("2026-07-07T18:00:00Z", NOW)).toBe(1);
|
||||
});
|
||||
|
||||
it("counts exact whole days", () => {
|
||||
expect(trialDaysLeft("2026-07-10T12:00:00Z", NOW)).toBe(3);
|
||||
expect(trialDaysLeft("2026-08-06T12:00:01Z", NOW)).toBe(31);
|
||||
});
|
||||
});
|
||||
|
||||
describe("editionLabel", () => {
|
||||
it("labels community", () => {
|
||||
expect(editionLabel(COMMUNITY_DEFAULT, NOW)).toBe("Community");
|
||||
});
|
||||
|
||||
it("labels permanent enterprise", () => {
|
||||
const info = parseEditionInfo({ edition: "enterprise", isTrial: false });
|
||||
expect(editionLabel(info, NOW)).toBe("Enterprise");
|
||||
});
|
||||
|
||||
it("labels a trial with a day countdown (singular/plural)", () => {
|
||||
const plural = parseEditionInfo({
|
||||
edition: "enterprise",
|
||||
isTrial: true,
|
||||
trialExpiresUtc: "2026-07-10T12:00:00Z",
|
||||
});
|
||||
expect(editionLabel(plural, NOW)).toBe("Enterprise Trial — 3 days left");
|
||||
|
||||
const singular = parseEditionInfo({
|
||||
edition: "enterprise",
|
||||
isTrial: true,
|
||||
trialExpiresUtc: "2026-07-07T18:00:00Z",
|
||||
});
|
||||
expect(editionLabel(singular, NOW)).toBe("Enterprise Trial — 1 day left");
|
||||
});
|
||||
});
|
||||
@@ -101,6 +101,10 @@ export const api = {
|
||||
all: () => `${API_BASE_URL}/api/config/all`,
|
||||
tenant: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}`,
|
||||
featureFlags: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/feature-flags`,
|
||||
/** PATCH — GA-only plan/trial mutation: { planTier?, trialExpiresUtc?: ISO | null }. */
|
||||
plan: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/plan`,
|
||||
/** POST — tenant-admin self-service 30-day Enterprise trial (once per tenant, 409 after). */
|
||||
trial: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/trial`,
|
||||
autopilotConsentUrl: (tenantId: string, redirectUri: string) =>
|
||||
`${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-url${qs({ redirectUri })}`,
|
||||
autopilotConsentStatus: (tenantId: string) =>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Pure edition/trial math for the Community/Enterprise entitlement surface.
|
||||
* Mirrors the backend's read-time resolution (FeatureEntitlementCatalog): the UI never
|
||||
* decides entitlements itself — it renders what /feature-flags reports — but needs small
|
||||
* pure helpers for countdown/labels. Kept free of React so it is unit-testable.
|
||||
*/
|
||||
|
||||
export type TenantEditionName = "community" | "enterprise";
|
||||
|
||||
/** Edition/trial surface returned by GET /api/config/{tenantId}/feature-flags. */
|
||||
export interface EditionInfo {
|
||||
edition: TenantEditionName;
|
||||
isTrial: boolean;
|
||||
trialExpiresUtc: string | null;
|
||||
trialAvailable: boolean;
|
||||
entitlements: {
|
||||
retentionCapDays: number;
|
||||
userRateLimitPerMinute: number | null;
|
||||
delegatedAdminAllowed: boolean;
|
||||
mcpUsagePlan: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Fail-closed default while flags are loading or on error: Community, no trial CTA. */
|
||||
export const COMMUNITY_DEFAULT: EditionInfo = {
|
||||
edition: "community",
|
||||
isTrial: false,
|
||||
trialExpiresUtc: null,
|
||||
trialAvailable: false,
|
||||
entitlements: {
|
||||
retentionCapDays: 90,
|
||||
userRateLimitPerMinute: null,
|
||||
delegatedAdminAllowed: false,
|
||||
mcpUsagePlan: "community",
|
||||
},
|
||||
};
|
||||
|
||||
/** Parse the feature-flags payload's edition surface; malformed → Community default. */
|
||||
export function parseEditionInfo(flags: unknown): EditionInfo {
|
||||
if (!flags || typeof flags !== "object") return COMMUNITY_DEFAULT;
|
||||
const f = flags as Record<string, unknown>;
|
||||
const edition: TenantEditionName = f.edition === "enterprise" ? "enterprise" : "community";
|
||||
const ent = (f.entitlements && typeof f.entitlements === "object"
|
||||
? (f.entitlements as Record<string, unknown>)
|
||||
: {}) as Record<string, unknown>;
|
||||
return {
|
||||
edition,
|
||||
isTrial: f.isTrial === true,
|
||||
trialExpiresUtc: typeof f.trialExpiresUtc === "string" ? f.trialExpiresUtc : null,
|
||||
trialAvailable: f.trialAvailable === true,
|
||||
entitlements: {
|
||||
retentionCapDays:
|
||||
typeof ent.retentionCapDays === "number" ? ent.retentionCapDays : COMMUNITY_DEFAULT.entitlements.retentionCapDays,
|
||||
userRateLimitPerMinute:
|
||||
typeof ent.userRateLimitPerMinute === "number" ? ent.userRateLimitPerMinute : null,
|
||||
delegatedAdminAllowed: ent.delegatedAdminAllowed === true,
|
||||
mcpUsagePlan: typeof ent.mcpUsagePlan === "string" ? ent.mcpUsagePlan : "community",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole days remaining until the trial expires (ceiling — "expires later today" = 1).
|
||||
* Returns 0 when expired or unset.
|
||||
*/
|
||||
export function trialDaysLeft(trialExpiresUtc: string | null | undefined, now: Date = new Date()): number {
|
||||
if (!trialExpiresUtc) return 0;
|
||||
const expiry = new Date(trialExpiresUtc);
|
||||
if (isNaN(expiry.getTime())) return 0;
|
||||
const ms = expiry.getTime() - now.getTime();
|
||||
return ms <= 0 ? 0 : Math.ceil(ms / 86_400_000);
|
||||
}
|
||||
|
||||
/** Badge label: "Enterprise", "Enterprise Trial — X days left", or "Community". */
|
||||
export function editionLabel(info: EditionInfo, now: Date = new Date()): string {
|
||||
if (info.edition !== "enterprise") return "Community";
|
||||
if (!info.isTrial) return "Enterprise";
|
||||
const days = trialDaysLeft(info.trialExpiresUtc, now);
|
||||
return `Enterprise Trial — ${days} day${days === 1 ? "" : "s"} left`;
|
||||
}
|
||||
Reference in New Issue
Block a user