mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
e816c92b88
A tenant configuration row is never written blind any more (D-290). Three writers saved
from the 5-minute instance cache and four wrote a fresh read without an ETag: auto-re-enable
could lift a suspension a Global Admin had just renewed, the PUT could write the cached
Disabled=false over the offboarding tombstone during the drain barrier, and a concurrent
first login could overwrite the write-once OnboardedBy.
- TenantConfigurationService.CreateOrUpdateAsync next to UpdateAsync (shared core): fresh
read with ETag, tombstone refused, mutation decides, conditional replace, conditional
insert for a missing row, re-read and re-decide on a lost race.
- SaveConfigurationAsync and IConfigRepository.SaveTenantConfigurationAsync deleted; new
TryCreateTenantConfigurationAsync. The default row of GetConfigurationAsync is a
conditional insert.
- Converted: first-login seed (writes only while the stored row has no domain; only the
winning login fires the signup side effects), auto-re-enable (decided on the fresh row),
auth client-id tracking, app-homing flip, conferred-Pro grace stamp, offboarding tombstone.
- PUT/POST config/{tenantId} removed (function, catalog entries, response DTO). The Global
Admin tenant editor sends only its changed fields to the field PATCH; the Collect Logs
quick-config PATCHes its two diagnostics fields with ?intent=collect-logs, which keeps
the CollectLogsQuickConfigEnabled ops event. Shared diff helper lib/tenantConfigSave.ts.
- Tests: create-or-update and default-row races, concurrent first login, renewed
suspension, tombstone paths; repository and service doubles in ConfigRepoMockExtensions.
Regenerated shared manifest and wire types (web, MCP).
Deploy order: backend, then web right after (the old web's editor save and quick-config
use the removed PUT).
Bumps internal (D-290, plan, docs).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
671 lines
35 KiB
C#
671 lines
35 KiB
C#
using System.Net;
|
|
using System.Text.Json;
|
|
using AutopilotMonitor.Functions.Functions.Admin;
|
|
using AutopilotMonitor.Functions.Helpers;
|
|
using AutopilotMonitor.Functions.Security;
|
|
using AutopilotMonitor.Functions.Services;
|
|
using AutopilotMonitor.Shared.DataAccess;
|
|
using AutopilotMonitor.Shared.Models;
|
|
using AutopilotMonitor.Shared.Models.Config;
|
|
using AutopilotMonitor.Shared;
|
|
using Microsoft.Azure.Functions.Worker;
|
|
using Microsoft.Azure.Functions.Worker.Http;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace AutopilotMonitor.Functions.Functions.Config
|
|
{
|
|
/// <summary>
|
|
/// Plan/edition management: GA-only plan+trial mutation (PATCH plan), tenant-admin self-service
|
|
/// trial (POST trial), and the global usage-plan definitions (SectionUsagePlans). All writes go
|
|
/// through <see cref="TenantConfigurationService"/> so the 5-minute config cache is invalidated,
|
|
/// and every mutation is audited under the target tenant's trail.
|
|
/// </summary>
|
|
public class PlanManagementFunction
|
|
{
|
|
/// <summary>Self-service trial length. GA can grant arbitrary end dates via PATCH.</summary>
|
|
internal const int SelfServiceTrialDays = 30;
|
|
|
|
private readonly ILogger<PlanManagementFunction> _logger;
|
|
private readonly TenantConfigurationService _configService;
|
|
private readonly AdminConfigurationService _adminConfigService;
|
|
private readonly IMaintenanceRepository _maintenanceRepo;
|
|
private readonly OpsEventService _opsEvents;
|
|
private readonly ProConferralService _proConferral;
|
|
private readonly TimeProvider _time;
|
|
|
|
public PlanManagementFunction(
|
|
ILogger<PlanManagementFunction> logger,
|
|
TenantConfigurationService configService,
|
|
AdminConfigurationService adminConfigService,
|
|
IMaintenanceRepository maintenanceRepo,
|
|
OpsEventService opsEvents,
|
|
ProConferralService proConferral)
|
|
: this(logger, configService, adminConfigService, maintenanceRepo, opsEvents, proConferral, TimeProvider.System)
|
|
{
|
|
}
|
|
|
|
/// <summary>Test seam — inject a fake <see cref="TimeProvider"/> for deterministic trial math.</summary>
|
|
public PlanManagementFunction(
|
|
ILogger<PlanManagementFunction> logger,
|
|
TenantConfigurationService configService,
|
|
AdminConfigurationService adminConfigService,
|
|
IMaintenanceRepository maintenanceRepo,
|
|
OpsEventService opsEvents,
|
|
ProConferralService proConferral,
|
|
TimeProvider time)
|
|
{
|
|
_logger = logger;
|
|
_configService = configService;
|
|
_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, "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
|
|
/// to the Community fallback); null clears it (the edition default applies). It does NOT change the edition.
|
|
/// The legacy stored value "enterprise" stays readable (resolves as Pro) but is no longer
|
|
/// accepted on writes.
|
|
/// Setting a trial date grants/extends the trial (TrialConsumed is NOT touched — GA
|
|
/// re-grants stay possible); explicit null ends the trial. Absent properties are unchanged.
|
|
/// </summary>
|
|
[Function("SetTenantPlanTier")]
|
|
public async Task<HttpResponseData> SetPlanTier(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "config/{tenantId}/plan")] HttpRequestData req,
|
|
string tenantId)
|
|
{
|
|
try
|
|
{
|
|
var requestCtx = req.GetRequestContext();
|
|
var caller = requestCtx.UserPrincipalName ?? "Unknown";
|
|
_logger.LogInformation("SetTenantPlanTier: tenantId={TenantId} by {User}", requestCtx.TargetTenantId, caller);
|
|
|
|
// Presence-sensitive PATCH (absent = unchanged, null = clear): read as a document, keys
|
|
// from the wire DTO so a rename there breaks this walk at compile time.
|
|
var read = await req.ReadDocumentAsync();
|
|
if (read.Error != null) return read.Error;
|
|
var doc = read.Value!;
|
|
|
|
string? newPlanTier = null;
|
|
bool trialProvided = false;
|
|
DateTime? newTrialExpiresUtc = null;
|
|
bool slotsProvided = false;
|
|
int? newMaxDelegatedTenants = null;
|
|
bool mcpPlanProvided = false;
|
|
string? newMcpUsagePlan = null;
|
|
bool payingProvided = false;
|
|
bool newPayingCustomer = false;
|
|
|
|
using (doc)
|
|
{
|
|
if (doc.RootElement.TryGetProperty(PlanPatchKeys.PlanTier, out var tierProp))
|
|
{
|
|
if (tierProp.ValueKind != JsonValueKind.String)
|
|
return await req.BadRequestAsync("planTier must be a string");
|
|
|
|
newPlanTier = tierProp.GetString()!.Trim().ToLowerInvariant();
|
|
if (newPlanTier != FeatureEntitlementCatalog.CommunityTierName &&
|
|
newPlanTier != FeatureEntitlementCatalog.ProTierName)
|
|
{
|
|
return await req.BadRequestAsync($"Invalid planTier. Valid values: {FeatureEntitlementCatalog.CommunityTierName}, {FeatureEntitlementCatalog.ProTierName}");
|
|
}
|
|
}
|
|
|
|
if (doc.RootElement.TryGetProperty(PlanPatchKeys.TrialExpiresUtc, out var trialProp))
|
|
{
|
|
trialProvided = true;
|
|
if (trialProp.ValueKind == JsonValueKind.Null)
|
|
{
|
|
newTrialExpiresUtc = null; // explicit null = end trial
|
|
}
|
|
else if (trialProp.ValueKind == JsonValueKind.String &&
|
|
trialProp.TryGetDateTime(out var parsed))
|
|
{
|
|
newTrialExpiresUtc = parsed.Kind == DateTimeKind.Utc ? parsed : parsed.ToUniversalTime();
|
|
}
|
|
else
|
|
{
|
|
return await req.BadRequestAsync("trialExpiresUtc must be an ISO-8601 date-time string or null");
|
|
}
|
|
}
|
|
|
|
if (doc.RootElement.TryGetProperty(PlanPatchKeys.MaxDelegatedTenants, out var slotsProp))
|
|
{
|
|
slotsProvided = true;
|
|
if (slotsProp.ValueKind == JsonValueKind.Null)
|
|
{
|
|
newMaxDelegatedTenants = null; // explicit null = clear the override
|
|
}
|
|
else if (slotsProp.ValueKind == JsonValueKind.Number && slotsProp.TryGetInt32(out var slots) && slots >= 0)
|
|
{
|
|
newMaxDelegatedTenants = slots;
|
|
}
|
|
else
|
|
{
|
|
return await req.BadRequestAsync("maxDelegatedTenants must be a non-negative integer or null");
|
|
}
|
|
}
|
|
|
|
if (doc.RootElement.TryGetProperty(PlanPatchKeys.McpUsagePlan, out var mcpPlanProp))
|
|
{
|
|
mcpPlanProvided = true;
|
|
if (mcpPlanProp.ValueKind == JsonValueKind.Null)
|
|
{
|
|
newMcpUsagePlan = null; // explicit null = clear the override
|
|
}
|
|
else if (mcpPlanProp.ValueKind == JsonValueKind.String)
|
|
{
|
|
// Blank = clear as well (the select's "edition default" option); a name is validated below.
|
|
newMcpUsagePlan = TenantEntitlementService.NormalizePlanName(mcpPlanProp.GetString());
|
|
}
|
|
else
|
|
{
|
|
return await req.BadRequestAsync("mcpUsagePlan must be a plan name or null");
|
|
}
|
|
}
|
|
|
|
if (doc.RootElement.TryGetProperty(PlanPatchKeys.PayingCustomer, out var payingProp))
|
|
{
|
|
if (payingProp.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
|
|
return await req.BadRequestAsync("payingCustomer must be a boolean");
|
|
payingProvided = true;
|
|
newPayingCustomer = payingProp.GetBoolean();
|
|
}
|
|
}
|
|
|
|
if (newPlanTier == null && !trialProvided && !slotsProvided && !mcpPlanProvided && !payingProvided)
|
|
return await req.BadRequestAsync("Provide planTier, trialExpiresUtc, maxDelegatedTenants, mcpUsagePlan and/or payingCustomer");
|
|
|
|
if (newMcpUsagePlan != null)
|
|
{
|
|
// Fail-loud on SET: an override naming a plan that exists nowhere would resolve fail-closed to
|
|
// the Community fallback at read time — silently shrinking the tenant instead of raising it.
|
|
var adminConfig = await _adminConfigService.GetConfigurationAsync();
|
|
var definitions = PlanTierDefinitionParser.Parse(adminConfig.PlanTierDefinitionsJson);
|
|
if (!IsKnownUsagePlan(newMcpUsagePlan, definitions))
|
|
{
|
|
var known = string.Join(", ", KnownUsagePlanNames(definitions));
|
|
return await req.BadRequestAsync($"Unknown mcpUsagePlan '{newMcpUsagePlan}'. Known plans: {known}");
|
|
}
|
|
}
|
|
|
|
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
|
var changes = new Dictionary<string, string>();
|
|
var wasPermanentPro = false;
|
|
TenantEdition editionBefore = default, editionAfter = default;
|
|
|
|
// Decided on the fresh row and written under its ETag; the mutation re-runs on a lost race.
|
|
var update = await _configService.UpdateAsync(requestCtx.TargetTenantId, row =>
|
|
{
|
|
changes.Clear();
|
|
// 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.
|
|
wasPermanentPro = FeatureEntitlementCatalog.IsPermanentProTier(row.PlanTier);
|
|
(editionBefore, editionAfter) =
|
|
ApplyPlanChanges(row, newPlanTier, trialProvided, newTrialExpiresUtc, caller, nowUtc, changes);
|
|
ApplyDelegatedSlotChange(row, slotsProvided, newMaxDelegatedTenants, changes);
|
|
ApplyMcpUsagePlanChange(row, mcpPlanProvided, newMcpUsagePlan, changes);
|
|
ApplyPayingCustomerChange(row, payingProvided, newPayingCustomer, changes);
|
|
if (changes.Count == 0)
|
|
return false;
|
|
row.UpdatedBy = caller;
|
|
return true;
|
|
}, "plan", "plan tier change");
|
|
|
|
if (await UpdateFailureAsync(req, update) is { } failure)
|
|
return failure;
|
|
var config = update.Config!;
|
|
|
|
if (update.Status == TenantConfigUpdateStatus.Updated)
|
|
{
|
|
await _maintenanceRepo.LogAuditEntryAsync(
|
|
requestCtx.TargetTenantId,
|
|
"UPDATE",
|
|
"TenantPlan",
|
|
requestCtx.TargetTenantId,
|
|
caller,
|
|
changes);
|
|
|
|
if (editionBefore == TenantEdition.Pro && editionAfter == TenantEdition.Community)
|
|
{
|
|
await _opsEvents.RecordTenantPlanDowngradedAsync(
|
|
requestCtx.TargetTenantId,
|
|
config.DomainName,
|
|
caller,
|
|
TenantEntitlementService.GetRetentionGraceEndUtc(config, nowUtc),
|
|
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
|
|
// an unrelated planTier-only edit stays silent; ending a trial (explicit null)
|
|
// is a downgrade, already covered above.
|
|
if (changes.ContainsKey("TrialExpiresUtc") && config.TrialExpiresUtc.HasValue)
|
|
{
|
|
await _opsEvents.RecordTenantTrialStartedAsync(
|
|
requestCtx.TargetTenantId,
|
|
config.DomainName,
|
|
config.ContactEmail,
|
|
config.CompanyName,
|
|
config.TrialStartedUtc,
|
|
config.TrialExpiresUtc,
|
|
caller,
|
|
selfService: false);
|
|
}
|
|
}
|
|
|
|
var resolution = FeatureEntitlementCatalog.Resolve(config, nowUtc);
|
|
var response = req.CreateResponse(HttpStatusCode.OK);
|
|
await response.WriteAsJsonAsync(new SetTenantPlanTierResponse
|
|
{
|
|
TenantId = requestCtx.TargetTenantId,
|
|
PlanTier = config.PlanTier,
|
|
TrialExpiresUtc = config.TrialExpiresUtc,
|
|
TrialConsumed = config.TrialConsumed,
|
|
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;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return await req.InternalServerErrorAsync(_logger, ex, "PlanManagement");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure mutation core of the PATCH plan endpoint — no I/O, unit-testable. Applies the
|
|
/// plan/trial changes to <paramref name="config"/>, records human-readable entries in
|
|
/// <paramref name="changes"/>, and returns the EFFECTIVE edition before/after (same
|
|
/// <paramref name="nowUtc"/> for both, so only the mutation itself can flip it).
|
|
///
|
|
/// Maintains the retention downgrade grace anchor: an effective Pro→Community
|
|
/// transition stamps <see cref="TenantConfiguration.ProDowngradedUtc"/> (covers both a
|
|
/// planTier downgrade and an explicitly ended trial); any state that is effectively Pro
|
|
/// afterwards clears the anchor, so a re-upgrade also resets the grace clock.
|
|
/// </summary>
|
|
internal static (TenantEdition Before, TenantEdition After) ApplyPlanChanges(
|
|
TenantConfiguration config,
|
|
string? newPlanTier,
|
|
bool trialProvided,
|
|
DateTime? newTrialExpiresUtc,
|
|
string caller,
|
|
DateTime nowUtc,
|
|
Dictionary<string, string> changes)
|
|
{
|
|
var before = FeatureEntitlementCatalog.ResolveEdition(config, nowUtc);
|
|
|
|
if (newPlanTier != null && !string.Equals(config.PlanTier, newPlanTier, StringComparison.Ordinal))
|
|
{
|
|
changes["PlanTier"] = $"{config.PlanTier} -> {newPlanTier}";
|
|
config.PlanTier = newPlanTier;
|
|
}
|
|
|
|
if (trialProvided && config.TrialExpiresUtc != newTrialExpiresUtc)
|
|
{
|
|
changes["TrialExpiresUtc"] = $"{FormatUtc(config.TrialExpiresUtc)} -> {FormatUtc(newTrialExpiresUtc)}";
|
|
config.TrialExpiresUtc = newTrialExpiresUtc;
|
|
if (newTrialExpiresUtc.HasValue)
|
|
{
|
|
config.TrialStartedUtc ??= nowUtc;
|
|
config.TrialGrantedBy = caller;
|
|
}
|
|
}
|
|
|
|
var after = FeatureEntitlementCatalog.ResolveEdition(config, nowUtc);
|
|
|
|
if (before == TenantEdition.Pro && after == TenantEdition.Community)
|
|
{
|
|
changes["ProDowngradedUtc"] = $"{FormatUtc(config.ProDowngradedUtc)} -> {FormatUtc(nowUtc)}";
|
|
config.ProDowngradedUtc = nowUtc;
|
|
}
|
|
else if (after == TenantEdition.Pro && config.ProDowngradedUtc.HasValue)
|
|
{
|
|
changes["ProDowngradedUtc"] = $"{FormatUtc(config.ProDowngradedUtc)} -> (none)";
|
|
config.ProDowngradedUtc = null;
|
|
}
|
|
|
|
return (before, after);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure mutation core for the delegated (MSP) slot override: records and applies the change only
|
|
/// when a value was provided AND differs. The audit entry names "(catalog)" for the absent override.
|
|
/// </summary>
|
|
internal static void ApplyDelegatedSlotChange(
|
|
TenantConfiguration config, bool provided, int? maxDelegatedTenants, Dictionary<string, string> changes)
|
|
{
|
|
if (!provided || config.MaxDelegatedTenantsOverride == maxDelegatedTenants)
|
|
return;
|
|
changes["MaxDelegatedTenantsOverride"] =
|
|
$"{config.MaxDelegatedTenantsOverride?.ToString() ?? "(catalog)"} -> {maxDelegatedTenants?.ToString() ?? "(catalog)"}";
|
|
config.MaxDelegatedTenantsOverride = maxDelegatedTenants;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure mutation core for the tenant-wide MCP usage-plan override: records and applies the change only
|
|
/// when a value was provided AND differs (case-insensitive — the stored value is normalized on write).
|
|
/// The audit entry names "(edition default)" for the absent override. Validation of the plan name against
|
|
/// the definitions happens in the endpoint (I/O), not here.
|
|
/// </summary>
|
|
internal static void ApplyMcpUsagePlanChange(
|
|
TenantConfiguration config, bool provided, string? mcpUsagePlan, Dictionary<string, string> changes)
|
|
{
|
|
var normalized = TenantEntitlementService.NormalizePlanName(mcpUsagePlan);
|
|
if (!provided || string.Equals(config.McpUsagePlanOverride, normalized, StringComparison.OrdinalIgnoreCase))
|
|
return;
|
|
changes["McpUsagePlanOverride"] =
|
|
$"{config.McpUsagePlanOverride ?? "(edition default)"} -> {normalized ?? "(edition default)"}";
|
|
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));
|
|
|
|
private static IEnumerable<string> KnownUsagePlanNames(IEnumerable<PlanTierDefinition> definitions)
|
|
=> definitions.Select(d => TenantEntitlementService.NormalizePlanName(d.Name)).Where(n => n != null).Select(n => n!).OrderBy(n => n, StringComparer.Ordinal);
|
|
|
|
/// <summary>
|
|
/// The response for an <see cref="TenantConfigurationService.UpdateAsync"/> outcome that wrote
|
|
/// nothing for a reason other than the caller's own decision; null for Updated and Declined.
|
|
/// </summary>
|
|
private static async Task<HttpResponseData?> UpdateFailureAsync(HttpRequestData req, TenantConfigUpdate update)
|
|
{
|
|
switch (update.Status)
|
|
{
|
|
case TenantConfigUpdateStatus.NotFound:
|
|
return await req.NotFoundAsync("Tenant not found");
|
|
case TenantConfigUpdateStatus.OffboardingInProgress:
|
|
// The answer the suspension gate gives once its cache has caught up with the tombstone.
|
|
return await req.ErrorAsync(HttpStatusCode.Forbidden,
|
|
Constants.ApiErrorCodes.TenantSuspended, TenantOffboardFunction.OffboardingDisabledReason);
|
|
case TenantConfigUpdateStatus.Conflict:
|
|
return await req.ErrorAsync(HttpStatusCode.Conflict,
|
|
Constants.ApiErrorCodes.Conflict, "The tenant configuration was changed concurrently. Retry shortly.");
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure verdict for the self-service trial start; null = allowed. Order matters and is
|
|
/// test-pinned: the terminal conditions (trial consumed, already Pro) win over the
|
|
/// contact-profile prompt — asking for an address would be pointless there. The contact
|
|
/// profile (address + company name) is enforced ONLY at this plan entry point, never as
|
|
/// a runtime gate on Pro features (existing Pro tenants get the dashboard banner instead
|
|
/// of a lockout); GA plan assignment (PATCH plan) deliberately has no such block — the
|
|
/// admin UI warns. One verdict names everything that is missing, so a caller never has
|
|
/// to fix the profile in two round trips.
|
|
/// </summary>
|
|
internal static (string Code, string Message)? EvaluateTrialStart(TenantConfiguration config, DateTime nowUtc)
|
|
{
|
|
if (config.TrialConsumed)
|
|
{
|
|
return (Constants.ApiErrorCodes.TrialAlreadyConsumed,
|
|
"This tenant has already used its one self-service Pro trial. Contact support to extend.");
|
|
}
|
|
|
|
// Effective edition, conferred Pro included: a tenant managed by a Pro tenant needs no trial.
|
|
if (FeatureEntitlementCatalog.ResolveEdition(config, nowUtc) == TenantEdition.Pro)
|
|
{
|
|
return (Constants.ApiErrorCodes.AlreadyPro, "This tenant is already on the Pro plan.");
|
|
}
|
|
|
|
var missing = MissingContactProfileParts(config);
|
|
if (missing.Count > 0)
|
|
{
|
|
return (Constants.ApiErrorCodes.ContactProfileRequired,
|
|
$"Pro requires a tenant contact profile so we can reach and identify you for service or security matters. Missing: {string.Join(" and ", missing)}. Set it under Settings → Tenant → Contact, then start the trial.");
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The parts of the contact profile the Pro entry point requires and this config lacks,
|
|
/// in display order. Empty = complete.
|
|
/// </summary>
|
|
internal static IReadOnlyList<string> MissingContactProfileParts(TenantConfiguration config)
|
|
{
|
|
var missing = new List<string>(2);
|
|
if (string.IsNullOrWhiteSpace(config.ContactEmail)) missing.Add("contact address");
|
|
if (string.IsNullOrWhiteSpace(config.CompanyName)) missing.Add("company name");
|
|
return missing;
|
|
}
|
|
|
|
/// <summary>
|
|
/// POST /api/config/{tenantId}/trial — TenantAdminOrGA (catalog-enforced). Self-service
|
|
/// 30-day Pro trial, exactly once per tenant — also across offboarding and re-onboarding
|
|
/// (the offboarding pointer carries <see cref="TenantConfiguration.TrialConsumed"/> over).
|
|
/// 409 when the trial was already consumed or the tenant is already effectively Pro.
|
|
/// </summary>
|
|
[Function("StartTenantTrial")]
|
|
public async Task<HttpResponseData> StartTrial(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "config/{tenantId}/trial")] HttpRequestData req,
|
|
string tenantId)
|
|
{
|
|
try
|
|
{
|
|
var requestCtx = req.GetRequestContext();
|
|
var caller = requestCtx.UserPrincipalName ?? "Unknown";
|
|
_logger.LogInformation("StartTenantTrial: tenantId={TenantId} by {User}", requestCtx.TargetTenantId, caller);
|
|
|
|
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
|
(string Code, string Message)? deny = null;
|
|
|
|
// The verdict runs on the fresh row inside the conditional write: two concurrent
|
|
// starts cannot both see TrialConsumed=false, and a stale cached row is never saved.
|
|
var update = await _configService.UpdateAsync(requestCtx.TargetTenantId, row =>
|
|
{
|
|
deny = EvaluateTrialStart(row, nowUtc);
|
|
if (deny != null)
|
|
return false;
|
|
row.TrialStartedUtc = nowUtc;
|
|
row.TrialExpiresUtc = nowUtc.AddDays(SelfServiceTrialDays);
|
|
row.TrialConsumed = true;
|
|
row.TrialGrantedBy = caller;
|
|
// Effectively Pro again — a leftover retention grace anchor is obsolete.
|
|
row.ProDowngradedUtc = null;
|
|
row.UpdatedBy = caller;
|
|
return true;
|
|
}, "plan", "self-service trial start");
|
|
|
|
if (await UpdateFailureAsync(req, update) is { } failure)
|
|
return failure;
|
|
if (deny is { } refused)
|
|
return await req.ErrorAsync(HttpStatusCode.Conflict, refused.Code, refused.Message);
|
|
var config = update.Config!;
|
|
|
|
await _maintenanceRepo.LogAuditEntryAsync(
|
|
requestCtx.TargetTenantId,
|
|
"CREATE",
|
|
"TenantTrial",
|
|
requestCtx.TargetTenantId,
|
|
caller,
|
|
new Dictionary<string, string>
|
|
{
|
|
{ "TrialStartedUtc", FormatUtc(config.TrialStartedUtc) },
|
|
{ "TrialExpiresUtc", FormatUtc(config.TrialExpiresUtc) }
|
|
});
|
|
|
|
// The conversion moment. Fired only after the conditional write landed (UpdateAsync
|
|
// returned Updated), so an alert never announces a trial that was not stored.
|
|
await _opsEvents.RecordTenantTrialStartedAsync(
|
|
requestCtx.TargetTenantId,
|
|
config.DomainName,
|
|
config.ContactEmail,
|
|
config.CompanyName,
|
|
config.TrialStartedUtc,
|
|
config.TrialExpiresUtc,
|
|
caller,
|
|
selfService: true);
|
|
|
|
var response = req.CreateResponse(HttpStatusCode.OK);
|
|
await response.WriteAsJsonAsync(new StartTenantTrialResponse
|
|
{
|
|
TenantId = requestCtx.TargetTenantId,
|
|
TrialStartedUtc = config.TrialStartedUtc,
|
|
TrialExpiresUtc = config.TrialExpiresUtc,
|
|
EffectiveEdition = FeatureEntitlementCatalog.ProTierName
|
|
});
|
|
return response;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return await req.InternalServerErrorAsync(_logger, ex, "PlanManagement");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The built-in catalog values per edition — what a blank field of the same-named plan definition
|
|
/// falls back to (McpQuotaService). Sent with the definitions so the operator sees what "unset" means.
|
|
/// </summary>
|
|
internal static IReadOnlyList<UsagePlanCatalogDefaults> BuildUsagePlanCatalog()
|
|
=> new[] { TenantEdition.Community, TenantEdition.Pro }
|
|
.Select(FeatureEntitlementCatalog.Get)
|
|
.Select(e => new UsagePlanCatalogDefaults
|
|
{
|
|
Edition = e.McpUsagePlanName,
|
|
DailyRequestLimit = e.McpDailyRequestLimit,
|
|
MonthlyRequestLimit = e.McpMonthlyRequestLimit,
|
|
TenantDailyRequestLimit = e.McpTenantDailyRequestLimit,
|
|
TenantMonthlyRequestLimit = e.McpTenantMonthlyRequestLimit,
|
|
IncludedDelegatedSlots = e.MaxDelegatedTenants,
|
|
SlotDailyRequestLimit = e.McpSlotDailyRequestLimit,
|
|
SlotMonthlyRequestLimit = e.McpSlotMonthlyRequestLimit,
|
|
SlotTenantDailyRequestLimit = e.McpSlotTenantDailyRequestLimit,
|
|
SlotTenantMonthlyRequestLimit = e.McpSlotTenantMonthlyRequestLimit,
|
|
})
|
|
.ToList();
|
|
|
|
/// <summary>
|
|
/// GET /api/global/config/plan-tiers
|
|
/// Returns plan tier definitions from AdminConfiguration.PlanTierDefinitionsJson plus the catalog fallbacks.
|
|
/// </summary>
|
|
[Function("GetPlanTierDefinitions")]
|
|
public async Task<HttpResponseData> GetPlanTierDefinitions(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/config/plan-tiers")] HttpRequestData req)
|
|
{
|
|
try
|
|
{
|
|
var config = await _adminConfigService.GetConfigurationAsync();
|
|
var tiers = PlanTierDefinitionParser.Parse(config.PlanTierDefinitionsJson);
|
|
|
|
var response = req.CreateResponse(HttpStatusCode.OK);
|
|
await response.WriteAsJsonAsync(new PlanTierDefinitionsResponse { Tiers = tiers, Catalog = BuildUsagePlanCatalog() });
|
|
return response;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return await req.InternalServerErrorAsync(_logger, ex, "PlanManagement");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// PUT /api/global/config/plan-tiers
|
|
/// Saves plan tier definitions. Body: { "tiers": [...] }
|
|
/// </summary>
|
|
[Function("SetPlanTierDefinitions")]
|
|
public async Task<HttpResponseData> SetPlanTierDefinitions(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "global/config/plan-tiers")] HttpRequestData req)
|
|
{
|
|
try
|
|
{
|
|
var read = await req.ReadAsync<SetPlanTierDefinitionsRequest>();
|
|
if (read.Error != null) return read.Error;
|
|
var body = read.Value!;
|
|
if (body.Tiers == null || body.Tiers.Count == 0)
|
|
{
|
|
return await req.BadRequestAsync("At least one tier definition is required");
|
|
}
|
|
|
|
// Validate tier names are unique
|
|
var names = body.Tiers.Select(t => t.Name.ToLowerInvariant()).ToList();
|
|
if (names.Distinct().Count() != names.Count)
|
|
{
|
|
return await req.BadRequestAsync("Tier names must be unique");
|
|
}
|
|
|
|
// Normalize names to lowercase
|
|
foreach (var tier in body.Tiers)
|
|
tier.Name = tier.Name.ToLowerInvariant();
|
|
|
|
var tiersJson = JsonSerializer.Serialize(body.Tiers);
|
|
await _adminConfigService.UpdateAsync(
|
|
c => { c.PlanTierDefinitionsJson = tiersJson; return null; },
|
|
TenantHelper.GetUserIdentifier(req), "plan-tiers");
|
|
|
|
var response = req.CreateResponse(HttpStatusCode.OK);
|
|
await response.WriteAsJsonAsync(new PlanTierDefinitionsResponse { Tiers = body.Tiers, Catalog = BuildUsagePlanCatalog() });
|
|
return response;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return await req.InternalServerErrorAsync(_logger, ex, "PlanManagement");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Wire keys of the plan PATCH, derived from <see cref="PatchTenantPlanRequest"/> — the one body
|
|
/// read as a document because absent and null mean different things. PlanPatchKeyParityTests
|
|
/// pins this list against the DTO's properties.
|
|
/// </summary>
|
|
internal static class PlanPatchKeys
|
|
{
|
|
public static readonly string PlanTier = Key(nameof(PatchTenantPlanRequest.PlanTier));
|
|
public static readonly string TrialExpiresUtc = Key(nameof(PatchTenantPlanRequest.TrialExpiresUtc));
|
|
public static readonly string MaxDelegatedTenants = Key(nameof(PatchTenantPlanRequest.MaxDelegatedTenants));
|
|
public static readonly string McpUsagePlan = Key(nameof(PatchTenantPlanRequest.McpUsagePlan));
|
|
public static readonly string PayingCustomer = Key(nameof(PatchTenantPlanRequest.PayingCustomer));
|
|
|
|
public static readonly string[] All = { PlanTier, TrialExpiresUtc, MaxDelegatedTenants, McpUsagePlan, PayingCustomer };
|
|
|
|
private static string Key(string property) => JsonNamingPolicy.CamelCase.ConvertName(property);
|
|
}
|
|
|
|
private static string FormatUtc(DateTime? value)
|
|
=> value?.ToString("yyyy-MM-ddTHH:mm:ssZ") ?? "(none)";
|
|
}
|
|
}
|