From d8e8cd15fd5839bfcb1bb10f2e2031d15693076b Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 7 Jul 2026 13:06:04 +0200 Subject: [PATCH] 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. --- .../AuthFunctionSideEffectTests.cs | 6 +- .../DelegatedAdminEditionGateTests.cs | 131 +++++++++ .../DelegatedAdminServiceTests.cs | 8 +- .../FeatureEntitlementCatalogTests.cs | 103 +++++++ .../GetTenantFeatureFlagsPayloadTests.cs | 61 +++- .../McpQuotaServiceTests.cs | 228 ++++++++++++++ .../McpUserServiceDelegatedTests.cs | 8 +- .../PolicyEnforcementMiddlewareTests.cs | 8 +- .../RateLimitResolverTests.cs | 59 ++++ .../SessionRetentionFanoutServiceTests.cs | 40 ++- .../StubTenantEntitlementService.cs | 31 ++ .../TenantConfigProjectionTests.cs | 3 +- .../TenantConfigTableSerializationTests.cs | 88 ++++++ .../TenantEntitlementServiceTests.cs | 144 +++++++++ .../TestTimeProvider.cs | 16 + .../TrialExpirySweepFunctionTests.cs | 182 ++++++++++++ .../TableStorage/TableConfigRepository.cs | 16 +- .../Admin/DelegatedAdminManagementFunction.cs | 5 + .../Config/GetTenantFeatureFlagsFunction.cs | 57 ++-- .../Config/PlanManagementFunction.cs | 277 +++++++++++++++--- .../UpdateTenantConfigurationFunction.cs | 32 ++ .../Functions/Infrastructure/AuthFunction.cs | 5 +- .../Infrastructure/McpUserFunction.cs | 3 +- .../SignalRAddToGroupFunction.cs | 4 +- .../SignalRRemoveFromGroupFunction.cs | 2 +- .../Maintenance/TrialExpirySweepFunction.cs | 134 +++++++++ .../Metrics/McpUsageMetricsFunction.cs | 19 +- .../Helpers/TenantConfigProjection.cs | 2 + .../McpQuotaEnforcementMiddleware.cs | 125 ++++++++ .../Middleware/PolicyEnforcementMiddleware.cs | 10 +- .../Middleware/UserRateLimitMiddleware.cs | 20 +- .../AutopilotMonitor.Functions/Program.cs | 6 + .../Security/EndpointAccessPolicyCatalog.cs | 3 + .../Security/FeatureEntitlementCatalog.cs | 119 ++++++++ .../Security/SecurityValidator.cs | 13 +- .../Services/DelegatedAdminService.cs | 52 +++- .../Deletion/SessionRetentionFanoutService.cs | 17 +- .../Services/McpQuotaService.cs | 205 +++++++++++++ .../Services/McpUserService.cs | 7 +- .../Services/OpsEventService.cs | 26 ++ .../Services/RateLimitResolver.cs | 23 +- .../Services/TenantEntitlementService.cs | 92 ++++++ .../autopilot-monitor-mcp/src/tools/admin.ts | 3 +- .../Models/Config/PlanTierDefinitionParser.cs | 31 ++ .../Models/Config/TenantConfiguration.cs | 27 +- .../admin/components/OpsAlertRulesSection.tsx | 12 +- .../components/TenantManagementSection.tsx | 145 ++++++++- .../sections/SectionTenantConfigReport.tsx | 9 +- .../app/settings/TenantConfigContext.tsx | 67 ++++- .../components/DataManagementSection.tsx | 13 +- .../app/settings/components/EditionBadge.tsx | 75 +++++ .../sections/SectionDataManagement.tsx | 3 +- .../app/settings/tenant/TenantSidebar.tsx | 11 +- .../app/settings/types.ts | 4 + .../lib/__tests__/edition.test.ts | 97 ++++++ src/Web/autopilot-monitor-web/lib/api.ts | 4 + src/Web/autopilot-monitor-web/lib/edition.ts | 80 +++++ 57 files changed, 2852 insertions(+), 119 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminEditionGateTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/FeatureEntitlementCatalogTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/StubTenantEntitlementService.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/TenantEntitlementServiceTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/TestTimeProvider.cs create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/TrialExpirySweepFunctionTests.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/McpQuotaService.cs create mode 100644 src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs create mode 100644 src/Shared/AutopilotMonitor.Shared/Models/Config/PlanTierDefinitionParser.cs create mode 100644 src/Web/autopilot-monitor-web/app/settings/components/EditionBadge.tsx create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/edition.test.ts create mode 100644 src/Web/autopilot-monitor-web/lib/edition.ts diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs index b29ae27c..cca637bf 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AuthFunctionSideEffectTests.cs @@ -43,9 +43,11 @@ public class AuthFunctionSideEffectTests { CallBase = false }; var delegatedAdminMock = new Mock( - adminRepo, cache, Mock.Of>()) + adminRepo, + new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise), + cache, Mock.Of>()) { CallBase = false }; - delegatedAdminMock.Setup(x => x.GetScopeAsync(It.IsAny())) + delegatedAdminMock.Setup(x => x.GetScopeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(DelegatedScope.Empty); _tenantAdminsMock = new Mock( diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminEditionGateTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminEditionGateTests.cs new file mode 100644 index 00000000..2e3abf40 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminEditionGateTests.cs @@ -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; + +/// +/// Pins the Enterprise entitlement gate in : +/// 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. +/// +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, + }; + + /// Entitlements: ONLY the Enterprise home tenant resolves Enterprise — managed targets are Community. + private static DelegatedAdminService Build(params DelegatedAdminEntry[] rows) + { + var repo = new Mock(); + repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny())).ReturnsAsync(rows.ToList()); + repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny())) + .ReturnsAsync(new List()); + return new DelegatedAdminService( + repo.Object, + new StubTenantEntitlementService( + tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Enterprise : TenantEdition.Community), + new MemoryCache(new MemoryCacheOptions()), + NullLogger.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(); + repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny())) + .ReturnsAsync(new List()); + repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny())) + .ReturnsAsync(new List + { + 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 { ManagedTenantA, ManagedTenantB }); + + var svc = new DelegatedAdminService( + repo.Object, + new StubTenantEntitlementService( + tenantId => tenantId == EnterpriseHomeTenant ? TenantEdition.Enterprise : TenantEdition.Community), + new MemoryCache(new MemoryCacheOptions()), + NullLogger.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)); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs index 0d319fd8..d757ffc4 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminServiceTests.cs @@ -32,7 +32,13 @@ public class DelegatedAdminServiceTests repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny())) .ReturnsAsync(new List()); var cache = new MemoryCache(new MemoryCacheOptions()); - var svc = new DelegatedAdminService(repo.Object, cache, NullLogger.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.Instance); return (svc, repo); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/FeatureEntitlementCatalogTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/FeatureEntitlementCatalogTests.cs new file mode 100644 index 00000000..8c5af094 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/FeatureEntitlementCatalogTests.cs @@ -0,0 +1,103 @@ +using AutopilotMonitor.Functions.Security; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Pins the edition-resolution matrix and the per-edition entitlement values of +/// . 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. +/// +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); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs index b09a40d0..ff371c77 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs @@ -14,9 +14,11 @@ namespace AutopilotMonitor.Functions.Tests; /// 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"); + } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs new file mode 100644 index 00000000..9d8c32b5 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/McpQuotaServiceTests.cs @@ -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; + +/// +/// Tests for : 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. +/// +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 usageRepo, + string? planDefinitionsJson = null, + string? mcpUserPlanOverride = null, + TenantEdition edition = TenantEdition.Community) + { + var adminRepo = new Mock(); + adminRepo.Setup(r => r.GetMcpUserAsync(It.IsAny())) + .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.Instance, + globalAdminService: null!, delegatedAdminService: null!, adminConfigService: null!); + + var configRepo = new Mock(); + configRepo.Setup(r => r.GetAdminConfigurationAsync()) + .ReturnsAsync(new AutopilotMonitor.Shared.Models.AdminConfiguration + { + UpdatedBy = "test", + PlanTierDefinitionsJson = planDefinitionsJson + }); + var adminConfigService = new AdminConfigurationService( + configRepo.Object, NullLogger.Instance, cache); + + return new McpQuotaService( + usageRepo.Object, + mcpUserService, + adminConfigService, + new StubTenantEntitlementService(edition), + cache, + NullLogger.Instance, + new TestTimeProvider(Now)); + } + + private static Mock UsageRepo(params (string Date, long Count)[] rows) + { + var repo = new Mock(); + repo.Setup(r => r.GetUsageByUserAsync(Oid, It.IsAny(), It.IsAny())) + .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(); + repo.Setup(r => r.GetUsageByUserAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .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(), It.IsAny(), It.IsAny()), + 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(), It.IsAny(), It.IsAny()), + Times.Once); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceDelegatedTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceDelegatedTests.cs index 39869716..241a2992 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceDelegatedTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/McpUserServiceDelegatedTests.cs @@ -35,13 +35,15 @@ public class McpUserServiceDelegatedTests _globalAdmin = new Mock( _adminRepo.Object, cache, NullLogger.Instance) { CallBase = false }; _delegatedAdmin = new Mock( - _adminRepo.Object, cache, NullLogger.Instance) { CallBase = false }; + _adminRepo.Object, + new StubTenantEntitlementService(AutopilotMonitor.Functions.Security.TenantEdition.Enterprise), + cache, NullLogger.Instance) { CallBase = false }; _adminConfig = new Mock( Mock.Of(), NullLogger.Instance, cache) { CallBase = false }; // Defaults: no platform role, no delegated scope, WhitelistOnly, no McpUsers row (NotPresent). _globalAdmin.Setup(x => x.GetGlobalRoleAsync(It.IsAny())).ReturnsAsync((string?)null); - _delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny())).ReturnsAsync(DelegatedScope.Empty); + _delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny(), It.IsAny())).ReturnsAsync(DelegatedScope.Empty); _adminRepo.Setup(x => x.GetMcpUserAsync(It.IsAny())).ReturnsAsync((McpUserEntry?)null); SetPolicy(McpAccessPolicy.WhitelistOnly); @@ -59,7 +61,7 @@ public class McpUserServiceDelegatedTests var map = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var (tenant, role) in assignments) map[tenant.ToLowerInvariant()] = role; - _delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny())) + _delegatedAdmin.Setup(x => x.GetScopeAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new DelegatedScope(map)); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs index a36ca985..6b4bc426 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/PolicyEnforcementMiddlewareTests.cs @@ -84,7 +84,13 @@ public class PolicyEnforcementMiddlewareTests var cache = new MemoryCache(new MemoryCacheOptions()); var globalAdmin = new GlobalAdminService(repo.Object, cache, NullLogger.Instance); - var delegatedAdmin = new DelegatedAdminService(repo.Object, cache, NullLogger.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.Instance); var tenantAdmins = new TenantAdminsService(repo.Object, cache, NullLogger.Instance); var tenantConfig = new TenantConfigurationService( configRepo.Object, NullLogger.Instance, cache); diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RateLimitResolverTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RateLimitResolverTests.cs index 5a2ffae6..cffdc073 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/RateLimitResolverTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/RateLimitResolverTests.cs @@ -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)); + } } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/SessionRetentionFanoutServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/SessionRetentionFanoutServiceTests.cs index b8d6a202..7015832b 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/SessionRetentionFanoutServiceTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/SessionRetentionFanoutServiceTests.cs @@ -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(), It.IsAny(), It.IsAny())) @@ -119,6 +120,37 @@ public class SessionRetentionFanoutServiceTests harness.MaintenanceRepo.Verify(m => m.GetSessionsOlderThanAsync(TenantA, It.IsAny(), It.IsAny(), It.IsAny()), 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(d => d > DateTime.UtcNow.AddDays(-91) && d <= DateTime.UtcNow.AddDays(-89)), + It.IsAny(), It.IsAny()), 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(d => d > DateTime.UtcNow.AddDays(-181) && d <= DateTime.UtcNow.AddDays(-179)), + It.IsAny(), It.IsAny()), 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(), It.IsAny(), It.IsAny())) .ReturnsAsync(new List(WithTenantId(tenantId, sessions))); } diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/StubTenantEntitlementService.cs b/src/Backend/AutopilotMonitor.Functions.Tests/StubTenantEntitlementService.cs new file mode 100644 index 00000000..bc4d37f5 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/StubTenantEntitlementService.cs @@ -0,0 +1,31 @@ +using AutopilotMonitor.Functions.Security; +using AutopilotMonitor.Functions.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Test stub for — 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). +/// +internal sealed class StubTenantEntitlementService : TenantEntitlementService +{ + private readonly Func _resolver; + + public StubTenantEntitlementService(TenantEdition edition) : this(_ => edition) + { + } + + public StubTenantEntitlementService(Func resolver) + : base(configService: null!, logger: NullLogger.Instance) + { + _resolver = resolver; + } + + public override Task GetEditionAsync(string? tenantId) + => Task.FromResult(_resolver(tenantId)); + + public override Task GetEntitlementsAsync(string? tenantId) + => Task.FromResult(FeatureEntitlementCatalog.Get(_resolver(tenantId))); +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs index bbe675eb..274480c3 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs @@ -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", }; diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs new file mode 100644 index 00000000..e6972834 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs @@ -0,0 +1,88 @@ +using AutopilotMonitor.Functions.DataAccess.TableStorage; +using AutopilotMonitor.Shared.Models; +using Azure.Data.Tables; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests; + +/// +/// 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. +/// +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); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantEntitlementServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantEntitlementServiceTests.cs new file mode 100644 index 00000000..7293ae65 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantEntitlementServiceTests.cs @@ -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; + +/// +/// Tests for : read-time edition resolution on top of the +/// tenant-config cache, the fail-closed contract (no row / storage error → Community), and the +/// retention clamp (). +/// +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 Repo) Build(TenantConfiguration? config) + { + var repo = new Mock(); + repo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny())).ReturnsAsync(config); + var configService = new TenantConfigurationService( + repo.Object, NullLogger.Instance, new MemoryCache(new MemoryCacheOptions())); + var svc = new TenantEntitlementService( + configService, NullLogger.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(); + repo.Setup(r => r.GetTenantConfigurationAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("storage down")); + var configService = new TenantConfigurationService( + repo.Object, NullLogger.Instance, new MemoryCache(new MemoryCacheOptions())); + var svc = new TenantEntitlementService( + configService, NullLogger.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()), 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()), 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))); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TestTimeProvider.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TestTimeProvider.cs new file mode 100644 index 00000000..ab14fb8f --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TestTimeProvider.cs @@ -0,0 +1,16 @@ +namespace AutopilotMonitor.Functions.Tests; + +/// +/// Shared fixed-clock for tests that need deterministic time math +/// (trial expiry, quota windows). Mutable via to simulate passage of time. +/// +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; +} diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TrialExpirySweepFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TrialExpirySweepFunctionTests.cs new file mode 100644 index 00000000..7ac0c1d3 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/TrialExpirySweepFunctionTests.cs @@ -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; + +/// +/// Tests for — 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. +/// +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 Events) Build(params TenantConfiguration[] configs) + { + var configRepo = new Mock(); + configRepo.Setup(r => r.GetAllTenantConfigurationsAsync()).ReturnsAsync(configs.ToList()); + + var events = new List(); + var opsRepo = new Mock(); + opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny())) + .Callback(events.Add) + .Returns(Task.CompletedTask); + + var cache = new MemoryCache(new MemoryCacheOptions()); + var adminConfig = new Mock( + Mock.Of(), NullLogger.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(), NullLogger.Instance), + new WebhookNotificationService(new HttpClient(), NullLogger.Instance), + NullLogger.Instance); + var opsService = new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch); + + var sut = new TrialExpirySweepFunction( + configRepo.Object, opsService, NullLogger.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(); + configRepo.Setup(r => r.GetAllTenantConfigurationsAsync()) + .ThrowsAsync(new InvalidOperationException("storage down")); + + var opsRepo = new Mock(); + var cache = new MemoryCache(new MemoryCacheOptions()); + var adminConfig = new Mock( + Mock.Of(), NullLogger.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(), NullLogger.Instance), + new WebhookNotificationService(new HttpClient(), NullLogger.Instance), + NullLogger.Instance); + + var sut = new TrialExpirySweepFunction( + configRepo.Object, + new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch), + NullLogger.Instance, + new TestTimeProvider(Now)); + + var result = await sut.RunCoreAsync(CancellationToken.None); + + Assert.Equal(0, result.TrialsSeen); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index 69562faa..12a4eb31 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -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"), diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegatedAdminManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegatedAdminManagementFunction.cs index 13a27a61..3244f90e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegatedAdminManagementFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DelegatedAdminManagementFunction.cs @@ -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 ?? ""); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs index a384229d..1d69c93b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs @@ -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 /// /// Projects the member-readable subset of . /// 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. feeds the + /// read-time edition resolution (trial expiry degrades automatically). /// - 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 + } + }; + } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs index dea85581..17b1f0fe 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs @@ -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 { + /// + /// 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 so the 5-minute config cache is invalidated, + /// and every mutation is audited under the target tenant's trail. + /// public class PlanManagementFunction { + /// Self-service trial length. GA can grant arbitrary end dates via PATCH. + internal const int SelfServiceTrialDays = 30; + private readonly ILogger _logger; - private readonly IConfigRepository _configRepo; + private readonly TenantConfigurationService _configService; private readonly AdminConfigurationService _adminConfigService; + private readonly IMaintenanceRepository _maintenanceRepo; + private readonly TimeProvider _time; public PlanManagementFunction( ILogger logger, - IConfigRepository configRepo, - AdminConfigurationService adminConfigService) + TenantConfigurationService configService, + AdminConfigurationService adminConfigService, + IMaintenanceRepository maintenanceRepo) + : this(logger, configService, adminConfigService, maintenanceRepo, TimeProvider.System) + { + } + + /// Test seam — inject a fake for deterministic trial math. + public PlanManagementFunction( + ILogger logger, + TenantConfigurationService configService, + AdminConfigurationService adminConfigService, + IMaintenanceRepository maintenanceRepo, + TimeProvider time) { _logger = logger; - _configRepo = configRepo; + _configService = configService; _adminConfigService = adminConfigService; + _maintenanceRepo = maintenanceRepo; + _time = time; } /// - /// 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. /// [Function("SetTenantPlanTier")] public async Task 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(); - 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(); + + 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 } } + /// + /// 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. + /// + [Function("StartTenantTrial")] + public async Task 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 + { + { "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; + } + } + /// /// 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(); 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 ParsePlanTierDefinitions(string? json) + /// + /// 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. + /// + private async Task SaveInvalidatingOnFailureAsync(AutopilotMonitor.Shared.Models.TenantConfiguration config) { - if (string.IsNullOrWhiteSpace(json)) - return new List(); - try { - return JsonSerializer.Deserialize>(json, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new List(); + await _configService.SaveConfigurationAsync(config); } catch { - return new List(); + _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 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 diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs index bb9ef696..5c49325c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs @@ -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); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs index 4287e9eb..5a943620 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs @@ -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, diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/McpUserFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/McpUserFunction.cs index a18e1d25..c5fd2566 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/McpUserFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/McpUserFunction.cs @@ -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 diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs index 84fcaac8..94c7df15 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRAddToGroupFunction.cs @@ -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}"); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs index 9b082c05..4f91e31f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/SignalRRemoveFromGroupFunction.cs @@ -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; } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs new file mode 100644 index 00000000..9bfb52f0 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs @@ -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; + +/// +/// 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: +/// +/// TenantTrialExpired (Warning) — the trial ended within the last sweep window +/// (24h look-back matching the daily cadence). +/// TenantTrialExpiring (Info) — the trial ends within the next +/// days (re-emitted each daily run until expiry — +/// acceptable for an Info-tier heads-up, no dedupe state to maintain). +/// +/// Tenants whose stored PlanTier 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. +/// +public class TrialExpirySweepFunction +{ + /// Heads-up window for TenantTrialExpiring. + public static readonly TimeSpan ExpiringHeadsUp = TimeSpan.FromDays(3); + private const int ExpiringHeadsUpDays = 3; + + /// Look-back for TenantTrialExpired — matches the daily cadence so no expiry is skipped. + public static readonly TimeSpan ExpiredLookBack = TimeSpan.FromHours(24); + + /// 03:30 UTC daily — off the busy maintenance window, after ScriptNameCacheCleanup (03:00). + private const string Cron = "0 30 3 * * *"; + + private readonly IConfigRepository _configRepo; + private readonly OpsEventService _opsEvents; + private readonly ILogger _logger; + private readonly TimeProvider _time; + + public TrialExpirySweepFunction( + IConfigRepository configRepo, + OpsEventService opsEvents, + ILogger logger) + : this(configRepo, opsEvents, logger, TimeProvider.System) + { + } + + /// Test seam — inject a fake for deterministic window math. + internal TrialExpirySweepFunction( + IConfigRepository configRepo, + OpsEventService opsEvents, + ILogger 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); + } + + /// Testable core — bypasses TimerInfo / FunctionContext. + internal async Task RunCoreAsync(CancellationToken ct) + { + var now = _time.GetUtcNow().UtcDateTime; + var result = new SweepResult(); + + System.Collections.Generic.List 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 counters returned from so tests can assert outcomes. + internal sealed class SweepResult + { + public int TrialsSeen { get; set; } + public int ExpiredEmitted { get; set; } + public int ExpiringEmitted { get; set; } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/McpUsageMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/McpUsageMetricsFunction.cs index bb6b5c97..ed08d292 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/McpUsageMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/McpUsageMetricsFunction.cs @@ -19,15 +19,18 @@ namespace AutopilotMonitor.Functions.Functions.Metrics private readonly ILogger _logger; private readonly IUserUsageRepository _userUsageRepo; private readonly McpUserService _mcpUserService; + private readonly McpQuotaService _quotaService; public McpUsageMetricsFunction( ILogger logger, IUserUsageRepository userUsageRepo, - McpUserService mcpUserService) + McpUserService mcpUserService, + McpQuotaService quotaService) { _logger = logger; _userUsageRepo = userUsageRepo; _mcpUserService = mcpUserService; + _quotaService = quotaService; } /// @@ -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; diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs index 419a9a20..d9732370 100644 --- a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs +++ b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs @@ -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), diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs new file mode 100644 index 00000000..15a5890c --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/McpQuotaEnforcementMiddleware.cs @@ -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; + +/// +/// Enforces the per-user MCP daily/monthly request quota. Runs after +/// (per-minute burst control) — this is the budget layer on +/// top. Applies ONLY to HTTP requests marked X-Client-Source: mcp with an authenticated +/// principal (oid); everything else passes through untouched. Global Admins are exempt. +/// +/// Over-quota requests get 429 with a structured body, Retry-After, and +/// X-MCP-Quota-* 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 +/// ), fail-closed on plan resolution (unknown plan → Community). +/// +public class McpQuotaEnforcementMiddleware : IFunctionsWorkerMiddleware +{ + private readonly McpQuotaService _quotaService; + private readonly ILogger _logger; + + public McpQuotaEnforcementMiddleware( + McpQuotaService quotaService, + ILogger 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(); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs index 6a2f8096..1e9fd71e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/PolicyEnforcementMiddleware.cs @@ -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). /// private async Task 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", diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/UserRateLimitMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/UserRateLimitMiddleware.cs index 74a6b9e3..af839482 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/UserRateLimitMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/UserRateLimitMiddleware.cs @@ -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 _logger; public UserRateLimitMiddleware( RateLimitService rateLimitService, AdminConfigurationService adminConfigService, TenantConfigurationService tenantConfigService, + TenantEntitlementService entitlementService, ILogger 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); diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 97a616f3..fd11485a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -41,6 +41,10 @@ builder.UseMiddleware(); builder.UseMiddleware(); builder.UseMiddleware(); builder.UseMiddleware(); +// 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(); // 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(); @@ -214,6 +218,8 @@ builder.Services.AddHostedService(); // Initialize all builder.Services.AddTableStorageDataAccess(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 4865d0cc..50b60db2 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -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), diff --git a/src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs new file mode 100644 index 00000000..5cce156d --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs @@ -0,0 +1,119 @@ +using System; + +namespace AutopilotMonitor.Functions.Security +{ + /// + /// 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. + /// + public enum TenantEdition + { + Community, + Enterprise + } + + /// + /// The static per-edition entitlement values. Immutable code catalog — analogous to + /// : 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. + /// + public sealed class EditionEntitlements + { + public TenantEdition Edition { get; init; } + + /// Maximum data retention a tenant admin may configure (days). + public int RetentionCapDays { get; init; } + + /// + /// 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. + /// + public int? UserRateLimitPerMinute { get; init; } + + /// + /// Entitlement floor for the per-device (agent/cert) rate limit, requests/minute. + /// Same semantics as . + /// + public int? DeviceRateLimitPerMinute { get; init; } + + /// + /// 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. + /// + public bool DelegatedAdminAllowed { get; init; } + + /// Default MCP usage plan name for users of this tenant (per-user override wins). + public string McpUsagePlanName { get; init; } = string.Empty; + + /// Fallback MCP daily request limit when no SectionUsagePlans row matches the plan name. + public int McpDailyRequestLimit { get; init; } + + /// Fallback MCP monthly request limit when no SectionUsagePlans row matches the plan name. + public int McpMonthlyRequestLimit { get; init; } + } + + /// + /// 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). + /// + public static class FeatureEntitlementCatalog + { + /// Write-side canonical tier names (the only values the plan endpoint accepts). + 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 + }; + + /// + /// 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 is already over (strict >). + /// + 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; + } + + /// Returns the entitlement set for an edition. Unknown enum values → Community (fail-closed). + public static EditionEntitlements Get(TenantEdition edition) => + edition == TenantEdition.Enterprise ? Enterprise : Community; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs index ce2d36a6..e1cc5ae0 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs @@ -72,17 +72,24 @@ namespace AutopilotMonitor.Functions.Security /// /// 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. /// private async Task 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); } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs b/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs index 0bed49cd..a8f3fa1a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs @@ -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 tid-agnostic — identical to : -/// 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 ), +/// 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. /// public class DelegatedAdminService { private readonly IAdminRepository _adminRepo; + private readonly TenantEntitlementService _entitlementService; private readonly IMemoryCache _cache; private readonly ILogger _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 logger) { _adminRepo = adminRepo; + _entitlementService = entitlementService; _cache = cache; _logger = logger; } @@ -43,8 +48,18 @@ public class DelegatedAdminService /// tenant. Only + 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. + /// + /// Entitlement gate (single choke-point for ALL delegated-scope consumers): delegated ("MSP") + /// access is an Enterprise capability of the caller's HOME tenant — the tenant that pays for the MSP + /// seat ( = 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. + /// /// - public virtual async Task GetScopeAsync(string? upn) + public virtual async Task 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(cacheKey, out var cached) && cached != null) - return cached; + return await ApplyHomeTenantGateAsync(cached, upn, callerHomeTenantId); var rows = await _adminRepo.GetDelegatedTenantsAsync(upn); var tenantRoles = new Dictionary(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); + } + + /// + /// 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. + /// + private async Task 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; } /// Creates or replaces an assignment, then invalidates the UPN's cached scope. diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRetentionFanoutService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRetentionFanoutService.cs index 55fb02ab..f14923d9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRetentionFanoutService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Deletion/SessionRetentionFanoutService.cs @@ -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) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/McpQuotaService.cs b/src/Backend/AutopilotMonitor.Functions/Services/McpQuotaService.cs new file mode 100644 index 00000000..7d84a2d7 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/McpQuotaService.cs @@ -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 +{ + /// + /// 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. + /// + 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 _logger; + private readonly TimeProvider _time; + + public McpQuotaService( + IUserUsageRepository usageRepo, + McpUserService mcpUserService, + AdminConfigurationService adminConfigService, + TenantEntitlementService entitlementService, + IMemoryCache cache, + ILogger logger) + : this(usageRepo, mcpUserService, adminConfigService, entitlementService, cache, logger, TimeProvider.System) + { + } + + /// Test seam — inject a fake for deterministic window math. + public McpQuotaService( + IUserUsageRepository usageRepo, + McpUserService mcpUserService, + AdminConfigurationService adminConfigService, + TenantEntitlementService entitlementService, + IMemoryCache cache, + ILogger logger, + TimeProvider time) + { + _usageRepo = usageRepo; + _mcpUserService = mcpUserService; + _adminConfigService = adminConfigService; + _entitlementService = entitlementService; + _cache = cache; + _logger = logger; + _time = time; + } + + /// + /// 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). + /// + public virtual async Task CheckAsync(string oid, string? upn, string? tenantId) + { + var cacheKey = $"mcp-quota:{oid}"; + if (_cache.TryGetValue(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; + } + + /// + /// Plan resolution only (no counter read) — used by the self-service usage endpoint. + /// + 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 + }; + } + } + + /// Outcome of an MCP quota check. + public sealed class McpQuotaDecision + { + public bool Allowed { get; init; } + public string Plan { get; init; } = string.Empty; + /// Which window was exceeded ("daily"/"monthly"), null when allowed. + 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; } + /// When the exceeded (or daily, when allowed) window resets. + 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 + }; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs b/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs index df09f3c9..ed0b3939 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/McpUserService.cs @@ -49,8 +49,11 @@ public class McpUserService /// /// Checks if a user is allowed to access the MCP server based on current policy. + /// 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. /// - public virtual async Task IsAllowedAsync(string? upn) + public virtual async Task 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= (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); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs index 032a1fbd..54fae30f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/OpsEventService.cs @@ -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. + + /// Heads-up: an Enterprise trial ends within the next few days. Info-tier visibility signal. + 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 }); + } + + /// + /// 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. + /// + 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) diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RateLimitResolver.cs b/src/Backend/AutopilotMonitor.Functions/Services/RateLimitResolver.cs index b083a0f6..3720c8ec 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/RateLimitResolver.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/RateLimitResolver.cs @@ -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 () and the user path /// (UserRateLimitMiddleware) resolve through here so the precedence stays consistent. + /// + /// Edition entitlement floor (FeatureEntitlementCatalog): 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). /// public static class RateLimitResolver { /// - /// 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). /// - 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); /// /// 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. /// - 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; } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs new file mode 100644 index 00000000..93721939 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs @@ -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 +{ + /// + /// Resolves a tenant's effective edition (Community/Enterprise) and its entitlements at read + /// time. Rides on '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. + /// + public class TenantEntitlementService + { + private readonly TenantConfigurationService _configService; + private readonly ILogger _logger; + private readonly TimeProvider _time; + + public TenantEntitlementService( + TenantConfigurationService configService, + ILogger logger) + : this(configService, logger, TimeProvider.System) + { + } + + /// Test seam — inject a fake for deterministic trial math. + public TenantEntitlementService( + TenantConfigurationService configService, + ILogger logger, + TimeProvider time) + { + _configService = configService; + _logger = logger; + _time = time; + } + + /// + /// Resolves the tenant's effective edition. Uses the strict point-read + /// () so an + /// entitlement check can never materialize a tenant-config row. No row / any error → + /// Community (fail-closed). + /// + public virtual async Task 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; + } + } + + /// Resolves the tenant's effective entitlement set (fail-closed → Community values). + public virtual async Task GetEntitlementsAsync(string? tenantId) + => FeatureEntitlementCatalog.Get(await GetEditionAsync(tenantId)); + + /// Pure edition resolution for callers that already hold the config. + public static TenantEdition ResolveEdition(TenantConfiguration config, DateTime nowUtc) + => FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc); + + /// + /// The retention days the platform actually enforces for this config: the stored value + /// clamped to the edition's cap. days <= 0 is the GA-only "infinite" escape hatch + /// and is passed through unclamped (the fanout skips those tenants entirely). + /// + 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); + } + } +} diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts index d4feeb5e..c9b56d7b 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts @@ -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 = new Set([ - 'tenantId', 'domainName', 'planTier', 'disabled', 'disabledReason', + 'tenantId', 'domainName', 'planTier', 'trialExpiresUtc', 'trialConsumed', + 'disabled', 'disabledReason', 'onboardedAt', 'onboardedBy', 'lastUpdated', 'dataRetentionDays', ]); diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/PlanTierDefinitionParser.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/PlanTierDefinitionParser.cs new file mode 100644 index 00000000..49fcdfdb --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/PlanTierDefinitionParser.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Text.Json; + +namespace AutopilotMonitor.Shared.Models.Config +{ + /// + /// Parses 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). + /// + public static class PlanTierDefinitionParser + { + private static readonly JsonSerializerOptions Options = new() { PropertyNameCaseInsensitive = true }; + + public static List Parse(string? json) + { + if (string.IsNullOrWhiteSpace(json)) + return new List(); + + try + { + return JsonSerializer.Deserialize>(json, Options) + ?? new List(); + } + catch + { + return new List(); + } + } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index 4d02811f..cf0722db 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -93,11 +93,36 @@ namespace AutopilotMonitor.Shared.Models /// /// 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. /// public string PlanTier { get; set; } = "free"; + /// + /// End of the tenant's Enterprise trial (UTC). While this is in the future the tenant's + /// effective edition is Enterprise regardless of . Null = no trial. + /// Expiry degrades the tenant to Community at read time — no timer involved. + /// + public DateTime? TrialExpiresUtc { get; set; } + + /// + /// When the tenant's Enterprise trial was started (UTC). Informational/audit only. + /// + public DateTime? TrialStartedUtc { get; set; } + + /// + /// 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). + /// + public bool TrialConsumed { get; set; } + + /// + /// Who granted/started the trial (UPN of the self-service caller or the Global Admin). + /// + public string? TrialGrantedBy { get; set; } + /// /// Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*") /// Comma-separated list diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx index 234b5891..921415f7 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsAlertRulesSection.tsx @@ -77,7 +77,17 @@ const OPS_EVENT_TYPES: Record = { // 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"], }; diff --git a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx index 8d42e204..f610736b 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx @@ -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(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({ + {/* Plan & Trial (own save path — PATCH plan endpoint; the modal's generic Save + does not touch these fields, the backend preserves them on PUT) */} +
+
+

Plan & Trial

+ {(() => { + const isEnterpriseTier = editingTenant.planTier === "enterprise"; + const trialActive = !!editingTenant.trialExpiresUtc && + new Date(editingTenant.trialExpiresUtc).getTime() > Date.now(); + const effective = isEnterpriseTier || trialActive ? "Enterprise" : "Community"; + return ( + + Effective: {effective}{!isEnterpriseTier && trialActive ? " (Trial)" : ""} + + ); + })()} +
+
+
+ + + {editingTenant.planTier && editingTenant.planTier !== "enterprise" && editingTenant.planTier !== "community" && ( +

+ Stored legacy tier "{editingTenant.planTier}" resolves to Community. Saving normalizes it. +

+ )} +
+
+ +
+ 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 && ( + + )} +
+

+ Set a date to grant/extend an Enterprise trial; clear to end it. Saving does not reset trial consumption. +

+
+
+ + {editingTenant.trialConsumed + ? "Self-service trial: already consumed (re-grants only via this panel)." + : "Self-service trial: still available to the tenant."} + + +
+
+
+ {/* Admin Users Info */} {editingTenant.dataRetentionDays === 0 ? (

⚠ Infinite retention — data will never be automatically deleted

- ) : (editingTenant.dataRetentionDays < 7 || editingTenant.dataRetentionDays > 180) ? ( -

⚠ Outside tenant range (7–180) — field will be locked for tenant admins

+ ) : (editingTenant.dataRetentionDays < 7 || editingTenant.dataRetentionDays > 365) ? ( +

⚠ Outside tenant range (7–365) — field will be locked for tenant admins

) : ( -

Tenant range: 7–180. Set 0 for infinite retention. No upper limit for Global.

+

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

)} diff --git a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx index d5871b34..e8c85440 100644 --- a/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/tenants/sections/SectionTenantConfigReport.tsx @@ -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() { - + Date.now() ? ` (trial until ${formatDate(config.trialExpiresUtc)})` : ''}`} + configKey="planTier" + defaults={DEFAULTS} + />
diff --git a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx index 4483347c..93dfb895 100644 --- a/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/TenantConfigContext.tsx @@ -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; + // 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(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 => { + 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, diff --git a/src/Web/autopilot-monitor-web/app/settings/components/DataManagementSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/DataManagementSection.tsx index 7d4d1e82..71921d40 100644 --- a/src/Web/autopilot-monitor-web/app/settings/components/DataManagementSection.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/components/DataManagementSection.tsx @@ -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; 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({ setDataRetentionDays(parseInt(e.target.value) || 90)} @@ -67,12 +70,14 @@ export default function DataManagementSection({ Overridden by administrator —{" "} {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.

) : ( -

Minimum: 7 days, Maximum: 180 days

+

+ Minimum: 7 days, Maximum: {retentionCapDays} days{retentionCapDays < 365 ? " (up to 365 with Enterprise)" : ""} +

)} diff --git a/src/Web/autopilot-monitor-web/app/settings/components/EditionBadge.tsx b/src/Web/autopilot-monitor-web/app/settings/components/EditionBadge.tsx new file mode 100644 index 00000000..41ee4062 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/settings/components/EditionBadge.tsx @@ -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 ( +
+ + {label} + + + {canStartTrial && !confirming && ( + + )} + + {canStartTrial && confirming && ( + + One-time trial — start now? + + + + )} +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/settings/management/sections/SectionDataManagement.tsx b/src/Web/autopilot-monitor-web/app/settings/management/sections/SectionDataManagement.tsx index fefa8d58..6a946a15 100644 --- a/src/Web/autopilot-monitor-web/app/settings/management/sections/SectionDataManagement.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/management/sections/SectionDataManagement.tsx @@ -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"} diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/TenantSidebar.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/TenantSidebar.tsx index d38c0120..8088ae6f 100644 --- a/src/Web/autopilot-monitor-web/app/settings/tenant/TenantSidebar.tsx +++ b/src/Web/autopilot-monitor-web/app/settings/tenant/TenantSidebar.tsx @@ -1,12 +1,17 @@ "use client"; +import EditionBadge from "../components/EditionBadge"; + export function TenantSidebar({ children }: { children: React.ReactNode }) { return ( <>
-
-

Tenant Configuration

-

Validation, notifications, and access management

+
+
+

Tenant Configuration

+

Validation, notifications, and access management

+
+
diff --git a/src/Web/autopilot-monitor-web/app/settings/types.ts b/src/Web/autopilot-monitor-web/app/settings/types.ts index e457a37c..e5f81317 100644 --- a/src/Web/autopilot-monitor-web/app/settings/types.ts +++ b/src/Web/autopilot-monitor-web/app/settings/types.ts @@ -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 { diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/edition.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/edition.test.ts new file mode 100644 index 00000000..8f19be37 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/edition.test.ts @@ -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"); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index 641e47b2..7692dafe 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -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) => diff --git a/src/Web/autopilot-monitor-web/lib/edition.ts b/src/Web/autopilot-monitor-web/lib/edition.ts new file mode 100644 index 00000000..7699214c --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/edition.ts @@ -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; + const edition: TenantEditionName = f.edition === "enterprise" ? "enterprise" : "community"; + const ent = (f.entitlements && typeof f.entitlements === "object" + ? (f.entitlements as Record) + : {}) as Record; + 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`; +}