Files
AutopilotMonitor/src/Backend/AutopilotMonitor.Functions/Helpers/TenantConfigProjection.cs
T
Oliver Kieselbach f795fbcb4e feat(auth): dual app-reg parallel operation - legacy gktatooine app alongside new C4A8 app
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>
2026-07-31 18:12:25 +02:00

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();
}
}
}