Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs
T
Oliver Kieselbach 1271658b16 Terms + DPA: a new tenant whose first sign-in skipped Get started confirms them once in the portal
- Get started keeps its tick as the acceptance: the CTA hands ?signupConsent=1 to portal (kept per
  tab for 30 minutes, removed from the address bar at once), the first auth/me forwards it, and the
  onboarding write records DpaVersion together with DpaAcceptedBy (first-login UPN) and
  DpaAcceptedAt.
- Any other first sign-in (the navbar "Sign in") sets DpaAcceptancePending on the new row instead.
  auth/me carries the flag; ProtectedRoute renders a blocking dialog with the same sentence and
  links as Get started (shared TermsDpaAgreementText) instead of the page; POST
  auth/dpa-acceptance writes the acceptance for the caller's own tenant only, a no-op 204 when
  nothing is pending.
- Tenants onboarded before this carry no flag and are never asked. Activation is unchanged; the
  dialog appears after it.
- The three new fields are system-owned like OnboardedBy (patch deny-list, revert-protected) and
  projected by config/all, the GA tenant report and the MCP tenant list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 18:23:42 +02:00

99 lines
5.2 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),
// Operator MCP switch — a flag plus its reason, rendered by the GA tenant list.
("mcpDisabled", c => c.McpDisabled),
("mcpDisabledReason", c => c.McpDisabledReason),
("onboardedAt", c => c.OnboardedAt),
("onboardedBy", c => c.OnboardedBy),
// DPA version accepted at onboarding — a version label, not a secret.
("dpaVersion", c => c.DpaVersion),
// Terms + DPA acceptance state — a flag, the accepting UPN (as onboardedBy) and a timestamp.
("dpaAcceptancePending", c => c.DpaAcceptancePending),
("dpaAcceptedBy", c => c.DpaAcceptedBy),
("dpaAcceptedAt", c => c.DpaAcceptedAt),
("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();
}
}
}