mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
f795fbcb4e
True parallel operation, no forced re-consent: existing tenants keep signing in via the legacy app (1a400946), new tenants land on the C4A8 app (886ab5e2). Model "default legacy, funnel primary, learn in the background": - Web: am_auth_app flag -> NEXT_PUBLIC_ENTRA_DEFAULT_APP -> primary; get-started CTA hands over with ?authapp=primary; auth/me homedApp updates the flag so an operator tenant flip takes effect on each user's NEXT login; AADSTS90094/65001 one-shot cross-app retry, AADSTS65004 -> failed screen with previous-app link; am_login_attempt pins redirect returns to the app that started them. - Backend: TenantConfiguration.HomedAppClientId (null = legacy, GA-only flip via PUT config) + LastAuthClientId/Since observability; EntraAppRegistry resolves the acting app per tenant (no-op until the legacy config pair is set); Graph client-credentials, admin-consent URLs and the surfaced client id follow the homed app (app-scoped cache keys); JwtBearer [Authorize] pipeline now shares the middleware audience builders (was single-audience - would 401 legacy tokens after the swap); middleware trusts EntraId:LegacyClientId implicitly. - well-known association file now lists both app ids (publisher domain); deploy-web passes NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID / _DEFAULT_APP; mcp-server.bicep default client id -> C4A8 app. Backend xUnit 3730/3730, vitest 625/625 (13 new), tsc/ESLint clean, next build ok. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
82 lines
4.0 KiB
C#
82 lines
4.0 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),
|
|
("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();
|
|
}
|
|
}
|
|
}
|