diff --git a/internal b/internal
index 81f3494c..e3958259 160000
--- a/internal
+++ b/internal
@@ -1 +1 @@
-Subproject commit 81f3494cff512044a709773c344bc09ab73832d0
+Subproject commit e39582590cc5b1031c403b60e28dd9cae1bb0857
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs
index 8e54e776..ca883f67 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/ConfigWireParityTests.cs
@@ -476,11 +476,13 @@ public class ConfigWireParityTests
trialExpiresUtc,
trialConsumed = true,
effectiveEdition = "pro",
+ editionSource = "plan",
retentionGraceEndsUtc,
maxDelegatedTenants = 5,
maxDelegatedTenantsOverride = 5,
mcpUsagePlan = "msp",
- mcpUsagePlanOverride = "msp"
+ mcpUsagePlanOverride = "msp",
+ payingCustomer = true
},
new SetTenantPlanTierResponse
{
@@ -489,11 +491,13 @@ public class ConfigWireParityTests
TrialExpiresUtc = trialExpiresUtc,
TrialConsumed = true,
EffectiveEdition = "pro",
+ EditionSource = "plan",
RetentionGraceEndsUtc = retentionGraceEndsUtc,
MaxDelegatedTenants = 5,
MaxDelegatedTenantsOverride = 5,
McpUsagePlan = "msp",
- McpUsagePlanOverride = "msp"
+ McpUsagePlanOverride = "msp",
+ PayingCustomer = true
});
}
@@ -514,11 +518,13 @@ public class ConfigWireParityTests
trialExpiresUtc,
trialConsumed = false,
effectiveEdition = "community",
+ editionSource = "community",
retentionGraceEndsUtc,
maxDelegatedTenants = 0,
maxDelegatedTenantsOverride,
mcpUsagePlan = "community",
- mcpUsagePlanOverride
+ mcpUsagePlanOverride,
+ payingCustomer = false
},
new SetTenantPlanTierResponse
{
@@ -527,11 +533,13 @@ public class ConfigWireParityTests
TrialExpiresUtc = null,
TrialConsumed = false,
EffectiveEdition = "community",
+ EditionSource = "community",
RetentionGraceEndsUtc = null,
MaxDelegatedTenants = 0,
MaxDelegatedTenantsOverride = null,
McpUsagePlan = "community",
- McpUsagePlanOverride = null
+ McpUsagePlanOverride = null,
+ PayingCustomer = false
});
}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminEditionGateTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminEditionGateTests.cs
index 22abc8d3..d06e3be8 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminEditionGateTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/DelegatedAdminEditionGateTests.cs
@@ -67,6 +67,32 @@ public class DelegatedAdminEditionGateTests
Assert.True(scope.Covers(ManagedTenantB));
}
+ private const string ConferredProHomeTenant = "66666666-6666-6666-6666-666666666666";
+
+ [Fact]
+ public async Task ConferredProHomeTenant_SuppressesEntireScope_NoTransitiveDelegation()
+ {
+ // The home tenant is Pro only because ANOTHER tenant manages it: the gate is the delegation
+ // ENTITLEMENT (absent from conferred Pro), not the edition.
+ var repo = new Mock();
+ repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny())).ReturnsAsync(new List { Row(ManagedTenantA) });
+ repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny())).ReturnsAsync(new List());
+ var svc = new DelegatedAdminService(
+ repo.Object,
+ new StubAdminIdentityBindingService(bound: true),
+ new StubTenantEntitlementService(
+ tenantId => tenantId == ConferredProHomeTenant
+ ? new EditionResolution(TenantEdition.Pro, EditionSource.Msp, OwnPro: false)
+ : new EditionResolution(TenantEdition.Community, EditionSource.Community, OwnPro: false),
+ planOverrideResolver: null),
+ new MemoryCache(new MemoryCacheOptions()),
+ NullLogger.Instance);
+
+ var scope = await svc.GetScopeAsync(Id(ConferredProHomeTenant));
+
+ Assert.True(scope.IsEmpty);
+ }
+
[Fact]
public async Task CommunityHomeTenant_SuppressesEntireScope()
{
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/DelegationSelfServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/DelegationSelfServiceTests.cs
index 99921272..a34c9b5d 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/DelegationSelfServiceTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/DelegationSelfServiceTests.cs
@@ -42,6 +42,7 @@ public class DelegationSelfServiceTests
public required FakeSignalRNotificationService SignalR { get; init; }
public required List Rows { get; init; }
public required List GroupTenants { get; init; }
+ public required Mock ProConferral { get; init; }
}
private static Harness Build(TenantEdition homeEdition = TenantEdition.Pro, int? slotOverride = null)
@@ -111,9 +112,66 @@ public class DelegationSelfServiceTests
.ReturnsAsync(true);
var signalR = new FakeSignalRNotificationService();
+ // Conferred Pro is the projection's business — here we only pin that the accept/end paths notify it.
+ var proConferral = new Mock(
+ repo.Object,
+ new TenantConfigurationService(configRepo.Object, NullLogger.Instance, cache),
+ ManagedTenantProIndex.None,
+ NullLogger.Instance) { CallBase = false };
+
var svc = new DelegationSelfService(repo.Object, invitations.Object, delegatedAdmins, slots, entitlements, configRepo.Object, audit.Object, signalR,
- NullLogger.Instance, time);
- return new Harness { Svc = svc, Repo = repo, Invitations = invitations, Audit = audit, SignalR = signalR, Rows = rows, GroupTenants = groupTenants };
+ proConferral.Object, NullLogger.Instance, time);
+ return new Harness { Svc = svc, Repo = repo, Invitations = invitations, Audit = audit, SignalR = signalR, Rows = rows, GroupTenants = groupTenants, ProConferral = proConferral };
+ }
+
+ // ── Conferred Pro hooks ───────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Accept_NotifiesConferredProProjection_ForTheCustomer()
+ {
+ var h = Build();
+ h.Rows.Add(Pending());
+
+ var r = await h.Svc.AcceptAsync(Token(), Customer, CustomerAdmin);
+
+ Assert.True(r.Ok);
+ h.ProConferral.Verify(p => p.NotifyDelegationChangedAsync(Customer), Times.Once);
+ h.ProConferral.Verify(p => p.RecordLossAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Fact]
+ public async Task RemoveManaged_RecordsConferredProLoss_WithTheAuditReason()
+ {
+ var h = Build();
+ h.GroupTenants.Add(Customer);
+
+ var r = await h.Svc.RemoveManagedAsync(Home, Customer, MspAdmin);
+
+ Assert.True(r.Ok);
+ h.ProConferral.Verify(p => p.RecordLossAsync(Customer, Home, "customer-removed-by-manager"), Times.Once);
+ }
+
+ [Fact]
+ public async Task RevokeManager_RecordsConferredProLoss_WithTheAuditReason()
+ {
+ var h = Build();
+ h.GroupTenants.Add(Customer);
+
+ var r = await h.Svc.RevokeManagerAsync(Customer, Home, CustomerAdmin);
+
+ Assert.True(r.Ok);
+ h.ProConferral.Verify(p => p.RecordLossAsync(Customer, Home, "customer-revoked"), Times.Once);
+ }
+
+ [Fact]
+ public async Task RemoveManaged_NotAMember_RecordsNoLoss()
+ {
+ var h = Build();
+
+ var r = await h.Svc.RemoveManagedAsync(Home, Customer, MspAdmin);
+
+ Assert.False(r.Ok);
+ h.ProConferral.Verify(p => p.RecordLossAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never);
}
private static DelegationInvitation Pending(string id = "inv1", DateTime? expires = null) => new()
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/FeatureEntitlementCatalogTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/FeatureEntitlementCatalogTests.cs
index cf5714fb..ed47ae38 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/FeatureEntitlementCatalogTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/FeatureEntitlementCatalogTests.cs
@@ -27,7 +27,10 @@ public class FeatureEntitlementCatalogTests
[InlineData(" enterprise ")]
public void ResolveEdition_ProOrLegacyEnterpriseTier_IsPro(string tier)
{
- Assert.Equal(TenantEdition.Pro, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
+ var r = FeatureEntitlementCatalog.Resolve(tier, null, null, Now);
+ Assert.Equal(TenantEdition.Pro, r.Edition);
+ Assert.Equal(EditionSource.Plan, r.Source);
+ Assert.True(r.OwnPro);
}
[Theory]
@@ -38,14 +41,20 @@ public class FeatureEntitlementCatalogTests
[InlineData("premium")] // unknown value → fail-closed
public void ResolveEdition_NonProTier_IsCommunity(string? tier)
{
- Assert.Equal(TenantEdition.Community, FeatureEntitlementCatalog.ResolveEdition(tier, null, Now));
+ var r = FeatureEntitlementCatalog.Resolve(tier, null, null, Now);
+ Assert.Equal(TenantEdition.Community, r.Edition);
+ Assert.Equal(EditionSource.Community, r.Source);
+ Assert.False(r.OwnPro);
}
[Fact]
public void ResolveEdition_ActiveTrial_IsPro_EvenOnFreeTier()
{
- Assert.Equal(TenantEdition.Pro,
- FeatureEntitlementCatalog.ResolveEdition("free", Now.AddSeconds(1), Now));
+ var r = FeatureEntitlementCatalog.Resolve("free", Now.AddSeconds(1), null, Now);
+ Assert.Equal(TenantEdition.Pro, r.Edition);
+ Assert.Equal(EditionSource.Trial, r.Source);
+ Assert.True(r.IsTrial);
+ Assert.True(r.OwnPro);
}
[Fact]
@@ -53,14 +62,14 @@ public class FeatureEntitlementCatalogTests
{
// Strict '>' — a trial ending exactly now is already over.
Assert.Equal(TenantEdition.Community,
- FeatureEntitlementCatalog.ResolveEdition("free", Now, Now));
+ FeatureEntitlementCatalog.Resolve("free", Now, null, Now).Edition);
}
[Fact]
public void ResolveEdition_ExpiredTrial_IsCommunity()
{
Assert.Equal(TenantEdition.Community,
- FeatureEntitlementCatalog.ResolveEdition(null, Now.AddDays(-1), Now));
+ FeatureEntitlementCatalog.Resolve(null, Now.AddDays(-1), null, Now).Edition);
}
[Theory]
@@ -68,8 +77,102 @@ public class FeatureEntitlementCatalogTests
[InlineData("enterprise")]
public void ResolveEdition_ExpiredTrial_ButPermanentProTier_StaysPro(string tier)
{
- Assert.Equal(TenantEdition.Pro,
- FeatureEntitlementCatalog.ResolveEdition(tier, Now.AddDays(-1), Now));
+ var r = FeatureEntitlementCatalog.Resolve(tier, Now.AddDays(-1), null, Now);
+ Assert.Equal(TenantEdition.Pro, r.Edition);
+ Assert.Equal(EditionSource.Plan, r.Source); // permanent tier beats the stale trial timestamp
+ }
+
+ // ── Conferred Pro ("Pro (MSP)") ─────────────────────────────────────────────
+
+ private const string ManagingTenant = "11111111-1111-1111-1111-111111111111";
+
+ [Theory]
+ [InlineData("free")]
+ [InlineData("community")]
+ [InlineData(null)]
+ public void Resolve_ManagedByProTenant_IsPro_SourceMsp_NotOwnPro(string? tier)
+ {
+ var r = FeatureEntitlementCatalog.Resolve(tier, null, ManagingTenant, Now);
+ Assert.Equal(TenantEdition.Pro, r.Edition);
+ Assert.Equal(EditionSource.Msp, r.Source);
+ Assert.True(r.IsViaMsp);
+ Assert.False(r.IsTrial);
+ Assert.False(r.OwnPro);
+ Assert.Equal("msp", r.SourceName);
+ Assert.Equal("pro", r.EditionName);
+ }
+
+ [Theory]
+ [InlineData("pro", null)]
+ [InlineData("enterprise", null)]
+ [InlineData("community", 1)] // active trial
+ public void Resolve_ManagedTenant_ThatIsProItself_ShowsMsp_KeepsOwnPro(string tier, int? trialDaysLeft)
+ {
+ // Display precedence: MSP wins even over the tenant's own Pro (badge "Pro (MSP)"); the own
+ // standing survives in OwnPro so the entitlement union keeps the delegation right.
+ var r = FeatureEntitlementCatalog.Resolve(tier, trialDaysLeft is int d ? Now.AddDays(d) : null, ManagingTenant, Now);
+ Assert.Equal(TenantEdition.Pro, r.Edition);
+ Assert.Equal(EditionSource.Msp, r.Source);
+ Assert.True(r.OwnPro);
+ Assert.False(r.IsTrial);
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Resolve_BlankManagedBy_IsNotManaged(string managedBy)
+ {
+ var r = FeatureEntitlementCatalog.Resolve("community", null, managedBy, Now);
+ Assert.Equal(TenantEdition.Community, r.Edition);
+ Assert.Equal(EditionSource.Community, r.Source);
+ }
+
+ [Fact]
+ public void Resolve_FromConfig_ReadsTheProjection()
+ {
+ var config = new AutopilotMonitor.Shared.Models.TenantConfiguration
+ {
+ TenantId = "t", PlanTier = "community", ManagedByProTenantId = ManagingTenant,
+ };
+ Assert.Equal(EditionSource.Msp, FeatureEntitlementCatalog.Resolve(config, Now).Source);
+ Assert.Equal(TenantEdition.Pro, FeatureEntitlementCatalog.ResolveEdition(config, Now));
+ }
+
+ [Fact]
+ public void Get_ConferredPro_IsProWithoutTheDelegationRight()
+ {
+ var conferred = FeatureEntitlementCatalog.Get(new EditionResolution(TenantEdition.Pro, EditionSource.Msp, OwnPro: false));
+ var pro = FeatureEntitlementCatalog.Get(TenantEdition.Pro);
+
+ Assert.Equal(TenantEdition.Pro, conferred.Edition);
+ Assert.False(conferred.DelegatedAdminAllowed);
+ Assert.Equal(0, conferred.MaxDelegatedTenants);
+ // Everything else is the Pro value.
+ Assert.Equal(pro.RetentionCapDays, conferred.RetentionCapDays);
+ Assert.Equal(pro.UserRateLimitPerMinute, conferred.UserRateLimitPerMinute);
+ Assert.Equal(pro.DeviceRateLimitPerMinute, conferred.DeviceRateLimitPerMinute);
+ Assert.Equal(pro.BootstrapIncluded, conferred.BootstrapIncluded);
+ Assert.Equal(pro.UnrestrictedModeAvailable, conferred.UnrestrictedModeAvailable);
+ Assert.Equal(pro.McpUsagePlanName, conferred.McpUsagePlanName);
+ Assert.Equal(pro.McpDailyRequestLimit, conferred.McpDailyRequestLimit);
+ Assert.Equal(pro.McpMonthlyRequestLimit, conferred.McpMonthlyRequestLimit);
+ Assert.Equal(pro.McpTenantDailyRequestLimit, conferred.McpTenantDailyRequestLimit);
+ Assert.Equal(pro.McpTenantMonthlyRequestLimit, conferred.McpTenantMonthlyRequestLimit);
+ }
+
+ [Fact]
+ public void Get_ManagedTenantThatIsProItself_KeepsTheFullProSet()
+ {
+ var e = FeatureEntitlementCatalog.Get(new EditionResolution(TenantEdition.Pro, EditionSource.Msp, OwnPro: true));
+ Assert.True(e.DelegatedAdminAllowed);
+ Assert.Equal(2, e.MaxDelegatedTenants);
+ }
+
+ [Fact]
+ public void Get_CommunityResolution_IsCommunity_RegardlessOfOwnProFlag()
+ {
+ var e = FeatureEntitlementCatalog.Get(new EditionResolution(TenantEdition.Community, EditionSource.Community, OwnPro: true));
+ Assert.Equal(TenantEdition.Community, e.Edition);
}
// ── IsPermanentProTier ───────────────────────────────────────────────────────
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs
index ca4bc82f..c9ce595d 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/GetTenantFeatureFlagsPayloadTests.cs
@@ -46,6 +46,9 @@ public class GetTenantFeatureFlagsPayloadTests
"contactEmailSet",
"diagnosticsUploadConfigured",
"edition",
+ // Non-sensitive by review: one of "community" | "plan" | "trial" | "msp" — says WHY the
+ // edition applies (the "Pro (MSP)" badge), never who the managing tenant is.
+ "editionSource",
"enableIntegrityBypassAnalyzer",
"enableSoftwareInventoryAnalyzer",
"entitlements",
@@ -266,11 +269,59 @@ public class GetTenantFeatureFlagsPayloadTests
var element = Serialize(new TenantConfiguration { TrialExpiresUtc = expiry, TrialConsumed = true });
Assert.Equal("pro", element.GetProperty("edition").GetString());
+ Assert.Equal("trial", element.GetProperty("editionSource").GetString());
Assert.True(element.GetProperty("isTrial").GetBoolean());
Assert.False(element.GetProperty("trialAvailable").GetBoolean());
Assert.Equal(expiry, element.GetProperty("trialExpiresUtc").GetDateTime());
}
+ [Fact]
+ public void Payload_EditionSource_Plan_And_Community()
+ {
+ Assert.Equal("plan", Serialize(new TenantConfiguration { PlanTier = "pro" }).GetProperty("editionSource").GetString());
+ Assert.Equal("community", Serialize(new TenantConfiguration()).GetProperty("editionSource").GetString());
+ }
+
+ [Fact]
+ public void Payload_ManagedByProTenant_IsProViaMsp_WithoutDelegationRight_NoTrialCta()
+ {
+ // The projection (set by the configuration loader) turns a Community tenant into "Pro (MSP)":
+ // Pro entitlements minus delegation, no trial CTA, never "isTrial", no expiry key — and the
+ // managing tenant's id itself is NOT part of this member-readable payload.
+ var element = Serialize(new TenantConfiguration
+ {
+ PlanTier = "community",
+ ManagedByProTenantId = "11111111-1111-1111-1111-111111111111",
+ });
+
+ Assert.Equal("pro", element.GetProperty("edition").GetString());
+ Assert.Equal("msp", element.GetProperty("editionSource").GetString());
+ Assert.False(element.GetProperty("isTrial").GetBoolean());
+ Assert.False(element.GetProperty("trialAvailable").GetBoolean());
+ Assert.False(element.TryGetProperty("trialExpiresUtc", out _));
+ Assert.True(element.GetProperty("bootstrapTokenEnabled").GetBoolean());
+ var entitlements = element.GetProperty("entitlements");
+ Assert.Equal(365, entitlements.GetProperty("retentionCapDays").GetInt32());
+ Assert.False(entitlements.GetProperty("delegatedAdminAllowed").GetBoolean());
+ Assert.Equal(0, entitlements.GetProperty("maxDelegatedTenants").GetInt32());
+ Assert.Equal("pro", entitlements.GetProperty("mcpUsagePlan").GetString());
+ Assert.DoesNotContain("11111111-1111-1111-1111-111111111111", element.GetRawText());
+ }
+
+ [Fact]
+ public void Payload_ManagedTenant_ThatIsProItself_ShowsMsp_KeepsDelegationRight()
+ {
+ var element = Serialize(new TenantConfiguration
+ {
+ PlanTier = "pro",
+ ManagedByProTenantId = "11111111-1111-1111-1111-111111111111",
+ });
+
+ Assert.Equal("msp", element.GetProperty("editionSource").GetString());
+ Assert.True(element.GetProperty("entitlements").GetProperty("delegatedAdminAllowed").GetBoolean());
+ Assert.Equal(2, element.GetProperty("entitlements").GetProperty("maxDelegatedTenants").GetInt32());
+ }
+
[Fact]
public void Payload_ExpiredTrial_DegradesToCommunity_NoTrialAvailable()
{
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ManagedTenantProIndexTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ManagedTenantProIndexTests.cs
new file mode 100644
index 00000000..84654541
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/ManagedTenantProIndexTests.cs
@@ -0,0 +1,182 @@
+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;
+
+///
+/// Pins the reverse index behind conferred Pro (): only OWNED groups
+/// of a PERMANENT-Pro owner confer; a trial owner and operator-created groups never do; the owner's tier is
+/// read raw (two tenants managing each other resolve without recursion); storage failure yields "nobody is
+/// managed" (fail-closed) without throwing; Invalidate forces a rebuild.
+///
+public sealed class ManagedTenantProIndexTests
+{
+ private const string Msp = "11111111-1111-1111-1111-111111111111";
+ private const string TrialMsp = "22222222-2222-2222-2222-222222222222";
+ private const string CustomerA = "33333333-3333-3333-3333-333333333333";
+ private const string CustomerB = "44444444-4444-4444-4444-444444444444";
+ private const string OperatorManaged = "55555555-5555-5555-5555-555555555555";
+
+ private static TenantGroup Owned(string owner, params string[] tenants) => new()
+ {
+ GroupId = AutopilotMonitor.Shared.Constants.TenantGroupIds.ForHomeTenant(owner),
+ Name = "Customers",
+ OwnerTenantId = owner,
+ TenantIds = tenants.ToList(),
+ };
+
+ private static TenantGroup Operator(params string[] tenants) => new()
+ {
+ GroupId = "operator-group",
+ Name = "Operator bundle",
+ OwnerTenantId = null,
+ TenantIds = tenants.ToList(),
+ };
+
+ private static (ManagedTenantProIndex Index, Mock Admin, Mock Config) Build(
+ IEnumerable groups, IDictionary configs)
+ {
+ var admin = new Mock();
+ admin.Setup(a => a.GetAllTenantGroupsAsync()).ReturnsAsync(groups.ToList());
+ var config = new Mock();
+ config.Setup(c => c.GetTenantConfigurationAsync(It.IsAny()))
+ .ReturnsAsync((string id) => configs.TryGetValue(id, out var cfg) ? cfg : null);
+ var index = new ManagedTenantProIndex(admin.Object, config.Object, new MemoryCache(new MemoryCacheOptions()),
+ NullLogger.Instance);
+ return (index, admin, config);
+ }
+
+ private static TenantConfiguration Tenant(string id, string tier, DateTime? trial = null) =>
+ new() { TenantId = id, PlanTier = tier, TrialExpiresUtc = trial };
+
+ [Fact]
+ public async Task OwnedGroup_OfPermanentProOwner_ConfersOnMembers()
+ {
+ var (index, _, _) = Build(
+ new[] { Owned(Msp, CustomerA, CustomerB) },
+ new Dictionary { [Msp] = Tenant(Msp, "pro") });
+
+ Assert.Equal(Msp, await index.GetConferringOwnerAsync(CustomerA));
+ Assert.Equal(Msp, await index.GetConferringOwnerAsync(CustomerB));
+ Assert.Null(await index.GetConferringOwnerAsync(Msp)); // the owner never manages itself
+ Assert.Null(await index.GetConferringOwnerAsync(OperatorManaged));
+ }
+
+ [Fact]
+ public async Task LegacyEnterpriseTier_CountsAsPermanentPro()
+ {
+ var (index, _, _) = Build(
+ new[] { Owned(Msp, CustomerA) },
+ new Dictionary { [Msp] = Tenant(Msp, "enterprise") });
+
+ Assert.Equal(Msp, await index.GetConferringOwnerAsync(CustomerA));
+ }
+
+ [Fact]
+ public async Task TrialOwner_ConfersNothing()
+ {
+ var (index, _, _) = Build(
+ new[] { Owned(TrialMsp, CustomerA) },
+ new Dictionary
+ {
+ [TrialMsp] = Tenant(TrialMsp, "community", trial: DateTime.UtcNow.AddDays(10)),
+ });
+
+ Assert.Null(await index.GetConferringOwnerAsync(CustomerA));
+ }
+
+ [Fact]
+ public async Task OwnerWithoutConfigRow_ConfersNothing()
+ {
+ var (index, _, _) = Build(new[] { Owned(Msp, CustomerA) }, new Dictionary());
+
+ Assert.Null(await index.GetConferringOwnerAsync(CustomerA));
+ }
+
+ [Fact]
+ public async Task OperatorGroup_NeverConfers_EvenWhenAssigneesAreProHomed()
+ {
+ var (index, _, _) = Build(
+ new[] { Operator(OperatorManaged) },
+ new Dictionary { [Msp] = Tenant(Msp, "pro") });
+
+ Assert.Null(await index.GetConferringOwnerAsync(OperatorManaged));
+ }
+
+ [Fact]
+ public async Task MutualManagement_ResolvesBothWays_WithoutRecursion()
+ {
+ // A manages B and B manages A, both permanent Pro: each owner's tier is one RAW read — the
+ // index never asks "is the owner itself managed", so there is no cycle to fall into.
+ var (index, _, config) = Build(
+ new[] { Owned(Msp, CustomerA), Owned(CustomerA, Msp) },
+ new Dictionary { [Msp] = Tenant(Msp, "pro"), [CustomerA] = Tenant(CustomerA, "pro") });
+
+ Assert.Equal(Msp, await index.GetConferringOwnerAsync(CustomerA));
+ Assert.Equal(CustomerA, await index.GetConferringOwnerAsync(Msp));
+ config.Verify(c => c.GetTenantConfigurationAsync(Msp), Times.Once);
+ config.Verify(c => c.GetTenantConfigurationAsync(CustomerA), Times.Once);
+ }
+
+ [Fact]
+ public async Task TwoProOwners_ResolveDeterministically_ByGroupId()
+ {
+ // GroupIds are "msp-{owner}", so ordinal order of the owner ids decides.
+ var first = "0aaaaaaa-0000-0000-0000-000000000000";
+ var second = "0bbbbbbb-0000-0000-0000-000000000000";
+ var (index, _, _) = Build(
+ new[] { Owned(second, CustomerA), Owned(first, CustomerA) },
+ new Dictionary { [first] = Tenant(first, "pro"), [second] = Tenant(second, "pro") });
+
+ Assert.Equal(first, await index.GetConferringOwnerAsync(CustomerA));
+ }
+
+ [Fact]
+ public async Task IsCachedAcrossLookups_AndInvalidateRebuilds()
+ {
+ var (index, admin, _) = Build(
+ new[] { Owned(Msp, CustomerA) },
+ new Dictionary { [Msp] = Tenant(Msp, "pro") });
+
+ await index.GetConferringOwnerAsync(CustomerA);
+ await index.GetConferringOwnerAsync(CustomerB);
+ admin.Verify(a => a.GetAllTenantGroupsAsync(), Times.Once);
+
+ index.Invalidate();
+ await index.GetConferringOwnerAsync(CustomerA);
+ admin.Verify(a => a.GetAllTenantGroupsAsync(), Times.Exactly(2));
+ }
+
+ [Fact]
+ public async Task StorageFailure_YieldsNobodyManaged_NeverThrows()
+ {
+ var admin = new Mock();
+ admin.Setup(a => a.GetAllTenantGroupsAsync()).ThrowsAsync(new InvalidOperationException("storage down"));
+ var index = new ManagedTenantProIndex(admin.Object, Mock.Of(),
+ new MemoryCache(new MemoryCacheOptions()), NullLogger.Instance);
+
+ Assert.Null(await index.GetConferringOwnerAsync(CustomerA));
+ }
+
+ [Fact]
+ public async Task BlankTenantId_IsNull_WithoutTouchingStorage()
+ {
+ var (index, admin, _) = Build(Array.Empty(), new Dictionary());
+
+ Assert.Null(await index.GetConferringOwnerAsync(null));
+ Assert.Null(await index.GetConferringOwnerAsync(" "));
+ admin.Verify(a => a.GetAllTenantGroupsAsync(), Times.Never);
+ }
+
+ [Fact]
+ public async Task None_ProjectsNobody()
+ {
+ Assert.Null(await ManagedTenantProIndex.None.GetConferringOwnerAsync(CustomerA));
+ ManagedTenantProIndex.None.Invalidate(); // no-op, must not throw
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantCustomsArchiveContractTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantCustomsArchiveContractTests.cs
index 30033a1c..7c664f3a 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantCustomsArchiveContractTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantCustomsArchiveContractTests.cs
@@ -44,6 +44,7 @@ public sealed class TenantCustomsArchiveContractTests
customsArchive: archive,
configRepo: null!,
farewellEmail: new FakeOffboardFarewellEmailSender(),
+ proConferral: null!,
logger: NullLogger.Instance);
}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerDrainContractTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerDrainContractTests.cs
index 6ea6eacd..671d0220 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerDrainContractTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerDrainContractTests.cs
@@ -670,7 +670,8 @@ public class TenantOffboardingHandlerDrainContractTests
IOffboardFarewellEmailSender farewellEmail,
ILogger logger)
: base(auditRepo, enumerator, cascadeEnqueuer, expectations, drainProbe, safeWipe,
- storage, maintenance, reEnqueuer, opsEvents, customsArchive, configRepo, farewellEmail, logger)
+ storage, maintenance, reEnqueuer, opsEvents, customsArchive, configRepo, farewellEmail,
+ TestProConferral.Inert(), logger)
{
}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs
index 3b8de3c3..589ce307 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingHandlerOrderingTests.cs
@@ -486,7 +486,7 @@ public class TenantOffboardingHandlerOrderingTests
TableStorageService s, IMaintenanceRepository m, ITenantOffboardingEnqueuer re,
OpsEventService o, ITenantCustomsArchiveRepository ca, IConfigRepository cr,
IOffboardFarewellEmailSender fe, ILogger log)
- : base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, log) { }
+ : base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, TestProConferral.Inert(), log) { }
internal override Task ArchiveAndWipeRulesTableAsync(
string tableName, string tenantId, string historyRowKey, CancellationToken ct)
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingWorkerPoisonTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingWorkerPoisonTests.cs
index 4c90acbc..b2c83d39 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingWorkerPoisonTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/Offboarding/TenantOffboardingWorkerPoisonTests.cs
@@ -258,7 +258,7 @@ public class TenantOffboardingWorkerPoisonTests
TableStorageService s, IMaintenanceRepository m, ITenantOffboardingEnqueuer re,
OpsEventService o, ITenantCustomsArchiveRepository ca, IConfigRepository cr,
IOffboardFarewellEmailSender fe, ILogger log)
- : base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, log) { }
+ : base(a, e, c, exp, d, sw, s, m, re, o, ca, cr, fe, TestProConferral.Inert(), log) { }
internal override Task ArchiveAndWipeRulesTableAsync(
string tableName, string tenantId, string historyRowKey, CancellationToken ct)
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/PlanManagementTransitionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/PlanManagementTransitionTests.cs
index ac5e3c72..40e3d412 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/PlanManagementTransitionTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/PlanManagementTransitionTests.cs
@@ -40,6 +40,53 @@ public class PlanManagementTransitionTests
Assert.True(changes.ContainsKey("PlanTier"));
}
+ [Fact]
+ public void ManagedTenant_PlanTierDowngrade_IsNotAnEffectiveDowngrade_NoAnchor()
+ {
+ // Conferred Pro keeps the tenant effectively Pro — the plan endpoint sees Pro → Pro, so no
+ // grace anchor and no downgrade event; the anchor is stamped when the delegation ends instead.
+ var config = new TenantConfiguration
+ {
+ TenantId = "t1", PlanTier = "pro", ManagedByProTenantId = "11111111-1111-1111-1111-111111111111",
+ };
+
+ var (before, after, changes) = Apply(config, planTier: "community");
+
+ Assert.Equal(TenantEdition.Pro, before);
+ Assert.Equal(TenantEdition.Pro, after);
+ Assert.Null(config.ProDowngradedUtc);
+ Assert.False(changes.ContainsKey("ProDowngradedUtc"));
+ Assert.Equal("community", config.PlanTier);
+ }
+
+ [Theory]
+ [InlineData(false, true, true)]
+ [InlineData(true, false, true)]
+ [InlineData(true, true, false)]
+ [InlineData(false, false, false)]
+ public void ApplyPayingCustomerChange_RecordsOnlyRealChanges(bool stored, bool requested, bool expectChange)
+ {
+ var config = new TenantConfiguration { TenantId = "t1", PayingCustomer = stored };
+ var changes = new Dictionary();
+
+ PlanManagementFunction.ApplyPayingCustomerChange(config, provided: true, requested, changes);
+
+ Assert.Equal(requested, config.PayingCustomer);
+ Assert.Equal(expectChange, changes.ContainsKey("PayingCustomer"));
+ }
+
+ [Fact]
+ public void ApplyPayingCustomerChange_NotProvided_IsNoOp()
+ {
+ var config = new TenantConfiguration { TenantId = "t1", PayingCustomer = true };
+ var changes = new Dictionary();
+
+ PlanManagementFunction.ApplyPayingCustomerChange(config, provided: false, payingCustomer: false, changes);
+
+ Assert.True(config.PayingCustomer);
+ Assert.Empty(changes);
+ }
+
[Fact]
public void Upgrade_CommunityToPro_ClearsAnchor()
{
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/ProConferralServiceTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/ProConferralServiceTests.cs
new file mode 100644
index 00000000..38c4bd7a
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/ProConferralServiceTests.cs
@@ -0,0 +1,123 @@
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.DataAccess;
+using AutopilotMonitor.Shared.Models;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Pins the write side of conferred Pro (): the ONLY thing ever written is
+/// the retention grace anchor () on the managed tenant —
+/// never a plan tier — and only when the managing tenant was a permanent-Pro tenant (a trial MSP conferred
+/// nothing). Group-wide loss stamps every member except the home tenant, and every path drops the index.
+///
+public sealed class ProConferralServiceTests
+{
+ private const string Home = "11111111-1111-1111-1111-111111111111";
+ private const string CustomerA = "33333333-3333-3333-3333-333333333333";
+ private const string CustomerB = "44444444-4444-4444-4444-444444444444";
+ private static readonly DateTime Now = new(2026, 9, 4, 12, 0, 0, DateTimeKind.Utc);
+
+ private sealed class Harness
+ {
+ public required ProConferralService Svc { get; init; }
+ public required Dictionary Rows { get; init; }
+ public required List<(TenantConfiguration Config, string? Source, string? Reason)> Saves { get; init; }
+ public required StubManagedTenantProIndex Index { get; init; }
+ }
+
+ private static Harness Build(string homeTier, params string[] members)
+ {
+ var rows = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [Home] = new() { TenantId = Home, DomainName = "partner.example", PlanTier = homeTier },
+ [CustomerA] = new() { TenantId = CustomerA, DomainName = "a.example", PlanTier = "community", DataRetentionDays = 365 },
+ [CustomerB] = new() { TenantId = CustomerB, DomainName = "b.example", PlanTier = "pro" },
+ };
+ var saves = new List<(TenantConfiguration, string?, string?)>();
+
+ var configRepo = new Mock();
+ configRepo.Setup(c => c.GetTenantConfigurationAsync(It.IsAny()))
+ .ReturnsAsync((string id) => rows.TryGetValue(id, out var cfg) ? cfg : null);
+ configRepo.Setup(c => c.SaveTenantConfigurationAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .ReturnsAsync((TenantConfiguration cfg, string? source, string? reason) => { saves.Add((cfg, source, reason)); return true; });
+
+ var adminRepo = new Mock();
+ adminRepo.Setup(a => a.GetGroupTenantsAsync(Constants.TenantGroupIds.ForHomeTenant(Home))).ReturnsAsync(members.ToList());
+
+ var index = new StubManagedTenantProIndex(_ => null);
+ var configs = new TenantConfigurationService(configRepo.Object, NullLogger.Instance,
+ new MemoryCache(new MemoryCacheOptions()), index);
+ var svc = new ProConferralService(adminRepo.Object, configs, index, NullLogger.Instance, new TestTimeProvider(Now));
+ return new Harness { Svc = svc, Rows = rows, Saves = saves, Index = index };
+ }
+
+ [Fact]
+ public async Task RecordLoss_PermanentProHome_StampsAnchor_NeverTouchesPlanTier()
+ {
+ var h = Build("pro");
+
+ var stamped = await h.Svc.RecordLossAsync(CustomerA, Home, "customer-revoked");
+
+ Assert.True(stamped);
+ var save = Assert.Single(h.Saves);
+ Assert.Equal(CustomerA, save.Config.TenantId);
+ Assert.Equal(Now, save.Config.ProDowngradedUtc);
+ Assert.Equal("community", save.Config.PlanTier);
+ Assert.Equal("delegation", save.Source);
+ Assert.Equal("customer-revoked", save.Reason);
+ Assert.Equal($"delegation:{Home}", save.Config.UpdatedBy);
+ Assert.Equal(1, h.Index.Invalidations);
+ }
+
+ [Fact]
+ public async Task RecordLoss_TrialHome_StampsNothing_StillDropsCaches()
+ {
+ var h = Build("community");
+ h.Rows[Home].TrialExpiresUtc = Now.AddDays(5);
+
+ var stamped = await h.Svc.RecordLossAsync(CustomerA, Home, "customer-revoked");
+
+ Assert.False(stamped);
+ Assert.Empty(h.Saves);
+ Assert.Equal(1, h.Index.Invalidations);
+ }
+
+ [Fact]
+ public async Task RecordLoss_UnknownCustomerRow_IsNoOp()
+ {
+ var h = Build("pro");
+
+ Assert.False(await h.Svc.RecordLossAsync("99999999-9999-9999-9999-999999999999", Home, "customer-revoked"));
+ Assert.Empty(h.Saves);
+ }
+
+ [Fact]
+ public async Task RecordLossForOwnedGroup_StampsEveryMember_ExceptHome()
+ {
+ var h = Build("community", CustomerA, CustomerB, Home);
+
+ var count = await h.Svc.RecordLossForOwnedGroupAsync(Home, "manager-lost-permanent-pro");
+
+ Assert.Equal(2, count);
+ Assert.Equal(new[] { CustomerA, CustomerB }, h.Saves.Select(s => s.Config.TenantId).OrderBy(t => t).ToArray());
+ Assert.All(h.Saves, s => Assert.Equal(Now, s.Config.ProDowngradedUtc));
+ Assert.All(h.Saves, s => Assert.Equal("manager-lost-permanent-pro", s.Reason));
+ Assert.Equal(1, h.Index.Invalidations);
+ }
+
+ [Fact]
+ public async Task NotifyDelegationChanged_DropsIndex()
+ {
+ var h = Build("pro");
+
+ await h.Svc.NotifyDelegationChangedAsync(CustomerA);
+
+ Assert.Equal(1, h.Index.Invalidations);
+ Assert.Empty(h.Saves);
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/RevokeEnforcementTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/RevokeEnforcementTests.cs
index 2da2b2b1..7b806fc1 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/RevokeEnforcementTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/RevokeEnforcementTests.cs
@@ -88,7 +88,8 @@ public class RevokeEnforcementTests
var audit = BuildAuditRepo();
var signalR = new FakeSignalRNotificationService();
var fn = new TenantGroupManagementFunction(
- NullLogger.Instance, BuildService(repo), Resolver(), audit.Object, signalR, BuildSlots(repo));
+ NullLogger.Instance, BuildService(repo), Resolver(), audit.Object, signalR, BuildSlots(repo),
+ TestProConferral.Inert());
return (fn, repo, audit, signalR);
}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/StubTenantEntitlementService.cs b/src/Backend/AutopilotMonitor.Functions.Tests/StubTenantEntitlementService.cs
index 29831d4f..37a3c845 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/StubTenantEntitlementService.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/StubTenantEntitlementService.cs
@@ -11,30 +11,39 @@ namespace AutopilotMonitor.Functions.Tests;
///
internal sealed class StubTenantEntitlementService : TenantEntitlementService
{
- private readonly Func _resolver;
+ private readonly Func _resolver;
private readonly Func _planOverrideResolver;
public StubTenantEntitlementService(TenantEdition edition) : this(_ => edition)
{
}
- /// Edition per tenant id.
+ /// Edition per tenant id — Pro means Pro in the tenant's OWN right (plan).
///
/// The tenant-wide MCP usage-plan override (TenantConfiguration.McpUsagePlanOverride) per tenant id;
/// null = no override (the edition's plan name applies). Defaults to "no override anywhere".
///
public StubTenantEntitlementService(Func resolver, Func? planOverrideResolver = null)
+ : this(tenantId => AsOwnResolution(resolver(tenantId)), planOverrideResolver)
+ {
+ }
+
+ /// Full resolution per tenant id (edition, source, own standing) — for conferred-Pro cases.
+ /// See the edition-based constructor.
+ public StubTenantEntitlementService(Func resolver, Func? planOverrideResolver)
: base(configService: null!, logger: NullLogger.Instance)
{
_resolver = resolver;
_planOverrideResolver = planOverrideResolver ?? (_ => null);
}
- public override Task GetEditionAsync(string? tenantId)
- => Task.FromResult(_resolver(tenantId));
+ private static EditionResolution AsOwnResolution(TenantEdition edition) => edition == TenantEdition.Pro
+ ? new EditionResolution(TenantEdition.Pro, EditionSource.Plan, OwnPro: true)
+ : new EditionResolution(TenantEdition.Community, EditionSource.Community, OwnPro: false);
- public override Task GetEntitlementsAsync(string? tenantId)
- => Task.FromResult(FeatureEntitlementCatalog.Get(_resolver(tenantId)));
+ // GetEditionAsync / GetEntitlementsAsync derive from this in the base class.
+ public override Task GetResolutionAsync(string? tenantId)
+ => Task.FromResult(_resolver(tenantId));
public override Task GetMcpUsagePlanNameAsync(string? tenantId)
=> Task.FromResult(
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs
index e3b1c4a0..35a762c7 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigProjectionTests.cs
@@ -28,7 +28,7 @@ public class TenantConfigProjectionTests
private static readonly string[] AllSafeKeys =
{
"tenantId", "domainName", "planTier", "trialExpiresUtc", "trialConsumed", "maxDelegatedTenantsOverride",
- "mcpUsagePlanOverride",
+ "mcpUsagePlanOverride", "payingCustomer", "managedByProTenantId",
"disabled", "disabledReason",
"onboardedAt", "onboardedBy", "lastUpdated", "dataRetentionDays",
"homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince",
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs
index b07385e5..09a925f3 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigTableSerializationTests.cs
@@ -226,6 +226,43 @@ public class TenantConfigTableSerializationTests
Assert.Equal(overrideLimit, mapped.MaxDelegatedTenantsOverride);
}
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void Roundtrip_PayingCustomer_SurvivesStoreAndMap(bool paying)
+ {
+ var config = new TenantConfiguration { TenantId = TenantId, DomainName = "contoso.com", UpdatedBy = "ga@operator.example", PayingCustomer = paying };
+
+ var mapped = TableConfigRepository.ConvertFromTenantTableEntity(TableConfigRepository.ConvertToTenantTableEntity(config));
+
+ Assert.Equal(paying, mapped.PayingCustomer);
+ }
+
+ [Fact]
+ public void Map_LegacyRow_WithoutPayingCustomerColumn_ReadsFalse()
+ {
+ var entity = new TableEntity(TenantId, "config") { { "DomainName", "fabrikam.com" } };
+
+ Assert.False(TableConfigRepository.ConvertFromTenantTableEntity(entity).PayingCustomer);
+ }
+
+ [Fact]
+ public void ManagedByProTenantId_IsNeverStored_AndNeverMapped()
+ {
+ // Read-time projection (conferred Pro): a value set on the model must not reach the table —
+ // otherwise a stale column could keep a tenant Pro after its delegation ended.
+ var config = new TenantConfiguration
+ {
+ TenantId = TenantId, DomainName = "contoso.com", UpdatedBy = "ga@operator.example",
+ ManagedByProTenantId = "11111111-1111-1111-1111-111111111111",
+ };
+
+ var entity = TableConfigRepository.ConvertToTenantTableEntity(config);
+
+ Assert.False(entity.ContainsKey("ManagedByProTenantId"));
+ Assert.Null(TableConfigRepository.ConvertFromTenantTableEntity(entity).ManagedByProTenantId);
+ }
+
[Theory]
[InlineData(null)]
[InlineData("msp")]
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigurationServiceReadSemanticsTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigurationServiceReadSemanticsTests.cs
index ff641be7..259ed722 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigurationServiceReadSemanticsTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/TenantConfigurationServiceReadSemanticsTests.cs
@@ -31,6 +31,91 @@ public sealed class TenantConfigurationServiceReadSemanticsTests
return (service, repo);
}
+ // ── Conferred-Pro projection (ManagedByProTenantId) ──────────────────────
+
+ private const string Owner = "99999999-9999-9999-9999-999999999999";
+
+ private static (TenantConfigurationService service, Mock repo, StubManagedTenantProIndex index) BuildProjecting(string? owner)
+ {
+ var repo = new Mock();
+ var index = new StubManagedTenantProIndex(id => string.Equals(id, TenantId, StringComparison.OrdinalIgnoreCase) ? owner : null);
+ var service = new TenantConfigurationService(
+ repo.Object, Mock.Of>(), new MemoryCache(new MemoryCacheOptions()), index);
+ return (service, repo, index);
+ }
+
+ [Fact]
+ public async Task Projection_IsAppliedOnMissAndOnCacheHit()
+ {
+ var (service, repo, _) = BuildProjecting(Owner);
+ repo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(new TenantConfiguration { TenantId = TenantId });
+
+ var first = await service.GetConfigurationIfExistsAsync(TenantId);
+ var second = await service.GetConfigurationIfExistsAsync(TenantId); // cache hit
+
+ Assert.Equal(Owner, first!.ManagedByProTenantId);
+ Assert.Equal(Owner, second!.ManagedByProTenantId);
+ repo.Verify(r => r.GetTenantConfigurationAsync(TenantId), Times.Once);
+ }
+
+ [Fact]
+ public async Task Projection_FollowsTheIndex_NotTheCachedRow()
+ {
+ // The index answer changes (delegation ended) while the config row stays cached: the next
+ // read must reflect the index, i.e. staleness is bounded by the INDEX, not by this cache.
+ var repo = new Mock();
+ string? owner = Owner;
+ var index = new StubManagedTenantProIndex(_ => owner);
+ var service = new TenantConfigurationService(
+ repo.Object, Mock.Of>(), new MemoryCache(new MemoryCacheOptions()), index);
+ repo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(new TenantConfiguration { TenantId = TenantId });
+
+ Assert.Equal(Owner, (await service.GetConfigurationAsync(TenantId)).ManagedByProTenantId);
+ owner = null;
+ Assert.Null((await service.GetConfigurationAsync(TenantId)).ManagedByProTenantId);
+ }
+
+ [Fact]
+ public async Task Projection_CoversFreshAllAndPageReads()
+ {
+ var (service, repo, _) = BuildProjecting(Owner);
+ TenantConfiguration Row() => new() { TenantId = TenantId };
+ repo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(Row());
+ repo.Setup(r => r.GetAllTenantConfigurationsAsync()).ReturnsAsync(new List { Row(), new() { TenantId = "other" } });
+ repo.Setup(r => r.GetTenantConfigurationsPageAsync(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new AutopilotMonitor.Shared.Pagination.RawPage(new List { Row() }, null));
+
+ Assert.Equal(Owner, (await service.GetConfigurationFreshAsync(TenantId))!.ManagedByProTenantId);
+ var all = await service.GetAllConfigurationsAsync();
+ Assert.Equal(Owner, all.Single(c => c.TenantId == TenantId).ManagedByProTenantId);
+ Assert.Null(all.Single(c => c.TenantId == "other").ManagedByProTenantId);
+ var page = await service.GetConfigurationsPageAsync(10, null);
+ Assert.Equal(Owner, page.Items.Single().ManagedByProTenantId);
+ }
+
+ [Fact]
+ public async Task Projection_NeverAppliesToASynthesizedDefault()
+ {
+ // No row ⇒ the auto-created default is returned unprojected: a tenant without a configuration
+ // row can never be Pro, whatever the index says (fail-closed).
+ var (service, repo, _) = BuildProjecting(Owner);
+ repo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync((TenantConfiguration?)null);
+ repo.Setup(r => r.SaveTenantConfigurationAsync(It.IsAny())).ReturnsAsync(true);
+
+ var config = await service.GetConfigurationAsync(TenantId);
+
+ Assert.Null(config.ManagedByProTenantId);
+ }
+
+ [Fact]
+ public async Task ThreeArgumentConstructor_ProjectsNobody()
+ {
+ var (service, repo) = Build();
+ repo.Setup(r => r.GetTenantConfigurationAsync(TenantId)).ReturnsAsync(new TenantConfiguration { TenantId = TenantId });
+
+ Assert.Null((await service.GetConfigurationIfExistsAsync(TenantId))!.ManagedByProTenantId);
+ }
+
[Fact]
public async Task GetConfigurationIfExistsAsync_StorageError_Propagates()
{
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TestProConferral.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TestProConferral.cs
new file mode 100644
index 00000000..108b1352
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/TestProConferral.cs
@@ -0,0 +1,36 @@
+using AutopilotMonitor.Functions.Services;
+using AutopilotMonitor.Shared.DataAccess;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Conferred-Pro test doubles: an inert for subjects that merely
+/// depend on it, and a storage-free that answers from a lambda.
+///
+internal static class TestProConferral
+{
+ /// A service over empty storage: every record call is a no-op that returns "nothing stamped".
+ public static ProConferralService Inert()
+ {
+ var adminRepo = new Mock();
+ adminRepo.Setup(r => r.GetGroupTenantsAsync(It.IsAny())).ReturnsAsync(new List());
+ var configs = new TenantConfigurationService(
+ Mock.Of(), NullLogger.Instance, new MemoryCache(new MemoryCacheOptions()));
+ return new ProConferralService(adminRepo.Object, configs, ManagedTenantProIndex.None, NullLogger.Instance);
+ }
+}
+
+/// Index stub: the conferring owner per tenant id comes from the lambda; no storage, no cache.
+internal sealed class StubManagedTenantProIndex : ManagedTenantProIndex
+{
+ private readonly Func _owner;
+ public int Invalidations { get; private set; }
+
+ public StubManagedTenantProIndex(Func owner) => _owner = owner;
+
+ public override Task GetConferringOwnerAsync(string? tenantId) => Task.FromResult(_owner(tenantId));
+ public override void Invalidate() => Invalidations++;
+}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TrialExpirySweepFunctionTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TrialExpirySweepFunctionTests.cs
index 37d823c3..5f37f541 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/TrialExpirySweepFunctionTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/TrialExpirySweepFunctionTests.cs
@@ -54,11 +54,42 @@ public class TrialExpirySweepFunctionTests
var opsService = new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch);
var sut = new TrialExpirySweepFunction(
- configRepo.Object, opsService, NullLogger.Instance,
+ Configs(configRepo.Object), opsService, NullLogger.Instance,
new TestTimeProvider(Now));
return (sut, events);
}
+ private static TenantConfigurationService Configs(IConfigRepository repo, ManagedTenantProIndex? index = null)
+ => new(repo, NullLogger.Instance, new MemoryCache(new MemoryCacheOptions()), index ?? ManagedTenantProIndex.None);
+
+ [Fact]
+ public async Task ExpiredTrial_OfManagedTenant_EmitsNothing_StillProViaMsp()
+ {
+ // The tenant's own trial ran out yesterday, but a permanent-Pro tenant manages it — the
+ // projection keeps it Pro, so no "trial expired" alert may reach sales/support.
+ var managed = Tenant("t-managed", Now.AddHours(-2), retentionDays: 365);
+ var configRepo = new Mock();
+ configRepo.Setup(r => r.GetAllTenantConfigurationsAsync()).ReturnsAsync(new List { managed });
+ 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 opsService = new OpsEventService(opsRepo.Object, NullLogger.Instance, TestNotifications.InertOpsAlertDispatch(adminConfig.Object));
+ var index = new StubManagedTenantProIndex(id => id == "t-managed" ? "11111111-1111-1111-1111-111111111111" : null);
+
+ var sut = new TrialExpirySweepFunction(Configs(configRepo.Object, index), opsService,
+ NullLogger.Instance, new TestTimeProvider(Now));
+ var result = await sut.RunCoreAsync(CancellationToken.None);
+
+ Assert.Equal(0, result.TrialsSeen);
+ Assert.Equal(0, result.ExpiredEmitted);
+ Assert.Empty(events);
+ }
+
[Fact]
public async Task ExpiredWithinLookBack_EmitsTenantTrialExpired()
{
@@ -247,7 +278,7 @@ public class TrialExpirySweepFunctionTests
var alertDispatch = TestNotifications.InertOpsAlertDispatch(adminConfig.Object);
var sut = new TrialExpirySweepFunction(
- configRepo.Object,
+ Configs(configRepo.Object),
new OpsEventService(opsRepo.Object, NullLogger.Instance, alertDispatch),
NullLogger.Instance,
new TestTimeProvider(Now));
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/TypedContractCompletionWireParityTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/TypedContractCompletionWireParityTests.cs
index 243114f1..e1debc41 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/TypedContractCompletionWireParityTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/TypedContractCompletionWireParityTests.cs
@@ -376,6 +376,7 @@ public class TypedContractCompletionWireParityTests
enableIntegrityBypassAnalyzer = true,
unrestrictedMode = false,
edition = "community",
+ editionSource = "community",
isTrial = false,
trialExpiresUtc = (DateTime?)null, // not on trial → key vanishes
trialAvailable = true,
@@ -401,6 +402,7 @@ public class TypedContractCompletionWireParityTests
EnableIntegrityBypassAnalyzer = true,
UnrestrictedMode = false,
Edition = "community",
+ EditionSource = "community",
IsTrial = false,
TrialExpiresUtc = null,
TrialAvailable = true,
diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs
index fc5bd9e9..199d7d6c 100644
--- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs
+++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs
@@ -746,6 +746,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "ProDowngradedUtc", config.ProDowngradedUtc },
{ "MaxDelegatedTenantsOverride", config.MaxDelegatedTenantsOverride },
{ "McpUsagePlanOverride", config.McpUsagePlanOverride },
+ { "PayingCustomer", config.PayingCustomer },
+ // ManagedByProTenantId is a load-time projection (see TenantConfiguration) — deliberately no column.
// SLA targets
{ "SlaTargetSuccessRate", config.SlaTargetSuccessRate.HasValue ? (double)config.SlaTargetSuccessRate.Value : (double?)null },
{ "SlaTargetMaxDurationMinutes", config.SlaTargetMaxDurationMinutes },
@@ -863,6 +865,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
ProDowngradedUtc = entity.GetDateTime("ProDowngradedUtc"),
MaxDelegatedTenantsOverride = entity.GetInt32("MaxDelegatedTenantsOverride"),
McpUsagePlanOverride = entity.GetString("McpUsagePlanOverride"),
+ PayingCustomer = entity.GetBoolean("PayingCustomer") ?? false,
// 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/TenantGroupManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantGroupManagementFunction.cs
index 40fe8f16..6779d324 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantGroupManagementFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantGroupManagementFunction.cs
@@ -34,6 +34,7 @@ public class TenantGroupManagementFunction
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly ISignalRNotificationService _signalRService;
private readonly DelegatedSlotService _slots;
+ private readonly ProConferralService _proConferral;
private const string AuditEntity = "DelegatedGroupAccess";
@@ -43,7 +44,8 @@ public class TenantGroupManagementFunction
AdminIdentityResolver identityResolver,
IMaintenanceRepository maintenanceRepo,
ISignalRNotificationService signalRService,
- DelegatedSlotService slots)
+ DelegatedSlotService slots,
+ ProConferralService proConferral)
{
_logger = logger;
_delegatedAdminService = delegatedAdminService;
@@ -51,6 +53,14 @@ public class TenantGroupManagementFunction
_maintenanceRepo = maintenanceRepo;
_signalRService = signalRService;
_slots = slots;
+ _proConferral = proConferral;
+ }
+
+ /// The managing tenant that owns (self-service group), or null for operator groups / unknown groups.
+ private async Task OwnerOfAsync(string groupId)
+ {
+ var group = await _delegatedAdminService.GetGroupAsync(groupId);
+ return string.IsNullOrWhiteSpace(group?.OwnerTenantId) ? null : group!.OwnerTenantId;
}
/// GET /api/global/tenant-groups — list every group with tenants + assignee count. GlobalReadOrAdmin.
@@ -223,6 +233,9 @@ public class TenantGroupManagementFunction
foreach (var home in await _slots.ResolveHomeTenantsAsync(assignees.Select(a => a.Upn)))
_slots.Invalidate(home);
+ // An OWNED (self-service) group confers Pro on its members — drop the projection caches now.
+ if (await OwnerOfAsync(groupId) != null)
+ await _proConferral.NotifyDelegationChangedAsync(tenantId);
// Every current assignee just gained access to this tenant — audit under that tenant per assignee.
foreach (var assignee in assignees)
@@ -251,12 +264,17 @@ public class TenantGroupManagementFunction
// Snapshot assignees BEFORE the removal so we can audit each one's lost access under this tenant.
var assignees = await _delegatedAdminService.GetGroupAssigneesAsync(groupId);
+ var owner = await OwnerOfAsync(groupId);
// The service returns false when the group doesn't exist or the tenant isn't a member — so a typo
// can't 200 and write false "access removed" audit rows. Only audit after a real removal.
var removed = await _delegatedAdminService.RemoveTenantFromGroupAsync(groupId, normalizedTenantId);
if (!removed)
return await NotFound(req);
+ // Leaving an OWNED (self-service) group ends conferred Pro — stamp the tenant's retention grace anchor.
+ if (owner != null)
+ await _proConferral.RecordLossAsync(normalizedTenantId, owner, "tenant-removed-from-group");
+
foreach (var assignee in assignees)
{
await _maintenanceRepo.LogAuditEntryAsync(
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs
index 3832f8e5..4cb3fde7 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetTenantFeatureFlagsFunction.cs
@@ -72,10 +72,11 @@ namespace AutopilotMonitor.Functions.Functions.Config
///
internal static TenantFeatureFlagsResponse BuildPayload(TenantConfiguration config, DateTime nowUtc, bool appHomingFunnelActive = false)
{
- var edition = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
- var entitlements = FeatureEntitlementCatalog.Get(edition);
- var isTrial = edition == TenantEdition.Pro &&
- !FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier);
+ // Edition + source in one resolution: Pro from the plan, from a trial, or conferred by the
+ // managing tenant ("msp" — shown as "Pro (MSP)", entitlements without the delegation right).
+ var resolution = FeatureEntitlementCatalog.Resolve(config, nowUtc);
+ var entitlements = FeatureEntitlementCatalog.Get(resolution);
+ var isTrial = resolution.IsTrial;
return new TenantFeatureFlagsResponse
{
@@ -106,10 +107,11 @@ namespace AutopilotMonitor.Functions.Functions.Config
UnrestrictedMode = TenantEntitlementService.IsUnrestrictedModeActive(config, nowUtc),
// 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(),
+ Edition = resolution.EditionName,
+ EditionSource = resolution.SourceName,
IsTrial = isTrial,
TrialExpiresUtc = isTrial ? config.TrialExpiresUtc : null,
- TrialAvailable = !config.TrialConsumed && edition == TenantEdition.Community,
+ TrialAvailable = !config.TrialConsumed && !resolution.IsPro,
// Pro-requires-contact surface: drives the trial CTA gate and the dashboard
// "set a contact address" banner for Pro tenants. Boolean only — the address
// itself stays in the admin-gated full config response.
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs
index 47876486..576f4314 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs
@@ -28,6 +28,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
private readonly AdminConfigurationService _adminConfigService;
private readonly IMaintenanceRepository _maintenanceRepo;
private readonly OpsEventService _opsEvents;
+ private readonly ProConferralService _proConferral;
private readonly TimeProvider _time;
public PlanManagementFunction(
@@ -35,8 +36,9 @@ namespace AutopilotMonitor.Functions.Functions.Config
TenantConfigurationService configService,
AdminConfigurationService adminConfigService,
IMaintenanceRepository maintenanceRepo,
- OpsEventService opsEvents)
- : this(logger, configService, adminConfigService, maintenanceRepo, opsEvents, TimeProvider.System)
+ OpsEventService opsEvents,
+ ProConferralService proConferral)
+ : this(logger, configService, adminConfigService, maintenanceRepo, opsEvents, proConferral, TimeProvider.System)
{
}
@@ -47,6 +49,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
AdminConfigurationService adminConfigService,
IMaintenanceRepository maintenanceRepo,
OpsEventService opsEvents,
+ ProConferralService proConferral,
TimeProvider time)
{
_logger = logger;
@@ -54,13 +57,17 @@ namespace AutopilotMonitor.Functions.Functions.Config
_adminConfigService = adminConfigService;
_maintenanceRepo = maintenanceRepo;
_opsEvents = opsEvents;
+ _proConferral = proConferral;
_time = time;
}
///
/// PATCH /api/config/{tenantId}/plan — GlobalAdminOnly (catalog-enforced).
/// Body: { "planTier"?: "community"|"pro", "trialExpiresUtc"?: ISO-8601 | null, "maxDelegatedTenants"?: int | null,
- /// "mcpUsagePlan"?: string | null }.
+ /// "mcpUsagePlan"?: string | null, "payingCustomer"?: bool }.
+ /// payingCustomer is sales bookkeeping only (no entitlement effect). A permanent-Pro tenant that drops to
+ /// Community here takes conferred Pro away from every tenant it manages — their retention grace anchor
+ /// is stamped in the same request (ProConferralService).
/// maxDelegatedTenants sets the delegated (MSP) slot override (null clears it — the plan entitlement applies).
/// mcpUsagePlan sets the tenant-wide MCP usage-plan override — the name of a SectionUsagePlans plan, validated
/// against the current definitions (400 for an unknown name, so a typo can never silently drop the tenant
@@ -99,6 +106,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
int? newMaxDelegatedTenants = null;
bool mcpPlanProvided = false;
string? newMcpUsagePlan = null;
+ bool payingProvided = false;
+ bool newPayingCustomer = false;
using (doc)
{
@@ -171,10 +180,18 @@ namespace AutopilotMonitor.Functions.Functions.Config
return await BadRequestAsync(req, "mcpUsagePlan must be a plan name or null");
}
}
+
+ if (doc.RootElement.TryGetProperty("payingCustomer", out var payingProp))
+ {
+ if (payingProp.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
+ return await BadRequestAsync(req, "payingCustomer must be a boolean");
+ payingProvided = true;
+ newPayingCustomer = payingProp.GetBoolean();
+ }
}
- if (newPlanTier == null && !trialProvided && !slotsProvided && !mcpPlanProvided)
- return await BadRequestAsync(req, "Provide planTier, trialExpiresUtc, maxDelegatedTenants and/or mcpUsagePlan");
+ if (newPlanTier == null && !trialProvided && !slotsProvided && !mcpPlanProvided && !payingProvided)
+ return await BadRequestAsync(req, "Provide planTier, trialExpiresUtc, maxDelegatedTenants, mcpUsagePlan and/or payingCustomer");
if (newMcpUsagePlan != null)
{
@@ -200,10 +217,15 @@ namespace AutopilotMonitor.Functions.Functions.Config
var nowUtc = _time.GetUtcNow().UtcDateTime;
var changes = new Dictionary();
+ // Conferred Pro follows the PERMANENT tier of the managing tenant (a trial confers nothing):
+ // remember the standing before the mutation so a permanent-Pro → non-permanent drop can
+ // stamp the managed tenants' grace anchors below.
+ var wasPermanentPro = FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier);
var (editionBefore, editionAfter) =
ApplyPlanChanges(config, newPlanTier, trialProvided, newTrialExpiresUtc, caller, nowUtc, changes);
ApplyDelegatedSlotChange(config, slotsProvided, newMaxDelegatedTenants, changes);
ApplyMcpUsagePlanChange(config, mcpPlanProvided, newMcpUsagePlan, changes);
+ ApplyPayingCustomerChange(config, payingProvided, newPayingCustomer, changes);
if (changes.Count > 0)
{
@@ -231,6 +253,15 @@ namespace AutopilotMonitor.Functions.Functions.Config
config.DataRetentionDays);
}
+ if (wasPermanentPro && !FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier))
+ {
+ // After the save, so the managed tenants' index rebuild already sees the new tier.
+ var stamped = await _proConferral.RecordLossForOwnedGroupAsync(
+ requestCtx.TargetTenantId, "manager-lost-permanent-pro");
+ if (stamped > 0)
+ changes["ConferredProEndedForManagedTenants"] = stamped.ToString();
+ }
+
// The mirror image of the downgrade event: a GA granting or extending a trial
// is the same conversion moment as the self-service start, and sales/support
// channels bind to one event type, not two. Keyed off the recorded change so
@@ -250,6 +281,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
}
}
+ var resolution = FeatureEntitlementCatalog.Resolve(config, nowUtc);
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new SetTenantPlanTierResponse
{
@@ -257,12 +289,14 @@ namespace AutopilotMonitor.Functions.Functions.Config
PlanTier = config.PlanTier,
TrialExpiresUtc = config.TrialExpiresUtc,
TrialConsumed = config.TrialConsumed,
- EffectiveEdition = editionAfter.ToString().ToLowerInvariant(),
+ EffectiveEdition = resolution.EditionName,
+ EditionSource = resolution.SourceName,
RetentionGraceEndsUtc = TenantEntitlementService.GetRetentionGraceEndUtc(config, nowUtc),
MaxDelegatedTenants = TenantEntitlementService.GetMaxDelegatedTenants(config, nowUtc),
MaxDelegatedTenantsOverride = config.MaxDelegatedTenantsOverride,
McpUsagePlan = TenantEntitlementService.GetMcpUsagePlanName(config, nowUtc),
McpUsagePlanOverride = config.McpUsagePlanOverride,
+ PayingCustomer = config.PayingCustomer,
});
return response;
}
@@ -295,7 +329,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
DateTime nowUtc,
Dictionary changes)
{
- var before = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
+ var before = FeatureEntitlementCatalog.ResolveEdition(config, nowUtc);
if (newPlanTier != null && !string.Equals(config.PlanTier, newPlanTier, StringComparison.Ordinal))
{
@@ -314,7 +348,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
}
}
- var after = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
+ var after = FeatureEntitlementCatalog.ResolveEdition(config, nowUtc);
if (before == TenantEdition.Pro && after == TenantEdition.Community)
{
@@ -361,6 +395,19 @@ namespace AutopilotMonitor.Functions.Functions.Config
config.McpUsagePlanOverride = normalized;
}
+ ///
+ /// Pure mutation core for the sales bookkeeping flag: records and applies the change only when a value
+ /// was provided AND differs. No entitlement effect — see .
+ ///
+ internal static void ApplyPayingCustomerChange(
+ TenantConfiguration config, bool provided, bool payingCustomer, Dictionary changes)
+ {
+ if (!provided || config.PayingCustomer == payingCustomer)
+ return;
+ changes["PayingCustomer"] = $"{config.PayingCustomer} -> {payingCustomer}";
+ config.PayingCustomer = payingCustomer;
+ }
+
/// Pure: whether (normalized) names a SectionUsagePlans definition.
internal static bool IsKnownUsagePlan(string planName, IEnumerable definitions)
=> definitions.Any(d => string.Equals(TenantEntitlementService.NormalizePlanName(d.Name), planName, StringComparison.OrdinalIgnoreCase));
@@ -391,7 +438,8 @@ namespace AutopilotMonitor.Functions.Functions.Config
"This tenant has already used its one self-service Pro trial. Contact support to extend.");
}
- if (FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc) == TenantEdition.Pro)
+ // Effective edition, conferred Pro included: a tenant managed by a Pro tenant needs no trial.
+ if (FeatureEntitlementCatalog.ResolveEdition(config, nowUtc) == TenantEdition.Pro)
{
return ("AlreadyPro", "This tenant is already on the Pro plan.");
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs
index 898dd563..d91c4c94 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs
@@ -614,7 +614,7 @@ public class AuthFunction
// included in Pro, Unrestricted Mode requires Pro + the GA on-request gate.
BootstrapTokenEnabled = TenantEntitlementService.IsBootstrapEnabled(tenantConfig, DateTime.UtcNow),
UnrestrictedModeEnabled =
- FeatureEntitlementCatalog.Get(TenantEntitlementService.ResolveEdition(tenantConfig, DateTime.UtcNow)).UnrestrictedModeAvailable
+ FeatureEntitlementCatalog.Get(TenantEntitlementService.Resolve(tenantConfig, DateTime.UtcNow)).UnrestrictedModeAvailable
&& tenantConfig.UnrestrictedModeEnabled
}, needsAutoAdmin);
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs
index 98a697e5..b4e4c750 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Maintenance/TrialExpirySweepFunction.cs
@@ -42,27 +42,29 @@ public class TrialExpirySweepFunction
/// 03:30 UTC daily — off the busy maintenance window, after ScriptNameCacheCleanup (03:00).
private const string Cron = "0 30 3 * * *";
- private readonly IConfigRepository _configRepo;
+ // The configuration SERVICE, not the repository: the sweep judges effective editions, and only the
+ // service projects ManagedByProTenantId (a managed tenant's expired trial changes nothing).
+ private readonly TenantConfigurationService _configs;
private readonly OpsEventService _opsEvents;
private readonly ILogger _logger;
private readonly TimeProvider _time;
public TrialExpirySweepFunction(
- IConfigRepository configRepo,
+ TenantConfigurationService configs,
OpsEventService opsEvents,
ILogger logger)
- : this(configRepo, opsEvents, logger, TimeProvider.System)
+ : this(configs, opsEvents, logger, TimeProvider.System)
{
}
/// Test seam — inject a fake for deterministic window math.
internal TrialExpirySweepFunction(
- IConfigRepository configRepo,
+ TenantConfigurationService configs,
OpsEventService opsEvents,
ILogger logger,
TimeProvider time)
{
- _configRepo = configRepo;
+ _configs = configs;
_opsEvents = opsEvents;
_logger = logger;
_time = time;
@@ -83,7 +85,7 @@ public class TrialExpirySweepFunction
System.Collections.Generic.List configs;
try
{
- configs = await _configRepo.GetAllTenantConfigurationsAsync().ConfigureAwait(false);
+ configs = await _configs.GetAllConfigurationsAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
@@ -114,8 +116,10 @@ public class TrialExpirySweepFunction
if (config.TrialExpiresUtc is not DateTime expiry)
return;
- // Permanent Pro: the trial timestamps are inert leftovers — expiry changes nothing.
- if (FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier))
+ // Permanent Pro, or Pro conferred by the managing tenant: the trial timestamps are inert
+ // leftovers — expiry changes nothing.
+ if (FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier) ||
+ FeatureEntitlementCatalog.Resolve(config, now).IsViaMsp)
return;
result.TrialsSeen++;
diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs
index b9b35a87..3045149b 100644
--- a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs
@@ -28,6 +28,10 @@ namespace AutopilotMonitor.Functions.Helpers
("maxDelegatedTenantsOverride", c => c.MaxDelegatedTenantsOverride),
// MCP usage-plan override — a plan name, not a secret; the GA tenant list renders it.
("mcpUsagePlanOverride", c => c.McpUsagePlanOverride),
+ // Sales bookkeeping flag — operator-only, never a customer surface.
+ ("payingCustomer", c => c.PayingCustomer),
+ // Load-time projection: the permanent-Pro tenant conferring Pro on this one (null = none).
+ ("managedByProTenantId", c => c.ManagedByProTenantId),
("disabled", c => c.Disabled),
("disabledReason", c => c.DisabledReason),
("onboardedAt", c => c.OnboardedAt),
diff --git a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs
index 00bebfb3..bab59f1b 100644
--- a/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigValidation.cs
@@ -218,7 +218,7 @@ namespace AutopilotMonitor.Functions.Helpers
if (!isGlobalAdmin && candidate.DataRetentionDays != existing.DataRetentionDays)
{
var cap = FeatureEntitlementCatalog
- .Get(TenantEntitlementService.ResolveEdition(existing, DateTime.UtcNow))
+ .Get(TenantEntitlementService.Resolve(existing, DateTime.UtcNow))
.RetentionCapDays;
if (candidate.DataRetentionDays < 7 || candidate.DataRetentionDays > cap)
return $"Data retention must be between 7 and {cap} days for your plan. Upgrade to Pro for up to 365 days.";
diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs
index eae24b12..8acebce6 100644
--- a/src/Backend/AutopilotMonitor.Functions/Program.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Program.cs
@@ -234,7 +234,11 @@ builder.Services.AddHostedService(); // BackendStartup
// To switch to Cosmos DB: replace AddTableStorageDataAccess() with AddCosmosDataAccess().
// To add event streaming: chain .AddEventStreaming() after this call.
builder.Services.AddTableStorageDataAccess();
+// Conferred Pro ("Pro (MSP)"): the read-time index the configuration loader projects from, and the
+// write side that stamps the retention grace anchor when a conferring delegation ends.
+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/FeatureEntitlementCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs
index 0992f116..2543f7c8 100644
--- a/src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Security/FeatureEntitlementCatalog.cs
@@ -1,4 +1,5 @@
using System;
+using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Security
{
@@ -12,6 +13,40 @@ namespace AutopilotMonitor.Functions.Security
Pro
}
+ ///
+ /// WHY a tenant is on its effective edition. wins over the tenant's own plan
+ /// and trial for display purposes ("Pro (MSP)" is shown even to a paying Pro tenant while it is
+ /// managed) — the entitlement set is the UNION, see .
+ ///
+ public enum EditionSource
+ {
+ /// No Pro from any source.
+ Community,
+ /// Permanent Pro tier stored on the tenant (paid or support-assigned).
+ Plan,
+ /// Active trial (TrialExpiresUtc in the future) on a non-Pro tier.
+ Trial,
+ /// Pro conferred by the permanent-Pro tenant that manages this tenant (self-service delegation).
+ Msp
+ }
+
+ ///
+ /// Read-time edition resolution: the effective edition, its source, and whether the tenant is Pro
+ /// in its OWN right (plan or trial) independent of any managing tenant. Delegation entitlements
+ /// (DelegatedAdminAllowed, MaxDelegatedTenants) follow only — conferred Pro
+ /// never lets a managed tenant delegate onward.
+ ///
+ public readonly record struct EditionResolution(TenantEdition Edition, EditionSource Source, bool OwnPro)
+ {
+ public bool IsPro => Edition == TenantEdition.Pro;
+ public bool IsTrial => Source == EditionSource.Trial;
+ public bool IsViaMsp => Source == EditionSource.Msp;
+ /// Wire spelling of ("community" | "plan" | "trial" | "msp").
+ public string SourceName => Source.ToString().ToLowerInvariant();
+ /// Wire spelling of ("community" | "pro").
+ public string EditionName => Edition.ToString().ToLowerInvariant();
+ }
+
///
/// The static per-edition entitlement values. Immutable code catalog — analogous to
/// : entitlements are defined here, not in storage,
@@ -43,7 +78,9 @@ namespace AutopilotMonitor.Functions.Security
///
/// 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 (a Pro MSP may manage Community customers).
+ /// TARGET tenants may be any edition (a Pro MSP may manage Community customers; a
+ /// permanent-Pro MSP additionally confers Pro on them, see ).
+ /// Conferred Pro never includes this right (no transitive delegation).
/// Enforced at resolve time in DelegatedAdminService.GetScopeAsync.
///
public bool DelegatedAdminAllowed { get; init; }
@@ -97,9 +134,11 @@ namespace AutopilotMonitor.Functions.Security
///
/// Resolution is computed at READ time: Pro ⇔ PlanTier == "pro" (or the legacy stored value
/// "enterprise", kept readable so existing rows need no migration) 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",
- /// unknown — is Community (fail-closed, no data migration required).
+ /// (TrialExpiresUtc strictly in the future) OR the tenant is managed by a permanent-Pro tenant
+ /// (, a load-time projection that is never
+ /// stored). Trial expiry and the end of a delegation therefore degrade the tenant automatically
+ /// without any timer or sweep. Everything else — null, empty, "free", unknown — is Community
+ /// (fail-closed, no data migration required).
///
public static class FeatureEntitlementCatalog
{
@@ -157,6 +196,28 @@ namespace AutopilotMonitor.Functions.Security
McpTenantMonthlyRequestLimit = 60000
};
+ ///
+ /// Pro conferred by a managing tenant: every Pro value EXCEPT the delegation right. A managed
+ /// tenant that is not Pro in its own right cannot invite or manage tenants itself — the
+ /// delegation right is what the managing tenant paid for, and conferral must not chain.
+ ///
+ private static readonly EditionEntitlements ProViaMsp = new()
+ {
+ Edition = TenantEdition.Pro,
+ RetentionCapDays = Pro.RetentionCapDays,
+ UserRateLimitPerMinute = Pro.UserRateLimitPerMinute,
+ DeviceRateLimitPerMinute = Pro.DeviceRateLimitPerMinute,
+ DelegatedAdminAllowed = false,
+ MaxDelegatedTenants = 0,
+ BootstrapIncluded = Pro.BootstrapIncluded,
+ UnrestrictedModeAvailable = Pro.UnrestrictedModeAvailable,
+ McpUsagePlanName = Pro.McpUsagePlanName,
+ McpDailyRequestLimit = Pro.McpDailyRequestLimit,
+ McpMonthlyRequestLimit = Pro.McpMonthlyRequestLimit,
+ McpTenantDailyRequestLimit = Pro.McpTenantDailyRequestLimit,
+ McpTenantMonthlyRequestLimit = Pro.McpTenantMonthlyRequestLimit
+ };
+
///
/// True when the stored tier is a PERMANENT Pro tier ("pro", or the legacy stored value
/// "enterprise"), independent of any trial. Used to separate paid tenants from trial
@@ -170,22 +231,53 @@ namespace AutopilotMonitor.Functions.Security
}
///
- /// Resolves the effective edition from the stored plan tier and trial expiry.
- /// Pro ⇔ tier "pro"/legacy "enterprise" (case-insensitive) OR trialExpiresUtc > nowUtc.
- /// A trial expiring exactly at is already over (strict >).
+ /// Resolves the effective edition and its source from the stored plan tier, the trial expiry
+ /// and the load-time "managed by a permanent-Pro tenant" projection.
+ /// Own Pro ⇔ tier "pro"/legacy "enterprise" (case-insensitive) OR trialExpiresUtc > nowUtc
+ /// (a trial expiring exactly at is already over — strict >).
+ /// Source precedence for DISPLAY: Msp > Plan > Trial;
+ /// keeps the tenant's own standing so the entitlement union is exact.
///
- public static TenantEdition ResolveEdition(string? planTier, DateTime? trialExpiresUtc, DateTime nowUtc)
+ public static EditionResolution Resolve(string? planTier, DateTime? trialExpiresUtc, string? managedByProTenantId, DateTime nowUtc)
{
- if (IsPermanentProTier(planTier))
- return TenantEdition.Pro;
+ var permanentPro = IsPermanentProTier(planTier);
+ var trialActive = trialExpiresUtc.HasValue && trialExpiresUtc.Value > nowUtc;
+ var ownPro = permanentPro || trialActive;
- if (trialExpiresUtc.HasValue && trialExpiresUtc.Value > nowUtc)
- return TenantEdition.Pro;
-
- return TenantEdition.Community;
+ if (!string.IsNullOrWhiteSpace(managedByProTenantId))
+ return new EditionResolution(TenantEdition.Pro, EditionSource.Msp, ownPro);
+ if (permanentPro)
+ return new EditionResolution(TenantEdition.Pro, EditionSource.Plan, true);
+ if (trialActive)
+ return new EditionResolution(TenantEdition.Pro, EditionSource.Trial, true);
+ return new EditionResolution(TenantEdition.Community, EditionSource.Community, false);
}
- /// Returns the entitlement set for an edition. Unknown enum values → Community (fail-closed).
+ /// Config-based overload of — the one every config-holding caller uses.
+ public static EditionResolution Resolve(TenantConfiguration config, DateTime nowUtc)
+ => Resolve(config.PlanTier, config.TrialExpiresUtc, config.ManagedByProTenantId, nowUtc);
+
+ /// The effective edition only — see .
+ public static TenantEdition ResolveEdition(TenantConfiguration config, DateTime nowUtc)
+ => Resolve(config, nowUtc).Edition;
+
+ ///
+ /// Returns the entitlement set for a resolution: Community values unless Pro; the full Pro set when the
+ /// tenant is Pro in its own right (plan or trial); the conferred set (no delegation right) when Pro comes
+ /// only from the managing tenant. Unknown enum values → Community (fail-closed).
+ ///
+ public static EditionEntitlements Get(EditionResolution resolution)
+ {
+ if (resolution.Edition != TenantEdition.Pro)
+ return Community;
+ return resolution.OwnPro ? Pro : ProViaMsp;
+ }
+
+ ///
+ /// Returns the entitlement set for a bare edition (catalog lookups such as a cap value). Callers that
+ /// hold a config must use so conferred Pro is resolved correctly.
+ /// Unknown enum values → Community (fail-closed).
+ ///
public static EditionEntitlements Get(TenantEdition edition) =>
edition == TenantEdition.Pro ? Pro : Community;
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs
index 170b4758..f4958636 100644
--- a/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Security/SecurityValidator.cs
@@ -116,7 +116,7 @@ namespace AutopilotMonitor.Functions.Security
// 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))
+ .Get(Services.TenantEntitlementService.Resolve(config, DateTime.UtcNow))
.DeviceRateLimitPerMinute;
return RateLimitResolver.ResolveDeviceLimit(
config.CustomRateLimitRequestsPerMinute,
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs b/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs
index e47058de..3f2f612b 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/DelegatedAdminService.cs
@@ -163,7 +163,9 @@ public class DelegatedAdminService
///
private async Task ApplyHomeTenantGateAsync(DelegatedScope scope, string upn, string callerHomeTenantId)
{
- if (await _entitlementService.GetEditionAsync(callerHomeTenantId) == Security.TenantEdition.Pro)
+ // The ENTITLEMENT, not the edition: a tenant whose Pro is conferred by its own managing tenant
+ // is Pro without the delegation right (no transitive delegation).
+ if ((await _entitlementService.GetEntitlementsAsync(callerHomeTenantId)).DelegatedAdminAllowed)
return scope;
_logger.LogInformation(
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/DelegatedSlotService.cs b/src/Backend/AutopilotMonitor.Functions/Services/DelegatedSlotService.cs
index b490b0ea..46ea9f65 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/DelegatedSlotService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/DelegatedSlotService.cs
@@ -304,7 +304,9 @@ public class DelegatedSlotService
var config = await _configRepo.GetTenantConfigurationAsync(homeTenantId);
if (config == null)
return (0, 0, null, null);
- var catalog = Security.FeatureEntitlementCatalog.Get(TenantEntitlementService.ResolveEdition(config, nowUtc)).MaxDelegatedTenants;
+ // Raw repository row: no ManagedByProTenantId projection, so this is the home tenant's OWN standing —
+ // exactly right for slots (conferred Pro carries none).
+ var catalog = Security.FeatureEntitlementCatalog.Get(TenantEntitlementService.Resolve(config, nowUtc)).MaxDelegatedTenants;
return (TenantEntitlementService.GetMaxDelegatedTenants(config, nowUtc), catalog, config.MaxDelegatedTenantsOverride,
string.IsNullOrWhiteSpace(config.DomainName) ? null : config.DomainName);
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/DelegationSelfService.cs b/src/Backend/AutopilotMonitor.Functions/Services/DelegationSelfService.cs
index c62232ae..e67ed5b6 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/DelegationSelfService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/DelegationSelfService.cs
@@ -59,6 +59,7 @@ public class DelegationSelfService
private readonly IConfigRepository _configRepo;
private readonly IMaintenanceRepository _audit;
private readonly ISignalRNotificationService _signalR;
+ private readonly ProConferralService _proConferral;
private readonly ILogger _logger;
private readonly TimeProvider _time;
@@ -71,8 +72,9 @@ public class DelegationSelfService
IConfigRepository configRepo,
IMaintenanceRepository audit,
ISignalRNotificationService signalR,
+ ProConferralService proConferral,
ILogger logger)
- : this(adminRepo, invitations, delegatedAdmins, slots, entitlements, configRepo, audit, signalR, logger, TimeProvider.System)
+ : this(adminRepo, invitations, delegatedAdmins, slots, entitlements, configRepo, audit, signalR, proConferral, logger, TimeProvider.System)
{
}
@@ -86,6 +88,7 @@ public class DelegationSelfService
IConfigRepository configRepo,
IMaintenanceRepository audit,
ISignalRNotificationService signalR,
+ ProConferralService proConferral,
ILogger logger,
TimeProvider time)
{
@@ -97,6 +100,7 @@ public class DelegationSelfService
_configRepo = configRepo;
_audit = audit;
_signalR = signalR;
+ _proConferral = proConferral;
_logger = logger;
_time = time;
}
@@ -217,6 +221,8 @@ public class DelegationSelfService
await EnsureOwnedGroupAsync(home);
await _delegatedAdmins.AddTenantToGroupAsync(groupId, target);
+ // Conferred Pro is projected from this membership — drop the caches so the customer sees "Pro (MSP)" now.
+ await _proConferral.NotifyDelegationChangedAsync(target);
var actor = callerUpn.ToLowerInvariant();
var assignees = await _delegatedAdmins.GetGroupAssigneesAsync(groupId);
@@ -280,6 +286,8 @@ public class DelegationSelfService
if (!await _delegatedAdmins.RemoveTenantFromGroupAsync(groupId, target))
return DelegationResult.Fail(404, Constants.DelegationCodes.NotManagedBySelfService, "That tenant is not managed through a self-service delegation.");
+ // The customer's conferred Pro ends with the membership: stamp its retention grace anchor.
+ await _proConferral.RecordLossAsync(target, home, reason);
var hold = await _slots.RecordReleaseAsync(home, target, actor);
var by = actor.ToLowerInvariant();
foreach (var assignee in assignees)
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/ManagedTenantProIndex.cs b/src/Backend/AutopilotMonitor.Functions/Services/ManagedTenantProIndex.cs
new file mode 100644
index 00000000..bf137eac
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Services/ManagedTenantProIndex.cs
@@ -0,0 +1,141 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using AutopilotMonitor.Functions.Security;
+using AutopilotMonitor.Shared.DataAccess;
+using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.Logging;
+
+namespace AutopilotMonitor.Functions.Services
+{
+ ///
+ /// The reverse index behind TenantConfiguration.ManagedByProTenantId: managed tenant → the
+ /// permanent-Pro tenant whose owned self-service group (msp-{tid}) it belongs to. Built from ONE
+ /// scan of the admin-scale Tenant Groups table plus a raw config read per owning tenant, cached
+ /// process-wide for , so projecting the pointer onto a configuration costs a dictionary
+ /// lookup — never a per-tenant table query on the agent or portal hot path.
+ ///
+ /// Invariants:
+ ///
+ /// Only OWNED groups (meta row with OwnerTenantId — the self-service delegation model) confer; operator-created groups never do.
+ /// Only a PERMANENT Pro owner confers (): a trial MSP may delegate but does not upgrade its customers.
+ /// The owner's tier is read RAW from the repository, never through — that service consults this index, and two tenants managing each other would otherwise recurse.
+ /// Fail-soft ⇒ fail-closed: any storage error yields an empty map (nobody is projected as managed, i.e. Community values) with a Warning, cached briefly so a hiccup does not hammer storage.
+ /// Owners are visited in group-id order so a tenant managed by two Pro tenants resolves deterministically.
+ ///
+ ///
+ public class ManagedTenantProIndex
+ {
+ private const string CacheKey = "managed-tenant-pro-index";
+ /// Matches the tenant-config cache TTL — the staleness budget plan changes already accept.
+ internal static readonly TimeSpan Ttl = TimeSpan.FromMinutes(5);
+ /// A failed build is cached briefly: fail-closed without a storage storm.
+ internal static readonly TimeSpan FailureTtl = TimeSpan.FromSeconds(30);
+
+ private static readonly IReadOnlyDictionary Empty =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ private readonly IAdminRepository _adminRepo;
+ private readonly IConfigRepository _configRepo;
+ private readonly IMemoryCache _cache;
+ private readonly ILogger _logger;
+
+ public ManagedTenantProIndex(
+ IAdminRepository adminRepo,
+ IConfigRepository configRepo,
+ IMemoryCache cache,
+ ILogger logger)
+ {
+ _adminRepo = adminRepo;
+ _configRepo = configRepo;
+ _cache = cache;
+ _logger = logger;
+ }
+
+ /// Test seam for subclasses that never touch storage (see ).
+ protected ManagedTenantProIndex()
+ {
+ _adminRepo = null!;
+ _configRepo = null!;
+ _cache = null!;
+ _logger = null!;
+ }
+
+ ///
+ /// An index that projects NO tenant as managed. For tests whose subject is not delegation and for
+ /// the configuration service's storage-free test constructor — never registered in production DI.
+ ///
+ public static ManagedTenantProIndex None { get; } = new NoneIndex();
+
+ /// The permanent-Pro tenant currently conferring Pro on , or null.
+ public virtual async Task GetConferringOwnerAsync(string? tenantId)
+ {
+ if (string.IsNullOrWhiteSpace(tenantId))
+ return null;
+ var map = await GetMapAsync();
+ return map.TryGetValue(tenantId, out var owner) ? owner : null;
+ }
+
+ /// Drops the cached map on THIS instance; other instances refresh within .
+ public virtual void Invalidate() => _cache.Remove(CacheKey);
+
+ private async Task> GetMapAsync()
+ {
+ if (_cache.TryGetValue(CacheKey, out IReadOnlyDictionary? cached) && cached != null)
+ return cached;
+
+ var (map, ok) = await BuildAsync();
+ _cache.Set(CacheKey, map, ok ? Ttl : FailureTtl);
+ return map;
+ }
+
+ private async Task<(IReadOnlyDictionary Map, bool Ok)> BuildAsync()
+ {
+ try
+ {
+ var groups = await _adminRepo.GetAllTenantGroupsAsync();
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var ownerTierCache = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var group in groups
+ .Where(g => !string.IsNullOrWhiteSpace(g.OwnerTenantId) && g.TenantIds.Count > 0)
+ .OrderBy(g => g.GroupId, StringComparer.Ordinal))
+ {
+ var owner = group.OwnerTenantId!.Trim().ToLowerInvariant();
+ if (!ownerTierCache.TryGetValue(owner, out var ownerIsPermanentPro))
+ {
+ // RAW read on purpose — see the class remarks (recursion through the config service).
+ var ownerConfig = await _configRepo.GetTenantConfigurationAsync(owner);
+ ownerIsPermanentPro = ownerConfig != null && FeatureEntitlementCatalog.IsPermanentProTier(ownerConfig.PlanTier);
+ ownerTierCache[owner] = ownerIsPermanentPro;
+ }
+ if (!ownerIsPermanentPro)
+ continue;
+
+ foreach (var tenantId in group.TenantIds)
+ {
+ var managed = tenantId.Trim().ToLowerInvariant();
+ if (managed.Length == 0 || string.Equals(managed, owner, StringComparison.OrdinalIgnoreCase))
+ continue;
+ result.TryAdd(managed, owner);
+ }
+ }
+
+ return (result, true);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "[ProConferral] Managed-tenant index build failed — no tenant is projected as managed until the next rebuild (fail-closed)");
+ return (Empty, false);
+ }
+ }
+
+ private sealed class NoneIndex : ManagedTenantProIndex
+ {
+ public override Task GetConferringOwnerAsync(string? tenantId) => Task.FromResult(null);
+ public override void Invalidate() { }
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs
index 12661638..daa6ccb3 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/Offboarding/TenantOffboardingHandler.cs
@@ -203,6 +203,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
private readonly ITenantCustomsArchiveRepository _customsArchive;
private readonly IConfigRepository _configRepo;
private readonly IOffboardFarewellEmailSender _farewellEmail;
+ private readonly ProConferralService _proConferral;
private readonly ILogger _logger;
public TenantOffboardingHandler(
@@ -219,6 +220,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
ITenantCustomsArchiveRepository customsArchive,
IConfigRepository configRepo,
IOffboardFarewellEmailSender farewellEmail,
+ ProConferralService proConferral,
ILogger logger)
{
_auditRepo = auditRepo;
@@ -234,6 +236,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
_customsArchive = customsArchive;
_configRepo = configRepo;
_farewellEmail = farewellEmail;
+ _proConferral = proConferral;
_logger = logger;
}
@@ -654,6 +657,15 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
throw; // worker dequeues + eventually poisons
}
+ // Conferred Pro: a permanent-Pro managing tenant leaves its customers behind. Their grace anchor
+ // must be stamped BEFORE the owned group (the only record of who was managed) is wiped below.
+ if (currentConfig != null && Security.FeatureEntitlementCatalog.IsPermanentProTier(currentConfig.PlanTier))
+ {
+ var stamped = await _proConferral.RecordLossForOwnedGroupAsync(tenantId, "manager-offboarded");
+ if (stamped > 0)
+ _logger.LogInformation("Offboarding tenant={Tenant}: conferred Pro ended for {Count} managed tenant(s)", tenantId, stamped);
+ }
+
// 2.D-pass-1 — SafeWipe all tenant-scoped tables, including AuditLogs.
var deletedCounts = new Dictionary(StringComparer.Ordinal);
await SafeWipeAllTenantTablesAsync(deletedCounts, tenantId, ct);
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/ProConferralService.cs b/src/Backend/AutopilotMonitor.Functions/Services/ProConferralService.cs
new file mode 100644
index 00000000..21b8ca53
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions/Services/ProConferralService.cs
@@ -0,0 +1,133 @@
+using System;
+using System.Threading.Tasks;
+using AutopilotMonitor.Functions.Security;
+using AutopilotMonitor.Shared;
+using AutopilotMonitor.Shared.DataAccess;
+using Microsoft.Extensions.Logging;
+
+namespace AutopilotMonitor.Functions.Services
+{
+ ///
+ /// The WRITE side of conferred Pro ("Pro (MSP)"). The edition itself is never written — it is projected at
+ /// read time from group membership (). What must be written is the
+ /// retention downgrade grace anchor: a managed tenant that raised its retention under conferred Pro would
+ /// otherwise be clamped to the Community cap (hard deletes) the moment the relationship ends. Every path
+ /// that ends a Pro-conferring relationship calls one of the record methods:
+ ///
+ /// self-service removal / customer revoke and the Global Admin removing a tenant from an owned group →
+ /// the managing tenant losing its permanent Pro tier (plan endpoint) and its offboarding →
+ ///
+ /// Accepting an invitation only needs the caches dropped ().
+ ///
+ public class ProConferralService
+ {
+ private const string BackupSource = "delegation";
+
+ private readonly IAdminRepository _adminRepo;
+ private readonly TenantConfigurationService _configs;
+ private readonly ManagedTenantProIndex _index;
+ private readonly ILogger _logger;
+ private readonly TimeProvider _time;
+
+ public ProConferralService(
+ IAdminRepository adminRepo,
+ TenantConfigurationService configs,
+ ManagedTenantProIndex index,
+ ILogger logger)
+ : this(adminRepo, configs, index, logger, TimeProvider.System)
+ {
+ }
+
+ /// Test seam — inject a fake .
+ public ProConferralService(
+ IAdminRepository adminRepo,
+ TenantConfigurationService configs,
+ ManagedTenantProIndex index,
+ ILogger logger,
+ TimeProvider time)
+ {
+ _adminRepo = adminRepo;
+ _configs = configs;
+ _index = index;
+ _logger = logger;
+ _time = time;
+ }
+
+ ///
+ /// A managed tenant joined or left a group on THIS instance: drop the index and the tenant's cached
+ /// configuration so the next read reflects the new standing immediately here (other instances follow
+ /// within the cache TTL).
+ ///
+ public virtual Task NotifyDelegationChangedAsync(string managedTenantId)
+ {
+ _index.Invalidate();
+ _configs.InvalidateCache(managedTenantId.ToLowerInvariant());
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// A single managed tenant left 's owned group. Stamps the grace anchor when
+ /// the home tenant is a permanent-Pro tenant (a trial MSP conferred nothing, so nothing is lost).
+ /// Returns whether an anchor was written.
+ ///
+ public virtual async Task RecordLossAsync(string managedTenantId, string homeTenantId, string reason)
+ {
+ var home = homeTenantId.ToLowerInvariant();
+ var managed = managedTenantId.ToLowerInvariant();
+ try
+ {
+ var homeConfig = await _configs.GetConfigurationIfExistsAsync(home);
+ var conferred = homeConfig != null && FeatureEntitlementCatalog.IsPermanentProTier(homeConfig.PlanTier);
+ if (!conferred)
+ return false;
+ return await StampAsync(managed, home, reason);
+ }
+ finally
+ {
+ await NotifyDelegationChangedAsync(managed);
+ }
+ }
+
+ ///
+ /// Every tenant in 's owned group loses conferred Pro at once (the home
+ /// tenant lost its permanent Pro tier or is being offboarded). The caller has established that the home
+ /// tenant WAS a permanent-Pro tenant before the change. Returns the number of anchors written.
+ ///
+ public virtual async Task RecordLossForOwnedGroupAsync(string homeTenantId, string reason)
+ {
+ var home = homeTenantId.ToLowerInvariant();
+ var groupId = Constants.TenantGroupIds.ForHomeTenant(home);
+ var members = await _adminRepo.GetGroupTenantsAsync(groupId);
+ var stamped = 0;
+ foreach (var member in members)
+ {
+ var managed = member.ToLowerInvariant();
+ if (string.Equals(managed, home, StringComparison.OrdinalIgnoreCase))
+ continue;
+ if (await StampAsync(managed, home, reason))
+ stamped++;
+ _configs.InvalidateCache(managed);
+ }
+ _index.Invalidate();
+ return stamped;
+ }
+
+ private async Task StampAsync(string managedTenantId, string homeTenantId, string reason)
+ {
+ // Fresh read: the anchor is a read-modify-write on the whole row; a stale cached instance would
+ // rewind fields another writer just changed (the app-homing failure mode).
+ var config = await _configs.GetConfigurationFreshAsync(managedTenantId);
+ if (config == null)
+ return false;
+
+ var nowUtc = _time.GetUtcNow().UtcDateTime;
+ config.ProDowngradedUtc = nowUtc;
+ config.UpdatedBy = $"delegation:{homeTenantId}";
+ await _configs.SaveConfigurationAsync(config, BackupSource, reason);
+ _logger.LogInformation(
+ "[ProConferral] {Managed} lost Pro conferred by {Home} ({Reason}); retention grace anchor set to {Anchor:O}",
+ managedTenantId, homeTenantId, reason, nowUtc);
+ return true;
+ }
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs
index 17c69f48..f9840b86 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigPatchService.cs
@@ -112,7 +112,8 @@ namespace AutopilotMonitor.Functions.Services
"LastUpdated", "UpdatedBy", "OnboardedAt", "OnboardedBy",
"HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince",
"PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy",
- "ProDowngradedUtc", "MaxDelegatedTenantsOverride", "McpUsagePlanOverride",
+ "ProDowngradedUtc", "MaxDelegatedTenantsOverride", "McpUsagePlanOverride", "PayingCustomer",
+ "ManagedByProTenantId",
};
///
@@ -159,7 +160,7 @@ namespace AutopilotMonitor.Functions.Services
"HomedAppClientId", "LastAuthClientId", "LastAuthClientIdSince",
"OnboardedBy", "OnboardedAt",
"PlanTier", "TrialExpiresUtc", "TrialStartedUtc", "TrialConsumed", "TrialGrantedBy",
- "ProDowngradedUtc", "MaxDelegatedTenantsOverride", "McpUsagePlanOverride",
+ "ProDowngradedUtc", "MaxDelegatedTenantsOverride", "McpUsagePlanOverride", "PayingCustomer",
};
private static readonly Dictionary ModelProperties =
@@ -507,8 +508,9 @@ namespace AutopilotMonitor.Functions.Services
"HomedAppClientId" => "system-owned — written only by the app-homing flow",
"LastAuthClientId" or "LastAuthClientIdSince" => "system-owned auth provenance",
"PlanTier" or "TrialExpiresUtc" or "TrialStartedUtc" or "TrialConsumed" or "TrialGrantedBy"
- or "MaxDelegatedTenantsOverride" or "McpUsagePlanOverride"
+ or "MaxDelegatedTenantsOverride" or "McpUsagePlanOverride" or "PayingCustomer"
=> "mutable only via the dedicated plan/trial endpoints",
+ "ManagedByProTenantId" => "read-time projection of the delegation state — never stored",
_ => "not writable via the field patch",
};
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs
index bfb127c4..4b83a1b9 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs
@@ -12,20 +12,49 @@ namespace AutopilotMonitor.Functions.Services
///
/// Service for managing tenant-specific configuration.
/// Caching and business logic layer — delegates storage to IConfigRepository.
+ ///
+ /// Every configuration that leaves this service through a READ path carries the load-time projection
+ /// (see ): it is
+ /// applied on cache hits and misses alike, so its staleness is bounded by the index TTL alone, and it is
+ /// never applied to a synthesized default (a tenant without a row can never be projected as Pro).
///
public class TenantConfigurationService
{
private readonly IConfigRepository _configRepo;
private readonly ILogger _logger;
private readonly IMemoryCache _cache;
+ private readonly ManagedTenantProIndex _proIndex;
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
- public TenantConfigurationService(IConfigRepository configRepo, ILogger logger, IMemoryCache cache)
+ public TenantConfigurationService(
+ IConfigRepository configRepo,
+ ILogger logger,
+ IMemoryCache cache,
+ ManagedTenantProIndex proIndex)
{
_configRepo = configRepo;
_logger = logger;
_cache = cache;
+ _proIndex = proIndex;
+ }
+
+ ///
+ /// Test seam — no delegation index: no tenant is ever projected as managed. Production DI always
+ /// resolves the four-argument constructor (the longest satisfiable one).
+ ///
+ public TenantConfigurationService(IConfigRepository configRepo, ILogger logger, IMemoryCache cache)
+ : this(configRepo, logger, cache, ManagedTenantProIndex.None)
+ {
+ }
+
+ /// Applies the read-time projection (see the class remarks). Idempotent; null passes through.
+ private async Task ProjectAsync(TenantConfiguration? config)
+ {
+ if (config == null)
+ return null;
+ config.ManagedByProTenantId = await _proIndex.GetConferringOwnerAsync(config.TenantId);
+ return config;
}
///
@@ -43,7 +72,7 @@ namespace AutopilotMonitor.Functions.Services
if (_cache.TryGetValue(cacheKey, out TenantConfiguration? cachedConfig) && cachedConfig != null)
{
- return cachedConfig;
+ return (await ProjectAsync(cachedConfig))!;
}
try
@@ -54,7 +83,7 @@ namespace AutopilotMonitor.Functions.Services
if (config != null)
{
_cache.Set(cacheKey, config, CacheDuration);
- return config;
+ return (await ProjectAsync(config))!;
}
// Configuration not found - create and save default immediately
@@ -197,13 +226,13 @@ namespace AutopilotMonitor.Functions.Services
var cacheKey = $"tenant-config:{tenantId}";
if (_cache.TryGetValue(cacheKey, out TenantConfiguration? cachedConfig) && cachedConfig != null)
- return cachedConfig;
+ return await ProjectAsync(cachedConfig);
var config = await _configRepo.GetTenantConfigurationAsync(tenantId);
if (config != null)
_cache.Set(cacheKey, config, CacheDuration);
- return config;
+ return await ProjectAsync(config);
}
///
@@ -224,7 +253,7 @@ namespace AutopilotMonitor.Functions.Services
if (config != null)
_cache.Set($"tenant-config:{tenantId}", config, CacheDuration);
- return config;
+ return await ProjectAsync(config);
}
///
@@ -261,7 +290,10 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
- return await _configRepo.GetAllTenantConfigurationsAsync();
+ var configs = await _configRepo.GetAllTenantConfigurationsAsync();
+ foreach (var config in configs)
+ await ProjectAsync(config);
+ return configs;
}
catch (Exception ex)
{
@@ -279,7 +311,10 @@ namespace AutopilotMonitor.Functions.Services
{
try
{
- return await _configRepo.GetTenantConfigurationsPageAsync(pageSize, continuation);
+ var page = await _configRepo.GetTenantConfigurationsPageAsync(pageSize, continuation);
+ foreach (var config in page.Items)
+ await ProjectAsync(config);
+ return page;
}
catch (Exception ex)
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs
index 62debc50..776d725c 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantEntitlementService.cs
@@ -38,41 +38,55 @@ namespace AutopilotMonitor.Functions.Services
_time = time;
}
+ private static readonly EditionResolution CommunityResolution =
+ new(TenantEdition.Community, EditionSource.Community, OwnPro: false);
+
///
- /// Resolves the tenant's effective edition. Uses the strict point-read
- /// () so an
+ /// Resolves the tenant's effective edition, its source and its own standing. 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)
+ public virtual async Task GetResolutionAsync(string? tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
- return TenantEdition.Community;
+ return CommunityResolution;
try
{
var config = await _configService.GetConfigurationIfExistsAsync(tenantId);
if (config == null)
- return TenantEdition.Community;
+ return CommunityResolution;
- return ResolveEdition(config, _time.GetUtcNow().UtcDateTime);
+ return Resolve(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;
+ return CommunityResolution;
}
}
- /// Resolves the tenant's effective entitlement set (fail-closed → Community values).
+ /// The effective edition only — see .
+ public virtual async Task GetEditionAsync(string? tenantId)
+ => (await GetResolutionAsync(tenantId)).Edition;
+
+ ///
+ /// Resolves the tenant's effective entitlement set (fail-closed → Community values). Conferred Pro
+ /// yields the Pro set WITHOUT the delegation right (see ).
+ ///
public virtual async Task GetEntitlementsAsync(string? tenantId)
- => FeatureEntitlementCatalog.Get(await GetEditionAsync(tenantId));
+ => FeatureEntitlementCatalog.Get(await GetResolutionAsync(tenantId));
+
+ /// Pure resolution (edition + source + own standing) for callers that already hold the config.
+ public static EditionResolution Resolve(TenantConfiguration config, DateTime nowUtc)
+ => FeatureEntitlementCatalog.Resolve(config, nowUtc);
/// 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);
+ => FeatureEntitlementCatalog.ResolveEdition(config, nowUtc);
///
/// Whether the OOBE bootstrap feature is effectively enabled for this config: included in
@@ -82,17 +96,18 @@ namespace AutopilotMonitor.Functions.Services
/// the feature off automatically.
///
public static bool IsBootstrapEnabled(TenantConfiguration config, DateTime nowUtc)
- => FeatureEntitlementCatalog.Get(ResolveEdition(config, nowUtc)).BootstrapIncluded
+ => FeatureEntitlementCatalog.Get(Resolve(config, nowUtc)).BootstrapIncluded
|| config.BootstrapTokenEnabled;
///
/// The effective delegated ("MSP") tenant slot limit: the Global Admin override when set (it applies
/// regardless of edition — a package can be provisioned ahead of the plan flip; USING delegation still
- /// needs Pro via DelegatedAdminAllowed), else the edition's catalog value (Community 0, Pro 2).
+ /// needs Pro via DelegatedAdminAllowed), else the catalog value of the tenant's OWN standing
+ /// (Community 0, Pro 2; conferred Pro 0 — a managed tenant gets no slots from its manager).
///
public static int GetMaxDelegatedTenants(TenantConfiguration config, DateTime nowUtc)
=> config.MaxDelegatedTenantsOverride
- ?? FeatureEntitlementCatalog.Get(ResolveEdition(config, nowUtc)).MaxDelegatedTenants;
+ ?? FeatureEntitlementCatalog.Get(Resolve(config, nowUtc)).MaxDelegatedTenants;
/// Cached read-time variant of . No row / any error ⇒ 0 (fail-closed).
public virtual async Task GetMaxDelegatedTenantsAsync(string? homeTenantId)
@@ -122,7 +137,7 @@ namespace AutopilotMonitor.Functions.Services
public static string GetMcpUsagePlanName(TenantConfiguration config, DateTime nowUtc)
{
var overridePlan = NormalizePlanName(config.McpUsagePlanOverride);
- return overridePlan ?? FeatureEntitlementCatalog.Get(ResolveEdition(config, nowUtc)).McpUsagePlanName;
+ return overridePlan ?? FeatureEntitlementCatalog.Get(Resolve(config, nowUtc)).McpUsagePlanName;
}
/// Trimmed lower-case plan name, or null for blank input.
@@ -159,7 +174,7 @@ namespace AutopilotMonitor.Functions.Services
/// opt-in toggle ().
///
public static bool IsUnrestrictedModeActive(TenantConfiguration config, DateTime nowUtc)
- => FeatureEntitlementCatalog.Get(ResolveEdition(config, nowUtc)).UnrestrictedModeAvailable
+ => FeatureEntitlementCatalog.Get(Resolve(config, nowUtc)).UnrestrictedModeAvailable
&& config.UnrestrictedModeEnabled
&& config.UnrestrictedMode;
diff --git a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts
index 37fb17db..1ea1b138 100644
--- a/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts
+++ b/src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts
@@ -3471,6 +3471,8 @@ export interface SetTenantPlanTierResponse {
trialConsumed: boolean;
/** Effective edition after the change, lowercase ("community" | "pro"). */
effectiveEdition: string;
+ /** Source of the effective edition: "community" | "plan" | "trial" | "msp" (conferred by the managing tenant). */
+ editionSource: string;
/** End of the retention downgrade grace window, or null — the key is omitted when null. */
retentionGraceEndsUtc?: string;
/** Effective delegated (MSP) tenant slot limit after the change (override or plan entitlement). */
@@ -3481,6 +3483,8 @@ export interface SetTenantPlanTierResponse {
mcpUsagePlan: string;
/** The Global Admin MCP usage-plan override (a SectionUsagePlans plan name); omitted when the edition default applies. */
mcpUsagePlanOverride?: string;
+ /** Sales bookkeeping: whether the tenant pays for its plan. No entitlement effect. */
+ payingCustomer: boolean;
}
/** Distinct-CVE counts grouped by their highest CVSS severity band. */
@@ -3761,6 +3765,10 @@ export interface TenantConfiguration {
maxDelegatedTenantsOverride?: number;
/** Global-Admin override of the tenant's MCP usage plan — the NAME of a SectionUsagePlans plan (AdminConfiguration.PlanTierDefinitionsJson), e.g. "msp". Applies to the WHOLE tenant: every member's default user plan (a per-user McpUsers override still wins) AND the organization-wide windows. Null/blank = the edition default (community/pro). Does NOT change the edition — Pro feature gates stay on PlanTier. Mutable only via the plan endpoint (validated against the plan definitions there). Backend-only: not delivered to the agent (no ConfigVersion impact). */
mcpUsagePlanOverride?: string;
+ /** Sales/support bookkeeping: whether this tenant PAYS for its plan (as opposed to a Pro tier assigned by support, a trial, or Pro conferred by a managing tenant). Carries NO entitlement — the effective edition is resolved from PlanTier/TrialExpiresUtc/ManagedByProTenantId only. Mutable only via the plan endpoint (Global Admin); shown in the operator tenant list, never in feature flags or any customer-facing surface. Backend-only: not delivered to the agent (no ConfigVersion impact). */
+ payingCustomer: boolean;
+ /** READ-TIME PROJECTION, NEVER STORED: the permanent-Pro tenant that currently manages this tenant through a self-service delegation (member of that tenant's owned msp-{tid} Tenant Group), or null. Populated by the backend's configuration loader on every read path from the Tenant Groups index; the table repository has no column for it and the patch endpoint denies it. While set, the tenant's effective edition is Pro with source "msp" (no delegation right of its own) — the delegation ending, the managing tenant losing its permanent Pro tier, or its offboarding all revert this automatically because nothing is written here. Not delivered to the agent (no ConfigVersion impact). */
+ managedByProTenantId?: string;
/** Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*") Comma-separated list */
manufacturerWhitelist: string;
/** Hardware whitelist: Allowed models (supports wildcards like "Latitude*") Comma-separated list Default: "*" (all models allowed) */
@@ -3956,6 +3964,8 @@ export interface TenantFeatureFlagsResponse {
unrestrictedMode: boolean;
/** Resolved edition, lowercase ("community" / "pro"). */
edition: string;
+ /** Why: "community" | "plan" (own Pro tier) | "trial" | "msp" (Pro conferred by the managing tenant — shown as "Pro (MSP)"). */
+ editionSource: string;
isTrial: boolean;
/** Absent unless the tenant is on an active trial. */
trialExpiresUtc?: string;
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs
index bcb27dd4..8a4c637f 100644
--- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs
@@ -213,6 +213,28 @@ namespace AutopilotMonitor.Shared.Models
///
public string? McpUsagePlanOverride { get; set; }
+ ///
+ /// Sales/support bookkeeping: whether this tenant PAYS for its plan (as opposed to a Pro tier
+ /// assigned by support, a trial, or Pro conferred by a managing tenant). Carries NO entitlement
+ /// — the effective edition is resolved from PlanTier/TrialExpiresUtc/ManagedByProTenantId
+ /// only. Mutable only via the plan endpoint (Global Admin); shown in the operator tenant list,
+ /// never in feature flags or any customer-facing surface. Backend-only: not delivered to the
+ /// agent (no ConfigVersion impact).
+ ///
+ public bool PayingCustomer { get; set; }
+
+ ///
+ /// READ-TIME PROJECTION, NEVER STORED: the permanent-Pro tenant that currently manages this
+ /// tenant through a self-service delegation (member of that tenant's owned msp-{tid}
+ /// Tenant Group), or null. Populated by the backend's configuration loader on every read path
+ /// from the Tenant Groups index; the table repository has no column for it and the patch
+ /// endpoint denies it. While set, the tenant's effective edition is Pro with source "msp"
+ /// (no delegation right of its own) — the delegation ending, the managing tenant losing its
+ /// permanent Pro tier, or its offboarding all revert this automatically because nothing is
+ /// written here. Not delivered to the agent (no ConfigVersion impact).
+ ///
+ public string? ManagedByProTenantId { get; set; }
+
///
/// Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*")
/// Comma-separated list
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantFeatureFlagsApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantFeatureFlagsApiModels.cs
index d17bb885..26dab6be 100644
--- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantFeatureFlagsApiModels.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantFeatureFlagsApiModels.cs
@@ -27,6 +27,8 @@ namespace AutopilotMonitor.Shared.Models
public bool UnrestrictedMode { get; set; }
/// Resolved edition, lowercase ("community" / "pro").
public string Edition { get; set; } = string.Empty;
+ /// Why: "community" | "plan" (own Pro tier) | "trial" | "msp" (Pro conferred by the managing tenant — shown as "Pro (MSP)").
+ public string EditionSource { get; set; } = string.Empty;
public bool IsTrial { get; set; }
/// Absent unless the tenant is on an active trial.
public DateTime? TrialExpiresUtc { get; set; }
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/ConfigApiModels.cs b/src/Shared/AutopilotMonitor.Shared/Models/ConfigApiModels.cs
index 06a06c2d..79085eec 100644
--- a/src/Shared/AutopilotMonitor.Shared/Models/ConfigApiModels.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Models/ConfigApiModels.cs
@@ -202,6 +202,9 @@ namespace AutopilotMonitor.Shared.Models
/// Effective edition after the change, lowercase ("community" | "pro").
public string EffectiveEdition { get; set; } = default!;
+ /// Source of the effective edition: "community" | "plan" | "trial" | "msp" (conferred by the managing tenant).
+ public string EditionSource { get; set; } = default!;
+
/// End of the retention downgrade grace window, or null — the key is omitted when null.
public DateTime? RetentionGraceEndsUtc { get; set; }
@@ -216,6 +219,9 @@ namespace AutopilotMonitor.Shared.Models
/// The Global Admin MCP usage-plan override (a SectionUsagePlans plan name); omitted when the edition default applies.
public string? McpUsagePlanOverride { get; set; }
+
+ /// Sales bookkeeping: whether the tenant pays for its plan. No entitlement effect.
+ public bool PayingCustomer { get; set; }
}
///
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 6b9a249d..26c3d882 100644
--- a/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx
+++ b/src/Web/autopilot-monitor-web/app/admin/components/TenantManagementSection.tsx
@@ -15,6 +15,14 @@ import { OffboardTenantConfirmDialog } from "./OffboardTenantConfirmDialog";
import { useCanMutatePlatform } from "@/hooks/useCanMutatePlatform";
import { matchesTenantSearch, notificationEmailFor, parseTenantSearch } from "./tenantSearch";
+/** "Pro (MSP)" / "Pro (Trial)" / "Pro" / "Community" for the plan-save confirmation. */
+function effectiveEditionLabel(edition: unknown, source: unknown): string {
+ if (edition !== "pro") return "Community";
+ if (source === "msp") return "Pro (MSP)";
+ if (source === "trial") return "Pro (Trial)";
+ return "Pro";
+}
+
export interface TenantConfiguration {
tenantId: string;
domainName: string;
@@ -54,6 +62,13 @@ export interface TenantConfiguration {
* default plan + the organization windows); null/undefined = edition default. Managed via PATCH plan.
*/
mcpUsagePlanOverride?: string | null;
+ /** Sales bookkeeping: the tenant pays for its plan. No entitlement effect. Managed via PATCH plan. */
+ payingCustomer?: boolean;
+ /**
+ * Read-time projection (never stored): the permanent-Pro tenant whose delegation confers Pro on this
+ * one, or null. While set the effective edition is "Pro (MSP)".
+ */
+ managedByProTenantId?: string | null;
/**
* Dual app-reg homing: null/undefined = legacy app. Typed explicitly so a payload refactor
* cannot silently drop the field on the generic PUT round-trip (absent ⇒ backend resets to
@@ -311,6 +326,7 @@ function TenantManagementSectionInner({
trialExpiresUtc: tenant.trialExpiresUtc ?? null,
maxDelegatedTenants: tenant.maxDelegatedTenantsOverride ?? null,
mcpUsagePlan: tenant.mcpUsagePlanOverride ?? null,
+ payingCustomer: tenant.payingCustomer === true,
}),
});
@@ -327,11 +343,12 @@ function TenantManagementSectionInner({
trialConsumed: result.trialConsumed ?? t.trialConsumed,
maxDelegatedTenantsOverride: result.maxDelegatedTenantsOverride ?? null,
mcpUsagePlanOverride: result.mcpUsagePlanOverride ?? null,
+ payingCustomer: result.payingCustomer === true,
});
setSlotUsage((prev) => (prev ? { ...prev, limit: result.maxDelegatedTenants ?? prev.limit, overrideLimit: result.maxDelegatedTenantsOverride ?? undefined } : prev));
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}`);
+ setSuccessMessage(`Plan saved — effective edition: ${effectiveEditionLabel(result.effectiveEdition, result.editionSource)}`);
setTimeout(() => setSuccessMessage(null), 4000);
} catch (err) {
if (err instanceof TokenExpiredError) {
@@ -668,7 +685,14 @@ function TenantManagementSectionInner({
Waitlist
)}
- {tenant.planTier === "pro" || tenant.planTier === "enterprise" ? (
+ {tenant.managedByProTenantId ? (
+
+ Pro (MSP)
+
+ ) : tenant.planTier === "pro" || tenant.planTier === "enterprise" ? (
Pro
@@ -677,6 +701,11 @@ function TenantManagementSectionInner({
Community
)}
+ {tenant.payingCustomer && (
+
+ Paying
+
+ )}
{tenant.validateAutopilotDevice && (
Ready
@@ -841,14 +870,18 @@ function TenantManagementSectionInner({
const isProTier = editingTenant.planTier === "pro" || editingTenant.planTier === "enterprise";
const trialActive = !!editingTenant.trialExpiresUtc &&
new Date(editingTenant.trialExpiresUtc).getTime() > nowMs;
- const effective = isProTier || trialActive ? "Pro" : "Community";
+ const viaMsp = !!editingTenant.managedByProTenantId;
+ const effective = viaMsp || isProTier || trialActive ? "Pro" : "Community";
return (
-
- Effective: {effective}{!isProTier && trialActive ? " (Trial)" : ""}
+
+ Effective: {effective}{viaMsp ? " (MSP)" : !isProTier && trialActive ? " (Trial)" : ""}
);
})()}
@@ -910,6 +943,20 @@ function TenantManagementSectionInner({
Set a date to grant/extend a Pro trial; clear to end it. Saving does not reset trial consumption.
+
diff --git a/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts b/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts
index be5a5cf2..8f859225 100644
--- a/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts
+++ b/src/Web/autopilot-monitor-web/app/settings/__tests__/sectionFieldMap.test.ts
@@ -61,7 +61,8 @@ const SERVER_DENIED_FIELDS = new Set([
"lastUpdated", "updatedBy", "onboardedAt", "onboardedBy",
"homedAppClientId", "lastAuthClientId", "lastAuthClientIdSince",
"planTier", "trialExpiresUtc", "trialStartedUtc", "trialConsumed", "trialGrantedBy",
- "proDowngradedUtc", "maxDelegatedTenantsOverride", "mcpUsagePlanOverride",
+ "proDowngradedUtc", "maxDelegatedTenantsOverride", "mcpUsagePlanOverride", "payingCustomer",
+ "managedByProTenantId",
]);
describe("SECTION_FIELD_MAP parity", () => {
diff --git a/src/Web/autopilot-monitor-web/app/settings/components/EditionBadge.tsx b/src/Web/autopilot-monitor-web/app/settings/components/EditionBadge.tsx
index 8dfdc5a5..4994a981 100644
--- a/src/Web/autopilot-monitor-web/app/settings/components/EditionBadge.tsx
+++ b/src/Web/autopilot-monitor-web/app/settings/components/EditionBadge.tsx
@@ -1,12 +1,12 @@
"use client";
import Link from "next/link";
-import { editionLabel } from "@/lib/edition";
+import { editionLabel, isProViaMsp } from "@/lib/edition";
import { useTenantConfig } from "../TenantConfigContext";
/**
* Edition badge for the settings header.
- * - "Pro" / "Pro Trial — X days left" / "Community"
+ * - "Pro" / "Pro (MSP)" / "Pro Trial — X days left" / "Community"
* - For Community tenants, an "Upgrade plan" link to the Plan section, where the full picture
* (what Pro adds) and the trial CTA live. The badge itself only reports status.
*/
@@ -26,9 +26,11 @@ export default function EditionBadge() {
}`}
title={
isPro
- ? editionInfo.isTrial
- ? "Pro trial is active — all Pro features are unlocked."
- : "This tenant is on the Pro plan."
+ ? isProViaMsp(editionInfo)
+ ? "Pro is included through the organization that manages this tenant. It ends with that delegation."
+ : editionInfo.isTrial
+ ? "Pro trial is active — all Pro features are unlocked."
+ : "This tenant is on the Pro plan."
: "This tenant is on the Community plan."
}
>
diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionDelegatedAccess.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionDelegatedAccess.tsx
index ebd26ea5..ed8e8ab9 100644
--- a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionDelegatedAccess.tsx
+++ b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionDelegatedAccess.tsx
@@ -8,6 +8,7 @@ import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"
import { api } from "@/lib/api";
import { SectionCardHeader } from "@/components/SectionCardHeader";
import { DOCS_PATHS } from "@/lib/docsPaths";
+import { isProViaMsp } from "@/lib/edition";
import { buildInviteLink, describeDelegationError, holdRemainingLabel, invitationStatusLabel } from "@/lib/delegations";
import type {
DelegationAssigneeListResponse,
@@ -191,6 +192,11 @@ export function SectionDelegatedAccess() {
docsPath={DOCS_PATHS.delegatedAccess}
/>
+ {isProViaMsp(editionInfo) && (
+
+ Your Pro plan is included through one of these organizations. It ends when that delegation ends.
+
+ )}
{loading && !managers ? (
Loading…
) : !managers || managers.managers.length === 0 ? (
@@ -253,7 +259,7 @@ export function SectionDelegatedAccess() {
tone="purple"
iconPath="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
title="Tenants you manage"
- subtitle="Invite customer tenants with a single-use link; their admin accepts it and your assigned users get read-only access. Each managed tenant's AI (MCP) usage draws on that tenant's own plan."
+ subtitle="Invite customer tenants with a single-use link; their admin accepts it and your assigned users get read-only access. Tenants you manage are on Pro for as long as you manage them, and each managed tenant's AI (MCP) usage draws on that tenant's own plan."
docsPath={DOCS_PATHS.delegatedAccess}
trailing={slots && (
diff --git a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionPlan.tsx b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionPlan.tsx
index 24c79075..3252f0ad 100644
--- a/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionPlan.tsx
+++ b/src/Web/autopilot-monitor-web/app/settings/tenant/sections/SectionPlan.tsx
@@ -3,7 +3,7 @@
import { useState } from "react";
import Link from "next/link";
import { useTenantConfig } from "../../TenantConfigContext";
-import { missingContactProfileParts, trialDaysLeft } from "@/lib/edition";
+import { isProViaMsp, missingContactProfileParts, trialDaysLeft } from "@/lib/edition";
import { PlanCards } from "@/components/plans/PlanCards";
import { SITE_URL } from "@/utils/config";
import { SectionCardHeader } from "@/components/SectionCardHeader";
@@ -31,6 +31,7 @@ export function SectionPlan() {
const [confirming, setConfirming] = useState(false);
const isPro = editionInfo.edition === "pro";
+ const viaMsp = isProViaMsp(editionInfo);
const daysLeft = editionInfo.isTrial ? trialDaysLeft(editionInfo.trialExpiresUtc) : 0;
const trialConsumed = !isPro && !editionInfo.trialAvailable;
const canStartTrial =
@@ -44,8 +45,15 @@ export function SectionPlan() {
) : undefined;
const proBadge = isPro ? (
-
- {editionInfo.isTrial ? `Trial — ${daysLeft} day${daysLeft === 1 ? "" : "s"} left` : "Current plan"}
+
+ {viaMsp
+ ? "Current plan — via MSP"
+ : editionInfo.isTrial
+ ? `Trial — ${daysLeft} day${daysLeft === 1 ? "" : "s"} left`
+ : "Current plan"}
) : (
diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/edition.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/edition.test.ts
index 5b25041b..d68cdb27 100644
--- a/src/Web/autopilot-monitor-web/lib/__tests__/edition.test.ts
+++ b/src/Web/autopilot-monitor-web/lib/__tests__/edition.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
COMMUNITY_DEFAULT,
editionLabel,
+ isProViaMsp,
missingContactProfileParts,
parseEditionInfo,
trialDaysLeft,
@@ -39,6 +40,27 @@ describe("parseEditionInfo", () => {
expect(info.edition).toBe("pro");
});
+ it("parses editionSource and flags conferred Pro", () => {
+ const info = parseEditionInfo({ edition: "pro", editionSource: "msp", entitlements: { delegatedAdminAllowed: false } });
+ expect(info.editionSource).toBe("msp");
+ expect(isProViaMsp(info)).toBe(true);
+ expect(info.entitlements.delegatedAdminAllowed).toBe(false);
+ expect(parseEditionInfo({ edition: "pro", editionSource: "plan" }).editionSource).toBe("plan");
+ expect(parseEditionInfo({ edition: "pro", editionSource: "trial", isTrial: true }).editionSource).toBe("trial");
+ });
+
+ it("editionSource: older backend without the field derives it from edition/isTrial", () => {
+ expect(parseEditionInfo({ edition: "pro" }).editionSource).toBe("plan");
+ expect(parseEditionInfo({ edition: "pro", isTrial: true }).editionSource).toBe("trial");
+ expect(parseEditionInfo({ edition: "community" }).editionSource).toBe("community");
+ });
+
+ it("editionSource: unknown or inconsistent values never yield an MSP badge (fail-closed)", () => {
+ expect(parseEditionInfo({ edition: "pro", editionSource: "partner" }).editionSource).toBe("plan");
+ expect(parseEditionInfo({ edition: "community", editionSource: "msp" }).editionSource).toBe("community");
+ expect(isProViaMsp(parseEditionInfo({ edition: "community", editionSource: "msp" }))).toBe(false);
+ });
+
it("fails closed to Community for malformed payloads", () => {
expect(parseEditionInfo(null)).toEqual(COMMUNITY_DEFAULT);
expect(parseEditionInfo(undefined)).toEqual(COMMUNITY_DEFAULT);
diff --git a/src/Web/autopilot-monitor-web/lib/edition.ts b/src/Web/autopilot-monitor-web/lib/edition.ts
index d703ed22..bed5d1bb 100644
--- a/src/Web/autopilot-monitor-web/lib/edition.ts
+++ b/src/Web/autopilot-monitor-web/lib/edition.ts
@@ -7,9 +7,16 @@
export type TenantEditionName = "community" | "pro";
+/**
+ * Why the edition applies: the tenant's own plan, an active trial, or Pro conferred by the
+ * organization that manages the tenant through a delegation ("msp" — shown as "Pro (MSP)").
+ */
+export type EditionSourceName = "community" | "plan" | "trial" | "msp";
+
/** Edition/trial surface returned by GET /api/config/{tenantId}/feature-flags. */
export interface EditionInfo {
edition: TenantEditionName;
+ editionSource: EditionSourceName;
isTrial: boolean;
trialExpiresUtc: string | null;
trialAvailable: boolean;
@@ -33,6 +40,7 @@ export interface EditionInfo {
/** Fail-closed default while flags are loading or on error: Community, no trial CTA. */
export const COMMUNITY_DEFAULT: EditionInfo = {
edition: "community",
+ editionSource: "community",
isTrial: false,
trialExpiresUtc: null,
trialAvailable: false,
@@ -64,6 +72,7 @@ export function parseEditionInfo(flags: unknown): EditionInfo {
: {}) as Record;
return {
edition,
+ editionSource: parseEditionSource(f.editionSource, edition, f.isTrial === true),
isTrial: f.isTrial === true,
trialExpiresUtc: typeof f.trialExpiresUtc === "string" ? f.trialExpiresUtc : null,
trialAvailable: f.trialAvailable === true,
@@ -82,6 +91,25 @@ export function parseEditionInfo(flags: unknown): EditionInfo {
};
}
+/**
+ * The edition source, or — against an older backend without the field — the value the old
+ * payload implied (Pro + isTrial ⇒ trial, Pro ⇒ plan, else community). Unknown strings fall
+ * back the same way, so a bad value can never render an "(MSP)" badge.
+ */
+function parseEditionSource(raw: unknown, edition: TenantEditionName, isTrial: boolean): EditionSourceName {
+ if (raw === "msp" || raw === "plan" || raw === "trial" || raw === "community") {
+ // Consistency guard: a non-Pro edition can only be "community".
+ return edition === "pro" ? raw : "community";
+ }
+ if (edition !== "pro") return "community";
+ return isTrial ? "trial" : "plan";
+}
+
+/** Whether Pro is conferred by the organization managing this tenant (badge "Pro (MSP)"). */
+export function isProViaMsp(info: Pick): boolean {
+ return info.edition === "pro" && info.editionSource === "msp";
+}
+
/**
* The parts of the tenant contact profile the Pro entry point requires and this tenant lacks,
* as display labels in the backend's order (mirrors PlanManagementFunction.MissingContactProfileParts).
@@ -108,9 +136,10 @@ export function trialDaysLeft(trialExpiresUtc: string | null | undefined, now: D
return ms <= 0 ? 0 : Math.ceil(ms / 86_400_000);
}
-/** Badge label: "Pro", "Pro Trial — X days left", or "Community". */
+/** Badge label: "Pro", "Pro (MSP)", "Pro Trial — X days left", or "Community". */
export function editionLabel(info: EditionInfo, now: Date = new Date()): string {
if (info.edition !== "pro") return "Community";
+ if (isProViaMsp(info)) return "Pro (MSP)";
if (!info.isTrial) return "Pro";
const days = trialDaysLeft(info.trialExpiresUtc, now);
return `Pro Trial — ${days} day${days === 1 ? "" : "s"} left`;
diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts
index a2088f19..b2300361 100644
--- a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts
+++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts
@@ -88,6 +88,8 @@ export const SHARED_MANIFEST = {
"proDowngradedUtc",
"maxDelegatedTenantsOverride",
"mcpUsagePlanOverride",
+ "payingCustomer",
+ "managedByProTenantId",
"manufacturerWhitelist",
"modelWhitelist",
"validateAutopilotDevice",
diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json
index cb22cc3a..b9d4196c 100644
--- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json
+++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json
@@ -84,6 +84,8 @@
"proDowngradedUtc",
"maxDelegatedTenantsOverride",
"mcpUsagePlanOverride",
+ "payingCustomer",
+ "managedByProTenantId",
"manufacturerWhitelist",
"modelWhitelist",
"validateAutopilotDevice",
@@ -16168,6 +16170,14 @@
},
"doc": "Effective edition after the change, lowercase (\"community\" | \"pro\")."
},
+ {
+ "name": "editionSource",
+ "type": {
+ "kind": "primitive",
+ "name": "string"
+ },
+ "doc": "Source of the effective edition: \"community\" | \"plan\" | \"trial\" | \"msp\" (conferred by the managing tenant)."
+ },
{
"name": "retentionGraceEndsUtc",
"type": {
@@ -16210,6 +16220,14 @@
},
"optional": true,
"doc": "The Global Admin MCP usage-plan override (a SectionUsagePlans plan name); omitted when the edition default applies."
+ },
+ {
+ "name": "payingCustomer",
+ "type": {
+ "kind": "primitive",
+ "name": "boolean"
+ },
+ "doc": "Sales bookkeeping: whether the tenant pays for its plan. No entitlement effect."
}
]
},
@@ -17393,6 +17411,23 @@
"optional": true,
"doc": "Global-Admin override of the tenant's MCP usage plan — the NAME of a SectionUsagePlans plan (AdminConfiguration.PlanTierDefinitionsJson), e.g. \"msp\". Applies to the WHOLE tenant: every member's default user plan (a per-user McpUsers override still wins) AND the organization-wide windows. Null/blank = the edition default (community/pro). Does NOT change the edition — Pro feature gates stay on PlanTier. Mutable only via the plan endpoint (validated against the plan definitions there). Backend-only: not delivered to the agent (no ConfigVersion impact)."
},
+ {
+ "name": "payingCustomer",
+ "type": {
+ "kind": "primitive",
+ "name": "boolean"
+ },
+ "doc": "Sales/support bookkeeping: whether this tenant PAYS for its plan (as opposed to a Pro tier assigned by support, a trial, or Pro conferred by a managing tenant). Carries NO entitlement — the effective edition is resolved from PlanTier/TrialExpiresUtc/ManagedByProTenantId only. Mutable only via the plan endpoint (Global Admin); shown in the operator tenant list, never in feature flags or any customer-facing surface. Backend-only: not delivered to the agent (no ConfigVersion impact)."
+ },
+ {
+ "name": "managedByProTenantId",
+ "type": {
+ "kind": "primitive",
+ "name": "string"
+ },
+ "optional": true,
+ "doc": "READ-TIME PROJECTION, NEVER STORED: the permanent-Pro tenant that currently manages this tenant through a self-service delegation (member of that tenant's owned msp-{tid} Tenant Group), or null. Populated by the backend's configuration loader on every read path from the Tenant Groups index; the table repository has no column for it and the patch endpoint denies it. While set, the tenant's effective edition is Pro with source \"msp\" (no delegation right of its own) — the delegation ending, the managing tenant losing its permanent Pro tier, or its offboarding all revert this automatically because nothing is written here. Not delivered to the agent (no ConfigVersion impact)."
+ },
{
"name": "manufacturerWhitelist",
"type": {
@@ -18199,6 +18234,14 @@
},
"doc": "Resolved edition, lowercase (\"community\" / \"pro\")."
},
+ {
+ "name": "editionSource",
+ "type": {
+ "kind": "primitive",
+ "name": "string"
+ },
+ "doc": "Why: \"community\" | \"plan\" (own Pro tier) | \"trial\" | \"msp\" (Pro conferred by the managing tenant — shown as \"Pro (MSP)\")."
+ },
{
"name": "isTrial",
"type": {
diff --git a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts
index db8445c0..a01c703f 100644
--- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts
+++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts
@@ -3471,6 +3471,8 @@ export interface SetTenantPlanTierResponse {
trialConsumed: boolean;
/** Effective edition after the change, lowercase ("community" | "pro"). */
effectiveEdition: string;
+ /** Source of the effective edition: "community" | "plan" | "trial" | "msp" (conferred by the managing tenant). */
+ editionSource: string;
/** End of the retention downgrade grace window, or null — the key is omitted when null. */
retentionGraceEndsUtc?: string;
/** Effective delegated (MSP) tenant slot limit after the change (override or plan entitlement). */
@@ -3481,6 +3483,8 @@ export interface SetTenantPlanTierResponse {
mcpUsagePlan: string;
/** The Global Admin MCP usage-plan override (a SectionUsagePlans plan name); omitted when the edition default applies. */
mcpUsagePlanOverride?: string;
+ /** Sales bookkeeping: whether the tenant pays for its plan. No entitlement effect. */
+ payingCustomer: boolean;
}
/** Distinct-CVE counts grouped by their highest CVSS severity band. */
@@ -3761,6 +3765,10 @@ export interface TenantConfiguration {
maxDelegatedTenantsOverride?: number;
/** Global-Admin override of the tenant's MCP usage plan — the NAME of a SectionUsagePlans plan (AdminConfiguration.PlanTierDefinitionsJson), e.g. "msp". Applies to the WHOLE tenant: every member's default user plan (a per-user McpUsers override still wins) AND the organization-wide windows. Null/blank = the edition default (community/pro). Does NOT change the edition — Pro feature gates stay on PlanTier. Mutable only via the plan endpoint (validated against the plan definitions there). Backend-only: not delivered to the agent (no ConfigVersion impact). */
mcpUsagePlanOverride?: string;
+ /** Sales/support bookkeeping: whether this tenant PAYS for its plan (as opposed to a Pro tier assigned by support, a trial, or Pro conferred by a managing tenant). Carries NO entitlement — the effective edition is resolved from PlanTier/TrialExpiresUtc/ManagedByProTenantId only. Mutable only via the plan endpoint (Global Admin); shown in the operator tenant list, never in feature flags or any customer-facing surface. Backend-only: not delivered to the agent (no ConfigVersion impact). */
+ payingCustomer: boolean;
+ /** READ-TIME PROJECTION, NEVER STORED: the permanent-Pro tenant that currently manages this tenant through a self-service delegation (member of that tenant's owned msp-{tid} Tenant Group), or null. Populated by the backend's configuration loader on every read path from the Tenant Groups index; the table repository has no column for it and the patch endpoint denies it. While set, the tenant's effective edition is Pro with source "msp" (no delegation right of its own) — the delegation ending, the managing tenant losing its permanent Pro tier, or its offboarding all revert this automatically because nothing is written here. Not delivered to the agent (no ConfigVersion impact). */
+ managedByProTenantId?: string;
/** Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*") Comma-separated list */
manufacturerWhitelist: string;
/** Hardware whitelist: Allowed models (supports wildcards like "Latitude*") Comma-separated list Default: "*" (all models allowed) */
@@ -3956,6 +3964,8 @@ export interface TenantFeatureFlagsResponse {
unrestrictedMode: boolean;
/** Resolved edition, lowercase ("community" / "pro"). */
edition: string;
+ /** Why: "community" | "plan" (own Pro tier) | "trial" | "msp" (Pro conferred by the managing tenant — shown as "Pro (MSP)"). */
+ editionSource: string;
isTrial: boolean;
/** Absent unless the tenant is on an active trial. */
trialExpiresUtc?: string;