mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
1271658b16
- 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>
99 lines
5.2 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|