mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 21:19:38 +00:00
395fdd49df
* feat(backend): pre-write config backup net - every tenant/admin config save snapshots first New ConfigurationBackups table (PK=tenantId or GlobalConfig, RK=reverse-ticks+guid, newest-first) holds the raw stored row as EntityJson before any overwriting save of TenantConfiguration or AdminConfiguration. Hook lives in TableConfigRepository, the single choke point all writers funnel through - fail-soft (a backup hiccup never breaks a save), skips row creations and noise-only changes (LastAuthClientId flips), prunes to the newest 2 per partition. Source/reason tagging via a new SaveTenantConfigurationAsync overload (not optional params - Moq expression trees, CS0854); portal-put/plan/auth/app-homing/offboard call sites tagged. Offboarding: backup partition wiped in the bulk phase (TenantPartitionTables) and by the marker-cleanup tombstone sweep; the GlobalConfig partition never matches a tenant GUID and survives. Table registered in TableNames.All + CriticalBackupTables.All. Groundwork for the transactional MCP config-write flow (patch/revert endpoints follow). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(backend): transactional tenant-config field patch + revert with verified writes New GA-only surface (policy catalog: GlobalAdminOnly + RouteParam): - PATCH config/{tenantId}/fields - field-level patch through TenantConfigPatchService: fresh ETag read -> field gate (deny-list incl. plan/trial, HomedAppClientId, auth provenance; unknown fields 400 with the name) -> patch onto a clone -> shared validation -> fail-CLOSED backup -> CAS replace -> fresh re-read -> verify that EXACTLY the intended fields changed -> automatic ETag-conditional rollback on drift (catches Store/Map serialization asymmetry). - POST config/{tenantId}/revert - restore from a ConfigurationBackups snapshot (latest by default); snapshots current state first (revert is revertible), preserves plan/homing/provenance fields unless includeProtectedFields. - GET config/{tenantId}/backups - metadata + masked diff only, never EntityJson. Supporting changes: IConfigRepository gains GetTenantConfigurationWithEtagAsync + TryReplaceTenantConfigurationAsync (412 -> false, no implicit backup hook); validators moved from the PUT into shared TenantConfigValidation (PUT re-pointed, forwarding shims keep test call sites); GET config/{tenantId} honors ?view=redacted so a GA's MCP session never pulls clear-text secrets into model context. Caller-tier deny-lists (TenantAdmin/DelegatedAdmin) ship now for the phase-2 flip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(mcp): tenant-config write tools - update/revert with snapshot-backed verified writes First mutating surface on the MCP server, strictGa (real Global Admin) only: - get_tenant_config: full config read, ALWAYS ?view=redacted (secrets never reach model context; pinned by config-write-tools tests) - update_tenant_config: field-level patch via PATCH config/{tenantId}/fields; required reason; response = applied fields + masked diff + backupId - list_tenant_config_backups: snapshot metadata + masked diff only - revert_tenant_config: restore latest/named snapshot; protected fields stay current unless includeProtectedFields; revert is itself revertible New MUTATING annotation + TenantGuidSchema (path-traversal guard) in shared.ts; role-aware instructions headline (non-GA keeps the exact READ-ONLY wording); catalog snapshot test extends GA_FULL + new CONFIG_WRITE_GA_STRICT difference list (Global Reader/tenant/delegated see none of the four). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(backend): de-flake session-deletion heartbeat test - condition-gated handler instead of wall-clock window The heartbeat test held the handler for a FIXED 350ms and expected the 50ms heartbeat timer to tick >=3 times inside that window. On loaded CI runners the heartbeat Task.Delay stretches disproportionately to the handler delay, the handler completes with <3 heartbeats fired, and the awaited condition can never become true afterwards (CI failure on PR #120). The handler now holds the message in-flight UNTIL the test observed 3 heartbeats (with ct + own 15s deadline as termination guards), so the assertion is reached deterministically regardless of scheduler jitter. Verified: 12/12 local runs green, full suite green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
387 lines
18 KiB
C#
387 lines
18 KiB
C#
using System.Net;
|
|
using System.Text.Json;
|
|
using AutopilotMonitor.Functions.Helpers;
|
|
using AutopilotMonitor.Functions.Security;
|
|
using AutopilotMonitor.Functions.Services;
|
|
using AutopilotMonitor.Shared.DataAccess;
|
|
using AutopilotMonitor.Shared.Models;
|
|
using AutopilotMonitor.Shared.Models.Config;
|
|
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 TimeProvider _time;
|
|
|
|
public PlanManagementFunction(
|
|
ILogger<PlanManagementFunction> logger,
|
|
TenantConfigurationService configService,
|
|
AdminConfigurationService adminConfigService,
|
|
IMaintenanceRepository maintenanceRepo)
|
|
: this(logger, configService, adminConfigService, maintenanceRepo, TimeProvider.System)
|
|
{
|
|
}
|
|
|
|
/// <summary>Test seam — inject a fake <see cref="TimeProvider"/> for deterministic trial math.</summary>
|
|
public PlanManagementFunction(
|
|
ILogger<PlanManagementFunction> logger,
|
|
TenantConfigurationService configService,
|
|
AdminConfigurationService adminConfigService,
|
|
IMaintenanceRepository maintenanceRepo,
|
|
TimeProvider time)
|
|
{
|
|
_logger = logger;
|
|
_configService = configService;
|
|
_adminConfigService = adminConfigService;
|
|
_maintenanceRepo = maintenanceRepo;
|
|
_time = time;
|
|
}
|
|
|
|
/// <summary>
|
|
/// PATCH /api/config/{tenantId}/plan — GlobalAdminOnly (catalog-enforced).
|
|
/// Body: { "planTier"?: "community"|"pro", "trialExpiresUtc"?: ISO-8601 | null }.
|
|
/// 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);
|
|
|
|
var body = await req.ReadAsStringAsync() ?? string.Empty;
|
|
JsonDocument doc;
|
|
try
|
|
{
|
|
doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body);
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return await BadRequestAsync(req, "Invalid JSON body");
|
|
}
|
|
|
|
string? newPlanTier = null;
|
|
bool trialProvided = false;
|
|
DateTime? newTrialExpiresUtc = null;
|
|
|
|
using (doc)
|
|
{
|
|
if (doc.RootElement.ValueKind != JsonValueKind.Object)
|
|
return await BadRequestAsync(req, "Body must be a JSON object");
|
|
|
|
if (doc.RootElement.TryGetProperty("planTier", out var tierProp))
|
|
{
|
|
if (tierProp.ValueKind != JsonValueKind.String)
|
|
return await BadRequestAsync(req, "planTier must be a string");
|
|
|
|
newPlanTier = tierProp.GetString()!.Trim().ToLowerInvariant();
|
|
if (newPlanTier != FeatureEntitlementCatalog.CommunityTierName &&
|
|
newPlanTier != FeatureEntitlementCatalog.ProTierName)
|
|
{
|
|
return await BadRequestAsync(req,
|
|
$"Invalid planTier. Valid values: {FeatureEntitlementCatalog.CommunityTierName}, {FeatureEntitlementCatalog.ProTierName}");
|
|
}
|
|
}
|
|
|
|
if (doc.RootElement.TryGetProperty("trialExpiresUtc", out var trialProp))
|
|
{
|
|
trialProvided = true;
|
|
if (trialProp.ValueKind == JsonValueKind.Null)
|
|
{
|
|
newTrialExpiresUtc = null; // explicit null = end trial
|
|
}
|
|
else if (trialProp.ValueKind == JsonValueKind.String &&
|
|
trialProp.TryGetDateTime(out var parsed))
|
|
{
|
|
newTrialExpiresUtc = parsed.Kind == DateTimeKind.Utc ? parsed : parsed.ToUniversalTime();
|
|
}
|
|
else
|
|
{
|
|
return await BadRequestAsync(req, "trialExpiresUtc must be an ISO-8601 date-time string or null");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (newPlanTier == null && !trialProvided)
|
|
return await BadRequestAsync(req, "Provide planTier and/or trialExpiresUtc");
|
|
|
|
var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
|
|
if (config == null)
|
|
{
|
|
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
|
|
await notFound.WriteAsJsonAsync(new { error = "Tenant not found" });
|
|
return notFound;
|
|
}
|
|
|
|
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
|
var changes = new Dictionary<string, string>();
|
|
|
|
if (newPlanTier != null && !string.Equals(config.PlanTier, newPlanTier, StringComparison.Ordinal))
|
|
{
|
|
changes["PlanTier"] = $"{config.PlanTier} -> {newPlanTier}";
|
|
config.PlanTier = newPlanTier;
|
|
}
|
|
|
|
if (trialProvided && config.TrialExpiresUtc != newTrialExpiresUtc)
|
|
{
|
|
changes["TrialExpiresUtc"] = $"{FormatUtc(config.TrialExpiresUtc)} -> {FormatUtc(newTrialExpiresUtc)}";
|
|
config.TrialExpiresUtc = newTrialExpiresUtc;
|
|
if (newTrialExpiresUtc.HasValue)
|
|
{
|
|
config.TrialStartedUtc ??= nowUtc;
|
|
config.TrialGrantedBy = caller;
|
|
}
|
|
}
|
|
|
|
if (changes.Count > 0)
|
|
{
|
|
config.UpdatedBy = caller;
|
|
// Fail-loud: SaveConfigurationAsync throws when the write did not persist (and
|
|
// invalidates the cached — possibly mutated — instance in its finally), so a
|
|
// failed save can never be audited or returned as 200.
|
|
await _configService.SaveConfigurationAsync(config, "plan", "plan tier change");
|
|
|
|
await _maintenanceRepo.LogAuditEntryAsync(
|
|
requestCtx.TargetTenantId,
|
|
"UPDATE",
|
|
"TenantPlan",
|
|
requestCtx.TargetTenantId,
|
|
caller,
|
|
changes);
|
|
}
|
|
|
|
var effectiveEdition = FeatureEntitlementCatalog.ResolveEdition(config.PlanTier, config.TrialExpiresUtc, nowUtc);
|
|
|
|
var response = req.CreateResponse(HttpStatusCode.OK);
|
|
await response.WriteAsJsonAsync(new
|
|
{
|
|
tenantId = requestCtx.TargetTenantId,
|
|
planTier = config.PlanTier,
|
|
trialExpiresUtc = config.TrialExpiresUtc,
|
|
trialConsumed = config.TrialConsumed,
|
|
effectiveEdition = effectiveEdition.ToString().ToLowerInvariant()
|
|
});
|
|
return response;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error setting plan tier");
|
|
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
|
await errorResponse.WriteAsJsonAsync(new { error = "Internal server error" });
|
|
return errorResponse;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// POST /api/config/{tenantId}/trial — TenantAdminOrGA (catalog-enforced). Self-service
|
|
/// 30-day Pro trial, exactly once per tenant. 409 when the trial was already
|
|
/// consumed or the tenant is already effectively Pro.
|
|
/// </summary>
|
|
/// <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-address prompt — asking for an address would be pointless there. The contact
|
|
/// requirement 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.
|
|
/// </summary>
|
|
internal static (string Error, string Message)? EvaluateTrialStart(TenantConfiguration config, DateTime nowUtc)
|
|
{
|
|
if (config.TrialConsumed)
|
|
{
|
|
return ("TrialAlreadyConsumed",
|
|
"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)
|
|
{
|
|
return ("AlreadyPro", "This tenant is already on the Pro plan.");
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(config.ContactEmail))
|
|
{
|
|
return ("ContactEmailRequired",
|
|
"Pro requires a tenant contact address so we can reach you about service or security matters. Set it under Settings → Tenant → Contact, then start the trial.");
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
[Function("StartTenantTrial")]
|
|
public async Task<HttpResponseData> StartTrial(
|
|
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "config/{tenantId}/trial")] HttpRequestData req,
|
|
string tenantId)
|
|
{
|
|
try
|
|
{
|
|
var requestCtx = req.GetRequestContext();
|
|
var caller = requestCtx.UserPrincipalName ?? "Unknown";
|
|
_logger.LogInformation("StartTenantTrial: tenantId={TenantId} by {User}", requestCtx.TargetTenantId, caller);
|
|
|
|
var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId);
|
|
if (config == null)
|
|
{
|
|
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
|
|
await notFound.WriteAsJsonAsync(new { error = "Tenant not found" });
|
|
return notFound;
|
|
}
|
|
|
|
var nowUtc = _time.GetUtcNow().UtcDateTime;
|
|
|
|
if (EvaluateTrialStart(config, nowUtc) is { } deny)
|
|
{
|
|
var conflict = req.CreateResponse(HttpStatusCode.Conflict);
|
|
await conflict.WriteAsJsonAsync(new { error = deny.Error, message = deny.Message });
|
|
return conflict;
|
|
}
|
|
|
|
config.TrialStartedUtc = nowUtc;
|
|
config.TrialExpiresUtc = nowUtc.AddDays(SelfServiceTrialDays);
|
|
config.TrialConsumed = true;
|
|
config.TrialGrantedBy = caller;
|
|
config.UpdatedBy = caller;
|
|
|
|
// Fail-loud: throws when the write did not persist (cache invalidated in finally).
|
|
await _configService.SaveConfigurationAsync(config, "plan", "self-service trial start");
|
|
|
|
await _maintenanceRepo.LogAuditEntryAsync(
|
|
requestCtx.TargetTenantId,
|
|
"CREATE",
|
|
"TenantTrial",
|
|
requestCtx.TargetTenantId,
|
|
caller,
|
|
new Dictionary<string, string>
|
|
{
|
|
{ "TrialStartedUtc", FormatUtc(config.TrialStartedUtc) },
|
|
{ "TrialExpiresUtc", FormatUtc(config.TrialExpiresUtc) }
|
|
});
|
|
|
|
var response = req.CreateResponse(HttpStatusCode.OK);
|
|
await response.WriteAsJsonAsync(new
|
|
{
|
|
tenantId = requestCtx.TargetTenantId,
|
|
trialStartedUtc = config.TrialStartedUtc,
|
|
trialExpiresUtc = config.TrialExpiresUtc,
|
|
effectiveEdition = FeatureEntitlementCatalog.ProTierName
|
|
});
|
|
return response;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error starting trial");
|
|
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
|
await errorResponse.WriteAsJsonAsync(new { error = "Internal server error" });
|
|
return errorResponse;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// GET /api/global/config/plan-tiers
|
|
/// Returns plan tier definitions from AdminConfiguration.PlanTierDefinitionsJson.
|
|
/// </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 { tiers });
|
|
return response;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error getting plan tier definitions");
|
|
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
|
await errorResponse.WriteAsJsonAsync(new { error = "Internal server error" });
|
|
return errorResponse;
|
|
}
|
|
}
|
|
|
|
/// <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 body = await req.ReadFromJsonAsync<SetPlanTierDefinitionsRequest>();
|
|
if (body?.Tiers == null || body.Tiers.Count == 0)
|
|
{
|
|
return await BadRequestAsync(req, "At least one tier definition is required");
|
|
}
|
|
|
|
// Validate tier names are unique
|
|
var names = body.Tiers.Select(t => t.Name.ToLowerInvariant()).ToList();
|
|
if (names.Distinct().Count() != names.Count)
|
|
{
|
|
return await BadRequestAsync(req, "Tier names must be unique");
|
|
}
|
|
|
|
// Normalize names to lowercase
|
|
foreach (var tier in body.Tiers)
|
|
tier.Name = tier.Name.ToLowerInvariant();
|
|
|
|
var config = await _adminConfigService.GetConfigurationAsync();
|
|
config.PlanTierDefinitionsJson = JsonSerializer.Serialize(body.Tiers);
|
|
await _adminConfigService.SaveConfigurationAsync(config);
|
|
|
|
var response = req.CreateResponse(HttpStatusCode.OK);
|
|
await response.WriteAsJsonAsync(new { tiers = body.Tiers });
|
|
return response;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error saving plan tier definitions");
|
|
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
|
await errorResponse.WriteAsJsonAsync(new { error = "Internal server error" });
|
|
return errorResponse;
|
|
}
|
|
}
|
|
|
|
private static string FormatUtc(DateTime? value)
|
|
=> value?.ToString("yyyy-MM-ddTHH:mm:ssZ") ?? "(none)";
|
|
|
|
private static async Task<HttpResponseData> BadRequestAsync(HttpRequestData req, string message)
|
|
{
|
|
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
|
await badRequest.WriteAsJsonAsync(new { error = message });
|
|
return badRequest;
|
|
}
|
|
|
|
private class SetPlanTierDefinitionsRequest
|
|
{
|
|
public List<PlanTierDefinition> Tiers { get; set; } = new();
|
|
}
|
|
}
|
|
}
|