Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs
T
Oliver Kieselbach 98cfd76232 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>
2026-09-04 18:31:48 +02:00

90 lines
4.6 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Helpers
{
/// <summary>
/// Server-side keep-list projection for the paginated <c>GET /api/config/all</c> surface.
/// Only non-sensitive identity / lifecycle / plan fields are ever emitted — secrets
/// (webhook / SAS / branding URLs, allow-lists) can never leave the backend even if a
/// caller requests them via <c>fields=</c>, because the requested set is intersected with
/// this keep-list (unknown keys are silently dropped, never widened). <c>tenantId</c> is
/// always included: the endpoint's whole purpose is tenant-ID discovery.
/// </summary>
public static class TenantConfigProjection
{
// Ordered keep-list: JSON key (camelCase — the wire contract the web + MCP already
// consume) -> value selector. This is the ONLY set of fields that can reach the wire.
private static readonly (string Key, Func<TenantConfiguration, object?> Value)[] SafeFields =
{
("tenantId", c => c.TenantId),
("domainName", c => c.DomainName),
("planTier", c => c.PlanTier),
("trialExpiresUtc", c => c.TrialExpiresUtc),
("trialConsumed", c => c.TrialConsumed),
// Delegated (MSP) slot override — a count, not a secret; the GA tenant list renders it.
("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),
("onboardedBy", c => c.OnboardedBy),
("lastUpdated", c => c.LastUpdated),
("dataRetentionDays", c => c.DataRetentionDays),
// App-reg migration observability: which app the tenant is homed on (null = legacy)
// and which app its users' logins actually arrive through. Public client ids, not secrets.
("homedAppClientId", c => c.HomedAppClientId),
("lastAuthClientId", c => c.LastAuthClientId),
("lastAuthClientIdSince", c => c.LastAuthClientIdSince),
};
/// <summary>
/// Parse a comma-separated field list into a case-insensitive set, or null when no
/// usable subset was requested (→ emit all safe fields). Keys outside the keep-list
/// are kept in the set but simply never match a real field in <see cref="Project"/>.
/// </summary>
public static HashSet<string>? ParseFields(string? fields)
{
if (string.IsNullOrWhiteSpace(fields)) return null;
var requested = fields
.Split(',')
.Select(f => f.Trim())
.Where(f => f.Length > 0)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
return requested.Count > 0 ? requested : null;
}
/// <summary>
/// Project one tenant to a { jsonKey -> value } map containing only the requested
/// safe fields (or all safe fields when <paramref name="requested"/> is null).
/// <c>tenantId</c> is always present regardless of the request.
/// </summary>
public static Dictionary<string, object?> Project(TenantConfiguration config, HashSet<string>? requested)
{
if (config == null) throw new ArgumentNullException(nameof(config));
var result = new Dictionary<string, object?>(SafeFields.Length);
foreach (var (key, value) in SafeFields)
{
if (requested == null || key == "tenantId" || requested.Contains(key))
result[key] = value(config);
}
return result;
}
/// <summary>Convenience: parse + project a sequence in one call.</summary>
public static List<Dictionary<string, object?>> ProjectAll(
IEnumerable<TenantConfiguration> configs, string? fields)
{
var requested = ParseFields(fields);
return configs.Select(c => Project(c, requested)).ToList();
}
}
}