plans: Pro (MSP) — a permanent-Pro managing tenant confers Pro on its managed tenants, derived at read time (B-qvu)

A tenant in the owned group of a permanent-Pro tenant is effectively Pro with source "msp":
badge "Pro (MSP)" (also over its own Pro), the Pro entitlement set without the delegation
right (no transitive delegation, no slots), no trial CTA. Nothing is written for that —
TenantConfiguration.ManagedByProTenantId is a load-time projection from a process-wide
index over the Tenant Groups table (owner tier read raw, never through the config service),
so the end of the delegation, the manager losing its permanent tier, or its offboarding
revert automatically. The one write is the retention grace anchor (ProDowngradedUtc),
stamped by ProConferralService from the four paths that end a conferring relationship.

- FeatureEntitlementCatalog.Resolve → EditionResolution { Edition, Source, OwnPro };
  Get(resolution) returns ProViaMsp for conferred Pro; the 3-argument ResolveEdition is
  gone so every caller resolves from the config (incl. the projection)
- DelegatedAdminService home gate checks the DelegatedAdminAllowed entitlement, not the edition
- TrialExpirySweep reads through the config service: a managed tenant's expired trial emits nothing
- feature-flags + plan response carry editionSource; config/all projects payingCustomer and
  managedByProTenantId; shared manifest + web/MCP codegen regenerated
- PayingCustomer: GA-only sales bookkeeping flag on the plan endpoint, no entitlement effect
- web: "Pro (MSP)" label/tooltips, plan section badge, delegated-access hints, tenant
  management chips + paying checkbox

Tests: catalog matrix, ManagedTenantProIndexTests (incl. mutual management without recursion,
fail-closed on storage error), ProConferralServiceTests, projection on every config read path,
feature-flags payload, parity facts, serialization (projection never stored), plan transitions,
edition gate, sweep. Backend 5902 green, web tsc/vitest/lint clean, MCP tsc/vitest green.

Concept doc + D-188 in internal (gitlink); customer docs in the docs repo (MCP redeploy needed).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-09-04 18:31:48 +02:00
parent 09e68a614f
commit 98cfd76232
55 changed files with 1655 additions and 119 deletions
@@ -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
});
}
@@ -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<IAdminRepository>();
repo.Setup(r => r.GetDelegatedTenantsAsync(It.IsAny<string>())).ReturnsAsync(new List<DelegatedAdminEntry> { Row(ManagedTenantA) });
repo.Setup(r => r.GetGroupAssignmentsForUpnAsync(It.IsAny<string>())).ReturnsAsync(new List<TenantGroupAssignment>());
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<DelegatedAdminService>.Instance);
var scope = await svc.GetScopeAsync(Id(ConferredProHomeTenant));
Assert.True(scope.IsEmpty);
}
[Fact]
public async Task CommunityHomeTenant_SuppressesEntireScope()
{
@@ -42,6 +42,7 @@ public class DelegationSelfServiceTests
public required FakeSignalRNotificationService SignalR { get; init; }
public required List<DelegationInvitation> Rows { get; init; }
public required List<string> GroupTenants { get; init; }
public required Mock<ProConferralService> 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<ProConferralService>(
repo.Object,
new TenantConfigurationService(configRepo.Object, NullLogger<TenantConfigurationService>.Instance, cache),
ManagedTenantProIndex.None,
NullLogger<ProConferralService>.Instance) { CallBase = false };
var svc = new DelegationSelfService(repo.Object, invitations.Object, delegatedAdmins, slots, entitlements, configRepo.Object, audit.Object, signalR,
NullLogger<DelegationSelfService>.Instance, time);
return new Harness { Svc = svc, Repo = repo, Invitations = invitations, Audit = audit, SignalR = signalR, Rows = rows, GroupTenants = groupTenants };
proConferral.Object, NullLogger<DelegationSelfService>.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<string>(), It.IsAny<string>(), It.IsAny<string>()), 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<string>(), It.IsAny<string>(), It.IsAny<string>()), Times.Never);
}
private static DelegationInvitation Pending(string id = "inv1", DateTime? expires = null) => new()
@@ -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 ───────────────────────────────────────────────────────
@@ -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()
{
@@ -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;
/// <summary>
/// Pins the reverse index behind conferred Pro (<see cref="ManagedTenantProIndex"/>): 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.
/// </summary>
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<IAdminRepository> Admin, Mock<IConfigRepository> Config) Build(
IEnumerable<TenantGroup> groups, IDictionary<string, TenantConfiguration> configs)
{
var admin = new Mock<IAdminRepository>();
admin.Setup(a => a.GetAllTenantGroupsAsync()).ReturnsAsync(groups.ToList());
var config = new Mock<IConfigRepository>();
config.Setup(c => c.GetTenantConfigurationAsync(It.IsAny<string>()))
.ReturnsAsync((string id) => configs.TryGetValue(id, out var cfg) ? cfg : null);
var index = new ManagedTenantProIndex(admin.Object, config.Object, new MemoryCache(new MemoryCacheOptions()),
NullLogger<ManagedTenantProIndex>.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<string, TenantConfiguration> { [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<string, TenantConfiguration> { [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<string, TenantConfiguration>
{
[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<string, TenantConfiguration>());
Assert.Null(await index.GetConferringOwnerAsync(CustomerA));
}
[Fact]
public async Task OperatorGroup_NeverConfers_EvenWhenAssigneesAreProHomed()
{
var (index, _, _) = Build(
new[] { Operator(OperatorManaged) },
new Dictionary<string, TenantConfiguration> { [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<string, TenantConfiguration> { [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<string, TenantConfiguration> { [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<string, TenantConfiguration> { [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<IAdminRepository>();
admin.Setup(a => a.GetAllTenantGroupsAsync()).ThrowsAsync(new InvalidOperationException("storage down"));
var index = new ManagedTenantProIndex(admin.Object, Mock.Of<IConfigRepository>(),
new MemoryCache(new MemoryCacheOptions()), NullLogger<ManagedTenantProIndex>.Instance);
Assert.Null(await index.GetConferringOwnerAsync(CustomerA));
}
[Fact]
public async Task BlankTenantId_IsNull_WithoutTouchingStorage()
{
var (index, admin, _) = Build(Array.Empty<TenantGroup>(), new Dictionary<string, TenantConfiguration>());
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
}
}
@@ -44,6 +44,7 @@ public sealed class TenantCustomsArchiveContractTests
customsArchive: archive,
configRepo: null!,
farewellEmail: new FakeOffboardFarewellEmailSender(),
proConferral: null!,
logger: NullLogger<TenantOffboardingHandler>.Instance);
}
@@ -670,7 +670,8 @@ public class TenantOffboardingHandlerDrainContractTests
IOffboardFarewellEmailSender farewellEmail,
ILogger<TenantOffboardingHandler> 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)
{
}
@@ -486,7 +486,7 @@ public class TenantOffboardingHandlerOrderingTests
TableStorageService s, IMaintenanceRepository m, ITenantOffboardingEnqueuer re,
OpsEventService o, ITenantCustomsArchiveRepository ca, IConfigRepository cr,
IOffboardFarewellEmailSender fe, ILogger<TenantOffboardingHandler> 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)
@@ -258,7 +258,7 @@ public class TenantOffboardingWorkerPoisonTests
TableStorageService s, IMaintenanceRepository m, ITenantOffboardingEnqueuer re,
OpsEventService o, ITenantCustomsArchiveRepository ca, IConfigRepository cr,
IOffboardFarewellEmailSender fe, ILogger<TenantOffboardingHandler> 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)
@@ -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<string, string>();
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<string, string>();
PlanManagementFunction.ApplyPayingCustomerChange(config, provided: false, payingCustomer: false, changes);
Assert.True(config.PayingCustomer);
Assert.Empty(changes);
}
[Fact]
public void Upgrade_CommunityToPro_ClearsAnchor()
{
@@ -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;
/// <summary>
/// Pins the write side of conferred Pro (<see cref="ProConferralService"/>): the ONLY thing ever written is
/// the retention grace anchor (<see cref="TenantConfiguration.ProDowngradedUtc"/>) 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.
/// </summary>
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<string, TenantConfiguration> 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<string, TenantConfiguration>(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<IConfigRepository>();
configRepo.Setup(c => c.GetTenantConfigurationAsync(It.IsAny<string>()))
.ReturnsAsync((string id) => rows.TryGetValue(id, out var cfg) ? cfg : null);
configRepo.Setup(c => c.SaveTenantConfigurationAsync(It.IsAny<TenantConfiguration>(), It.IsAny<string?>(), It.IsAny<string?>()))
.ReturnsAsync((TenantConfiguration cfg, string? source, string? reason) => { saves.Add((cfg, source, reason)); return true; });
var adminRepo = new Mock<IAdminRepository>();
adminRepo.Setup(a => a.GetGroupTenantsAsync(Constants.TenantGroupIds.ForHomeTenant(Home))).ReturnsAsync(members.ToList());
var index = new StubManagedTenantProIndex(_ => null);
var configs = new TenantConfigurationService(configRepo.Object, NullLogger<TenantConfigurationService>.Instance,
new MemoryCache(new MemoryCacheOptions()), index);
var svc = new ProConferralService(adminRepo.Object, configs, index, NullLogger<ProConferralService>.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);
}
}
@@ -88,7 +88,8 @@ public class RevokeEnforcementTests
var audit = BuildAuditRepo();
var signalR = new FakeSignalRNotificationService();
var fn = new TenantGroupManagementFunction(
NullLogger<TenantGroupManagementFunction>.Instance, BuildService(repo), Resolver(), audit.Object, signalR, BuildSlots(repo));
NullLogger<TenantGroupManagementFunction>.Instance, BuildService(repo), Resolver(), audit.Object, signalR, BuildSlots(repo),
TestProConferral.Inert());
return (fn, repo, audit, signalR);
}
@@ -11,30 +11,39 @@ namespace AutopilotMonitor.Functions.Tests;
/// </summary>
internal sealed class StubTenantEntitlementService : TenantEntitlementService
{
private readonly Func<string?, TenantEdition> _resolver;
private readonly Func<string?, EditionResolution> _resolver;
private readonly Func<string?, string?> _planOverrideResolver;
public StubTenantEntitlementService(TenantEdition edition) : this(_ => edition)
{
}
/// <param name="resolver">Edition per tenant id.</param>
/// <param name="resolver">Edition per tenant id — Pro means Pro in the tenant's OWN right (plan).</param>
/// <param name="planOverrideResolver">
/// 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".
/// </param>
public StubTenantEntitlementService(Func<string?, TenantEdition> resolver, Func<string?, string?>? planOverrideResolver = null)
: this(tenantId => AsOwnResolution(resolver(tenantId)), planOverrideResolver)
{
}
/// <param name="resolver">Full resolution per tenant id (edition, source, own standing) — for conferred-Pro cases.</param>
/// <param name="planOverrideResolver">See the edition-based constructor.</param>
public StubTenantEntitlementService(Func<string?, EditionResolution> resolver, Func<string?, string?>? planOverrideResolver)
: base(configService: null!, logger: NullLogger<TenantEntitlementService>.Instance)
{
_resolver = resolver;
_planOverrideResolver = planOverrideResolver ?? (_ => null);
}
public override Task<TenantEdition> 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<EditionEntitlements> GetEntitlementsAsync(string? tenantId)
=> Task.FromResult(FeatureEntitlementCatalog.Get(_resolver(tenantId)));
// GetEditionAsync / GetEntitlementsAsync derive from this in the base class.
public override Task<EditionResolution> GetResolutionAsync(string? tenantId)
=> Task.FromResult(_resolver(tenantId));
public override Task<string> GetMcpUsagePlanNameAsync(string? tenantId)
=> Task.FromResult(
@@ -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",
@@ -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")]
@@ -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<IConfigRepository> repo, StubManagedTenantProIndex index) BuildProjecting(string? owner)
{
var repo = new Mock<IConfigRepository>();
var index = new StubManagedTenantProIndex(id => string.Equals(id, TenantId, StringComparison.OrdinalIgnoreCase) ? owner : null);
var service = new TenantConfigurationService(
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), 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<IConfigRepository>();
string? owner = Owner;
var index = new StubManagedTenantProIndex(_ => owner);
var service = new TenantConfigurationService(
repo.Object, Mock.Of<ILogger<TenantConfigurationService>>(), 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<TenantConfiguration> { Row(), new() { TenantId = "other" } });
repo.Setup(r => r.GetTenantConfigurationsPageAsync(It.IsAny<int>(), It.IsAny<string?>()))
.ReturnsAsync(new AutopilotMonitor.Shared.Pagination.RawPage<TenantConfiguration>(new List<TenantConfiguration> { 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<TenantConfiguration>())).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()
{
@@ -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;
/// <summary>
/// Conferred-Pro test doubles: an inert <see cref="ProConferralService"/> for subjects that merely
/// depend on it, and a storage-free <see cref="ManagedTenantProIndex"/> that answers from a lambda.
/// </summary>
internal static class TestProConferral
{
/// <summary>A service over empty storage: every record call is a no-op that returns "nothing stamped".</summary>
public static ProConferralService Inert()
{
var adminRepo = new Mock<IAdminRepository>();
adminRepo.Setup(r => r.GetGroupTenantsAsync(It.IsAny<string>())).ReturnsAsync(new List<string>());
var configs = new TenantConfigurationService(
Mock.Of<IConfigRepository>(), NullLogger<TenantConfigurationService>.Instance, new MemoryCache(new MemoryCacheOptions()));
return new ProConferralService(adminRepo.Object, configs, ManagedTenantProIndex.None, NullLogger<ProConferralService>.Instance);
}
}
/// <summary>Index stub: the conferring owner per tenant id comes from the lambda; no storage, no cache.</summary>
internal sealed class StubManagedTenantProIndex : ManagedTenantProIndex
{
private readonly Func<string?, string?> _owner;
public int Invalidations { get; private set; }
public StubManagedTenantProIndex(Func<string?, string?> owner) => _owner = owner;
public override Task<string?> GetConferringOwnerAsync(string? tenantId) => Task.FromResult(_owner(tenantId));
public override void Invalidate() => Invalidations++;
}
@@ -54,11 +54,42 @@ public class TrialExpirySweepFunctionTests
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.Instance, alertDispatch);
var sut = new TrialExpirySweepFunction(
configRepo.Object, opsService, NullLogger<TrialExpirySweepFunction>.Instance,
Configs(configRepo.Object), opsService, NullLogger<TrialExpirySweepFunction>.Instance,
new TestTimeProvider(Now));
return (sut, events);
}
private static TenantConfigurationService Configs(IConfigRepository repo, ManagedTenantProIndex? index = null)
=> new(repo, NullLogger<TenantConfigurationService>.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<IConfigRepository>();
configRepo.Setup(r => r.GetAllTenantConfigurationsAsync()).ReturnsAsync(new List<TenantConfiguration> { managed });
var events = new List<OpsEventEntry>();
var opsRepo = new Mock<IOpsEventRepository>();
opsRepo.Setup(r => r.SaveOpsEventAsync(It.IsAny<OpsEventEntry>())).Callback<OpsEventEntry>(events.Add).Returns(Task.CompletedTask);
var cache = new MemoryCache(new MemoryCacheOptions());
var adminConfig = new Mock<AdminConfigurationService>(
Mock.Of<IConfigRepository>(), NullLogger<AdminConfigurationService>.Instance, cache)
{ CallBase = false };
adminConfig.Setup(a => a.GetConfigurationAsync()).ReturnsAsync(new AdminConfiguration { UpdatedBy = "test" });
var opsService = new OpsEventService(opsRepo.Object, NullLogger<OpsEventService>.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<TrialExpirySweepFunction>.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<OpsEventService>.Instance, alertDispatch),
NullLogger<TrialExpirySweepFunction>.Instance,
new TestTimeProvider(Now));
@@ -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,
@@ -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"),
@@ -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;
}
/// <summary>The managing tenant that owns <paramref name="groupId"/> (self-service group), or null for operator groups / unknown groups.</summary>
private async Task<string?> OwnerOfAsync(string groupId)
{
var group = await _delegatedAdminService.GetGroupAsync(groupId);
return string.IsNullOrWhiteSpace(group?.OwnerTenantId) ? null : group!.OwnerTenantId;
}
/// <summary>GET /api/global/tenant-groups — list every group with tenants + assignee count. GlobalReadOrAdmin.</summary>
@@ -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(
@@ -72,10 +72,11 @@ namespace AutopilotMonitor.Functions.Functions.Config
/// </summary>
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.
@@ -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;
}
/// <summary>
/// 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<string, string>();
// 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<string, string> 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;
}
/// <summary>
/// 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 <see cref="TenantConfiguration.PayingCustomer"/>.
/// </summary>
internal static void ApplyPayingCustomerChange(
TenantConfiguration config, bool provided, bool payingCustomer, Dictionary<string, string> changes)
{
if (!provided || config.PayingCustomer == payingCustomer)
return;
changes["PayingCustomer"] = $"{config.PayingCustomer} -> {payingCustomer}";
config.PayingCustomer = payingCustomer;
}
/// <summary>Pure: whether <paramref name="planName"/> (normalized) names a SectionUsagePlans definition.</summary>
internal static bool IsKnownUsagePlan(string planName, IEnumerable<PlanTierDefinition> 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.");
}
@@ -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);
}
@@ -42,27 +42,29 @@ public class TrialExpirySweepFunction
/// <summary>03:30 UTC daily — off the busy maintenance window, after ScriptNameCacheCleanup (03:00).</summary>
private const string Cron = "0 30 3 * * *";
private readonly IConfigRepository _configRepo;
// 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<TrialExpirySweepFunction> _logger;
private readonly TimeProvider _time;
public TrialExpirySweepFunction(
IConfigRepository configRepo,
TenantConfigurationService configs,
OpsEventService opsEvents,
ILogger<TrialExpirySweepFunction> logger)
: this(configRepo, opsEvents, logger, TimeProvider.System)
: this(configs, opsEvents, logger, TimeProvider.System)
{
}
/// <summary>Test seam — inject a fake <see cref="TimeProvider"/> for deterministic window math.</summary>
internal TrialExpirySweepFunction(
IConfigRepository configRepo,
TenantConfigurationService configs,
OpsEventService opsEvents,
ILogger<TrialExpirySweepFunction> logger,
TimeProvider time)
{
_configRepo = configRepo;
_configs = configs;
_opsEvents = opsEvents;
_logger = logger;
_time = time;
@@ -83,7 +85,7 @@ public class TrialExpirySweepFunction
System.Collections.Generic.List<AutopilotMonitor.Shared.Models.TenantConfiguration> 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++;
@@ -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),
@@ -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.";
@@ -234,7 +234,11 @@ builder.Services.AddHostedService<StartupTelemetryService>(); // BackendStartup
// To switch to Cosmos DB: replace AddTableStorageDataAccess() with AddCosmosDataAccess().
// To add event streaming: chain .AddEventStreaming<EventHubPublisher>() 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<ManagedTenantProIndex>();
builder.Services.AddSingleton<TenantConfigurationService>();
builder.Services.AddSingleton<ProConferralService>();
builder.Services.AddSingleton<TenantConfigPatchService>();
builder.Services.AddSingleton<AdminConfigurationService>();
builder.Services.AddSingleton<AppHomingService>();
@@ -1,4 +1,5 @@
using System;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Security
{
@@ -12,6 +13,40 @@ namespace AutopilotMonitor.Functions.Security
Pro
}
/// <summary>
/// WHY a tenant is on its effective edition. <see cref="Msp"/> 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 <see cref="EditionResolution.OwnPro"/>.
/// </summary>
public enum EditionSource
{
/// <summary>No Pro from any source.</summary>
Community,
/// <summary>Permanent Pro tier stored on the tenant (paid or support-assigned).</summary>
Plan,
/// <summary>Active trial (TrialExpiresUtc in the future) on a non-Pro tier.</summary>
Trial,
/// <summary>Pro conferred by the permanent-Pro tenant that manages this tenant (self-service delegation).</summary>
Msp
}
/// <summary>
/// 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 <see cref="OwnPro"/> only — conferred Pro
/// never lets a managed tenant delegate onward.
/// </summary>
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;
/// <summary>Wire spelling of <see cref="Source"/> ("community" | "plan" | "trial" | "msp").</summary>
public string SourceName => Source.ToString().ToLowerInvariant();
/// <summary>Wire spelling of <see cref="Edition"/> ("community" | "pro").</summary>
public string EditionName => Edition.ToString().ToLowerInvariant();
}
/// <summary>
/// The static per-edition entitlement values. Immutable code catalog — analogous to
/// <see cref="EndpointAccessPolicyCatalog"/>: entitlements are defined here, not in storage,
@@ -43,7 +78,9 @@ namespace AutopilotMonitor.Functions.Security
/// <summary>
/// Whether users HOMED in this tenant may hold delegated ("MSP") admin scopes over other
/// tenants. The gate applies to the delegated admin's home tenant (JWT tid) — the managed
/// TARGET tenants may be any edition (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 <see cref="EditionSource.Msp"/>).
/// Conferred Pro never includes this right (no transitive delegation).
/// Enforced at resolve time in DelegatedAdminService.GetScopeAsync.
/// </summary>
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
/// (<see cref="TenantConfiguration.ManagedByProTenantId"/>, 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).
/// </summary>
public static class FeatureEntitlementCatalog
{
@@ -157,6 +196,28 @@ namespace AutopilotMonitor.Functions.Security
McpTenantMonthlyRequestLimit = 60000
};
/// <summary>
/// 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.
/// </summary>
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
};
/// <summary>
/// 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
}
/// <summary>
/// Resolves the effective edition from the stored plan tier and trial expiry.
/// Pro ⇔ tier "pro"/legacy "enterprise" (case-insensitive) OR trialExpiresUtc &gt; nowUtc.
/// A trial expiring exactly at <paramref name="nowUtc"/> is already over (strict &gt;).
/// 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 &gt; nowUtc
/// (a trial expiring exactly at <paramref name="nowUtc"/> is already over — strict &gt;).
/// Source precedence for DISPLAY: Msp &gt; Plan &gt; Trial; <see cref="EditionResolution.OwnPro"/>
/// keeps the tenant's own standing so the entitlement union is exact.
/// </summary>
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);
}
/// <summary>Returns the entitlement set for an edition. Unknown enum values → Community (fail-closed).</summary>
/// <summary>Config-based overload of <see cref="Resolve(string?, DateTime?, string?, DateTime)"/> — the one every config-holding caller uses.</summary>
public static EditionResolution Resolve(TenantConfiguration config, DateTime nowUtc)
=> Resolve(config.PlanTier, config.TrialExpiresUtc, config.ManagedByProTenantId, nowUtc);
/// <summary>The effective edition only — see <see cref="Resolve(TenantConfiguration, DateTime)"/>.</summary>
public static TenantEdition ResolveEdition(TenantConfiguration config, DateTime nowUtc)
=> Resolve(config, nowUtc).Edition;
/// <summary>
/// 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).
/// </summary>
public static EditionEntitlements Get(EditionResolution resolution)
{
if (resolution.Edition != TenantEdition.Pro)
return Community;
return resolution.OwnPro ? Pro : ProViaMsp;
}
/// <summary>
/// Returns the entitlement set for a bare edition (catalog lookups such as a cap value). Callers that
/// hold a config must use <see cref="Get(EditionResolution)"/> so conferred Pro is resolved correctly.
/// Unknown enum values → Community (fail-closed).
/// </summary>
public static EditionEntitlements Get(TenantEdition edition) =>
edition == TenantEdition.Pro ? Pro : Community;
}
@@ -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,
@@ -163,7 +163,9 @@ public class DelegatedAdminService
/// </summary>
private async Task<DelegatedScope> 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(
@@ -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);
}
@@ -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<DelegationSelfService> _logger;
private readonly TimeProvider _time;
@@ -71,8 +72,9 @@ public class DelegationSelfService
IConfigRepository configRepo,
IMaintenanceRepository audit,
ISignalRNotificationService signalR,
ProConferralService proConferral,
ILogger<DelegationSelfService> 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<DelegationSelfService> 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<bool>.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)
@@ -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
{
/// <summary>
/// The reverse index behind <c>TenantConfiguration.ManagedByProTenantId</c>: managed tenant → the
/// permanent-Pro tenant whose owned self-service group (<c>msp-{tid}</c>) 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 <see cref="Ttl"/>, 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:
/// <list type="bullet">
/// <item>Only OWNED groups (meta row with OwnerTenantId — the self-service delegation model) confer; operator-created groups never do.</item>
/// <item>Only a PERMANENT Pro owner confers (<see cref="FeatureEntitlementCatalog.IsPermanentProTier"/>): a trial MSP may delegate but does not upgrade its customers.</item>
/// <item>The owner's tier is read RAW from the repository, never through <see cref="TenantConfigurationService"/> — that service consults this index, and two tenants managing each other would otherwise recurse.</item>
/// <item>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.</item>
/// <item>Owners are visited in group-id order so a tenant managed by two Pro tenants resolves deterministically.</item>
/// </list>
/// </summary>
public class ManagedTenantProIndex
{
private const string CacheKey = "managed-tenant-pro-index";
/// <summary>Matches the tenant-config cache TTL — the staleness budget plan changes already accept.</summary>
internal static readonly TimeSpan Ttl = TimeSpan.FromMinutes(5);
/// <summary>A failed build is cached briefly: fail-closed without a storage storm.</summary>
internal static readonly TimeSpan FailureTtl = TimeSpan.FromSeconds(30);
private static readonly IReadOnlyDictionary<string, string> Empty =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
private readonly IAdminRepository _adminRepo;
private readonly IConfigRepository _configRepo;
private readonly IMemoryCache _cache;
private readonly ILogger<ManagedTenantProIndex> _logger;
public ManagedTenantProIndex(
IAdminRepository adminRepo,
IConfigRepository configRepo,
IMemoryCache cache,
ILogger<ManagedTenantProIndex> logger)
{
_adminRepo = adminRepo;
_configRepo = configRepo;
_cache = cache;
_logger = logger;
}
/// <summary>Test seam for subclasses that never touch storage (see <see cref="None"/>).</summary>
protected ManagedTenantProIndex()
{
_adminRepo = null!;
_configRepo = null!;
_cache = null!;
_logger = null!;
}
/// <summary>
/// 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.
/// </summary>
public static ManagedTenantProIndex None { get; } = new NoneIndex();
/// <summary>The permanent-Pro tenant currently conferring Pro on <paramref name="tenantId"/>, or null.</summary>
public virtual async Task<string?> GetConferringOwnerAsync(string? tenantId)
{
if (string.IsNullOrWhiteSpace(tenantId))
return null;
var map = await GetMapAsync();
return map.TryGetValue(tenantId, out var owner) ? owner : null;
}
/// <summary>Drops the cached map on THIS instance; other instances refresh within <see cref="Ttl"/>.</summary>
public virtual void Invalidate() => _cache.Remove(CacheKey);
private async Task<IReadOnlyDictionary<string, string>> GetMapAsync()
{
if (_cache.TryGetValue(CacheKey, out IReadOnlyDictionary<string, string>? cached) && cached != null)
return cached;
var (map, ok) = await BuildAsync();
_cache.Set(CacheKey, map, ok ? Ttl : FailureTtl);
return map;
}
private async Task<(IReadOnlyDictionary<string, string> Map, bool Ok)> BuildAsync()
{
try
{
var groups = await _adminRepo.GetAllTenantGroupsAsync();
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
var ownerTierCache = new Dictionary<string, bool>(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<string?> GetConferringOwnerAsync(string? tenantId) => Task.FromResult<string?>(null);
public override void Invalidate() { }
}
}
}
@@ -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<TenantOffboardingHandler> _logger;
public TenantOffboardingHandler(
@@ -219,6 +220,7 @@ namespace AutopilotMonitor.Functions.Services.Offboarding
ITenantCustomsArchiveRepository customsArchive,
IConfigRepository configRepo,
IOffboardFarewellEmailSender farewellEmail,
ProConferralService proConferral,
ILogger<TenantOffboardingHandler> 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<string, int>(StringComparer.Ordinal);
await SafeWipeAllTenantTablesAsync(deletedCounts, tenantId, ct);
@@ -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
{
/// <summary>
/// The WRITE side of conferred Pro ("Pro (MSP)"). The edition itself is never written — it is projected at
/// read time from group membership (<see cref="ManagedTenantProIndex"/>). 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:
/// <list type="bullet">
/// <item>self-service removal / customer revoke and the Global Admin removing a tenant from an owned group → <see cref="RecordLossAsync"/></item>
/// <item>the managing tenant losing its permanent Pro tier (plan endpoint) and its offboarding → <see cref="RecordLossForOwnedGroupAsync"/></item>
/// </list>
/// Accepting an invitation only needs the caches dropped (<see cref="NotifyDelegationChangedAsync"/>).
/// </summary>
public class ProConferralService
{
private const string BackupSource = "delegation";
private readonly IAdminRepository _adminRepo;
private readonly TenantConfigurationService _configs;
private readonly ManagedTenantProIndex _index;
private readonly ILogger<ProConferralService> _logger;
private readonly TimeProvider _time;
public ProConferralService(
IAdminRepository adminRepo,
TenantConfigurationService configs,
ManagedTenantProIndex index,
ILogger<ProConferralService> logger)
: this(adminRepo, configs, index, logger, TimeProvider.System)
{
}
/// <summary>Test seam — inject a fake <see cref="TimeProvider"/>.</summary>
public ProConferralService(
IAdminRepository adminRepo,
TenantConfigurationService configs,
ManagedTenantProIndex index,
ILogger<ProConferralService> logger,
TimeProvider time)
{
_adminRepo = adminRepo;
_configs = configs;
_index = index;
_logger = logger;
_time = time;
}
/// <summary>
/// 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).
/// </summary>
public virtual Task NotifyDelegationChangedAsync(string managedTenantId)
{
_index.Invalidate();
_configs.InvalidateCache(managedTenantId.ToLowerInvariant());
return Task.CompletedTask;
}
/// <summary>
/// A single managed tenant left <paramref name="homeTenantId"/>'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.
/// </summary>
public virtual async Task<bool> 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);
}
}
/// <summary>
/// Every tenant in <paramref name="homeTenantId"/>'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.
/// </summary>
public virtual async Task<int> 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<bool> 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;
}
}
}
@@ -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",
};
/// <summary>
@@ -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<string, PropertyInfo> 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",
};
@@ -12,20 +12,49 @@ namespace AutopilotMonitor.Functions.Services
/// <summary>
/// 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 cref="TenantConfiguration.ManagedByProTenantId"/> (see <see cref="ManagedTenantProIndex"/>): 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).
/// </summary>
public class TenantConfigurationService
{
private readonly IConfigRepository _configRepo;
private readonly ILogger<TenantConfigurationService> _logger;
private readonly IMemoryCache _cache;
private readonly ManagedTenantProIndex _proIndex;
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
public TenantConfigurationService(IConfigRepository configRepo, ILogger<TenantConfigurationService> logger, IMemoryCache cache)
public TenantConfigurationService(
IConfigRepository configRepo,
ILogger<TenantConfigurationService> logger,
IMemoryCache cache,
ManagedTenantProIndex proIndex)
{
_configRepo = configRepo;
_logger = logger;
_cache = cache;
_proIndex = proIndex;
}
/// <summary>
/// Test seam — no delegation index: no tenant is ever projected as managed. Production DI always
/// resolves the four-argument constructor (the longest satisfiable one).
/// </summary>
public TenantConfigurationService(IConfigRepository configRepo, ILogger<TenantConfigurationService> logger, IMemoryCache cache)
: this(configRepo, logger, cache, ManagedTenantProIndex.None)
{
}
/// <summary>Applies the read-time projection (see the class remarks). Idempotent; null passes through.</summary>
private async Task<TenantConfiguration?> ProjectAsync(TenantConfiguration? config)
{
if (config == null)
return null;
config.ManagedByProTenantId = await _proIndex.GetConferringOwnerAsync(config.TenantId);
return config;
}
/// <summary>
@@ -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);
}
/// <summary>
@@ -224,7 +253,7 @@ namespace AutopilotMonitor.Functions.Services
if (config != null)
_cache.Set($"tenant-config:{tenantId}", config, CacheDuration);
return config;
return await ProjectAsync(config);
}
/// <summary>
@@ -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)
{
@@ -38,41 +38,55 @@ namespace AutopilotMonitor.Functions.Services
_time = time;
}
private static readonly EditionResolution CommunityResolution =
new(TenantEdition.Community, EditionSource.Community, OwnPro: false);
/// <summary>
/// Resolves the tenant's effective edition. Uses the strict point-read
/// (<see cref="TenantConfigurationService.GetConfigurationIfExistsAsync"/>) so an
/// Resolves the tenant's effective edition, its source and its own standing. Uses the strict
/// point-read (<see cref="TenantConfigurationService.GetConfigurationIfExistsAsync"/>) so an
/// entitlement check can never materialize a tenant-config row. No row / any error →
/// Community (fail-closed).
/// </summary>
public virtual async Task<TenantEdition> GetEditionAsync(string? tenantId)
public virtual async Task<EditionResolution> 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;
}
}
/// <summary>Resolves the tenant's effective entitlement set (fail-closed → Community values).</summary>
/// <summary>The effective edition only — see <see cref="GetResolutionAsync"/>.</summary>
public virtual async Task<TenantEdition> GetEditionAsync(string? tenantId)
=> (await GetResolutionAsync(tenantId)).Edition;
/// <summary>
/// Resolves the tenant's effective entitlement set (fail-closed → Community values). Conferred Pro
/// yields the Pro set WITHOUT the delegation right (see <see cref="FeatureEntitlementCatalog.Get(EditionResolution)"/>).
/// </summary>
public virtual async Task<EditionEntitlements> GetEntitlementsAsync(string? tenantId)
=> FeatureEntitlementCatalog.Get(await GetEditionAsync(tenantId));
=> FeatureEntitlementCatalog.Get(await GetResolutionAsync(tenantId));
/// <summary>Pure resolution (edition + source + own standing) for callers that already hold the config.</summary>
public static EditionResolution Resolve(TenantConfiguration config, DateTime nowUtc)
=> FeatureEntitlementCatalog.Resolve(config, nowUtc);
/// <summary>Pure edition resolution for callers that already hold the config.</summary>
public static TenantEdition ResolveEdition(TenantConfiguration config, DateTime nowUtc)
=> FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
=> FeatureEntitlementCatalog.ResolveEdition(config, nowUtc);
/// <summary>
/// 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.
/// </summary>
public static bool IsBootstrapEnabled(TenantConfiguration config, DateTime nowUtc)
=> FeatureEntitlementCatalog.Get(ResolveEdition(config, nowUtc)).BootstrapIncluded
=> FeatureEntitlementCatalog.Get(Resolve(config, nowUtc)).BootstrapIncluded
|| config.BootstrapTokenEnabled;
/// <summary>
/// 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).
/// </summary>
public static int GetMaxDelegatedTenants(TenantConfiguration config, DateTime nowUtc)
=> config.MaxDelegatedTenantsOverride
?? FeatureEntitlementCatalog.Get(ResolveEdition(config, nowUtc)).MaxDelegatedTenants;
?? FeatureEntitlementCatalog.Get(Resolve(config, nowUtc)).MaxDelegatedTenants;
/// <summary>Cached read-time variant of <see cref="GetMaxDelegatedTenants"/>. No row / any error ⇒ 0 (fail-closed).</summary>
public virtual async Task<int> 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;
}
/// <summary>Trimmed lower-case plan name, or null for blank input.</summary>
@@ -159,7 +174,7 @@ namespace AutopilotMonitor.Functions.Services
/// opt-in toggle (<see cref="TenantConfiguration.UnrestrictedMode"/>).
/// </summary>
public static bool IsUnrestrictedModeActive(TenantConfiguration config, DateTime nowUtc)
=> FeatureEntitlementCatalog.Get(ResolveEdition(config, nowUtc)).UnrestrictedModeAvailable
=> FeatureEntitlementCatalog.Get(Resolve(config, nowUtc)).UnrestrictedModeAvailable
&& config.UnrestrictedModeEnabled
&& config.UnrestrictedMode;
@@ -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;
@@ -213,6 +213,28 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public string? McpUsagePlanOverride { get; set; }
/// <summary>
/// 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).
/// </summary>
public bool PayingCustomer { get; set; }
/// <summary>
/// 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 <c>msp-{tid}</c>
/// 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).
/// </summary>
public string? ManagedByProTenantId { get; set; }
/// <summary>
/// Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*")
/// Comma-separated list
@@ -27,6 +27,8 @@ namespace AutopilotMonitor.Shared.Models
public bool UnrestrictedMode { get; set; }
/// <summary>Resolved edition, lowercase ("community" / "pro").</summary>
public string Edition { get; set; } = string.Empty;
/// <summary>Why: "community" | "plan" (own Pro tier) | "trial" | "msp" (Pro conferred by the managing tenant — shown as "Pro (MSP)").</summary>
public string EditionSource { get; set; } = string.Empty;
public bool IsTrial { get; set; }
/// <summary>Absent unless the tenant is on an active trial.</summary>
public DateTime? TrialExpiresUtc { get; set; }
@@ -202,6 +202,9 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>Effective edition after the change, lowercase ("community" | "pro").</summary>
public string EffectiveEdition { get; set; } = default!;
/// <summary>Source of the effective edition: "community" | "plan" | "trial" | "msp" (conferred by the managing tenant).</summary>
public string EditionSource { get; set; } = default!;
/// <summary>End of the retention downgrade grace window, or null — the key is omitted when null.</summary>
public DateTime? RetentionGraceEndsUtc { get; set; }
@@ -216,6 +219,9 @@ namespace AutopilotMonitor.Shared.Models
/// <summary>The Global Admin MCP usage-plan override (a SectionUsagePlans plan name); omitted when the edition default applies.</summary>
public string? McpUsagePlanOverride { get; set; }
/// <summary>Sales bookkeeping: whether the tenant pays for its plan. No entitlement effect.</summary>
public bool PayingCustomer { get; set; }
}
/// <summary>
@@ -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
</span>
)}
{tenant.planTier === "pro" || tenant.planTier === "enterprise" ? (
{tenant.managedByProTenantId ? (
<span
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800"
title={`Pro conferred by the managing tenant ${tenant.managedByProTenantId}`}
>
Pro (MSP)
</span>
) : tenant.planTier === "pro" || tenant.planTier === "enterprise" ? (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
Pro
</span>
@@ -677,6 +701,11 @@ function TenantManagementSectionInner({
Community
</span>
)}
{tenant.payingCustomer && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-emerald-100 text-emerald-800" title="Paying customer (sales bookkeeping)">
Paying
</span>
)}
{tenant.validateAutopilotDevice && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
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 (
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
effective === "Pro"
? "bg-purple-100 text-purple-800"
: "bg-gray-100 text-gray-700"
}`}>
Effective: {effective}{!isProTier && trialActive ? " (Trial)" : ""}
<span
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
effective === "Pro"
? "bg-purple-100 text-purple-800"
: "bg-gray-100 text-gray-700"
}`}
title={viaMsp ? `Pro conferred by the managing tenant ${editingTenant.managedByProTenantId}` : undefined}
>
Effective: {effective}{viaMsp ? " (MSP)" : !isProTier && trialActive ? " (Trial)" : ""}
</span>
);
})()}
@@ -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.
</p>
</div>
<label className="flex items-start gap-2 text-sm text-gray-700">
<input
type="checkbox"
checked={editingTenant.payingCustomer === true}
onChange={(e) => setEditingTenant({ ...editingTenant, payingCustomer: e.target.checked })}
className="mt-0.5 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
/>
<span>
<span className="font-medium">Paying customer</span>
<span className="block text-xs text-gray-500">
Sales bookkeeping only: distinguishes a paid Pro from a support-assigned one or Pro conferred by an MSP. Changes nothing about the plan.
</span>
</span>
</label>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Delegated tenant slots (override)</label>
<div className="flex items-center gap-2">
@@ -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", () => {
@@ -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."
}
>
@@ -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}
/>
<div className="p-6 space-y-3">
{isProViaMsp(editionInfo) && (
<p className="text-sm text-purple-800 bg-purple-50 border border-purple-200 rounded-lg px-3 py-2">
Your Pro plan is included through one of these organizations. It ends when that delegation ends.
</p>
)}
{loading && !managers ? (
<p className="text-sm text-gray-500">Loading…</p>
) : !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 && (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
@@ -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 ? (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-purple-600 text-white">
{editionInfo.isTrial ? `Trial — ${daysLeft} day${daysLeft === 1 ? "" : "s"} left` : "Current plan"}
<span
className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold bg-purple-600 text-white"
title={viaMsp ? "Included through the organization that manages this tenant." : undefined}
>
{viaMsp
? "Current plan — via MSP"
: editionInfo.isTrial
? `Trial — ${daysLeft} day${daysLeft === 1 ? "" : "s"} left`
: "Current plan"}
</span>
) : (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-700 border border-purple-200">
@@ -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);
+30 -1
View File
@@ -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<string, unknown>;
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<EditionInfo, "edition" | "editionSource">): 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`;
@@ -88,6 +88,8 @@ export const SHARED_MANIFEST = {
"proDowngradedUtc",
"maxDelegatedTenantsOverride",
"mcpUsagePlanOverride",
"payingCustomer",
"managedByProTenantId",
"manufacturerWhitelist",
"modelWhitelist",
"validateAutopilotDevice",
@@ -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": {
@@ -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;