using System;
using System.Collections.Generic;
using System.Text.Json;
namespace AutopilotMonitor.Shared.Models
{
///
/// Global platform configuration managed by Global Admins
/// Stored in Azure Table Storage with single instance
/// PartitionKey = "GlobalConfig"
/// RowKey = "config"
///
public class AdminConfiguration
{
///
/// Partition key (always "GlobalConfig")
///
public string PartitionKey { get; set; } = "GlobalConfig";
///
/// Row key (always "config")
///
public string RowKey { get; set; } = "config";
///
/// When the configuration was last updated
///
public DateTime LastUpdated { get; set; }
///
/// Updated by (Global Admin user email)
///
public string UpdatedBy { get; set; } = default!;
// ===== RATE LIMITING SETTINGS =====
///
/// Global default rate limit: Maximum requests per minute per device
/// This applies to all tenants unless they have a custom override
/// Default: 100
///
public int GlobalRateLimitRequestsPerMinute { get; set; } = 100;
///
/// Per-user rate limit for standard users (Tenant Admins, Operators, Viewers).
/// Requests per minute keyed by UPN. Default: 120.
///
public int UserRateLimitRequestsPerMinute { get; set; } = 120;
///
/// Per-user rate limit for Global Admins.
/// Higher budget but not exempt. Default: 600.
///
public int GlobalAdminRateLimitRequestsPerMinute { get; set; } = 600;
///
/// JSON-serialized plan tier definitions mapping tier name to rate limits and features.
/// Example: {"free":{"apiRateLimit":60},"pro":{"apiRateLimit":300},"enterprise":{"apiRateLimit":1000}}
///
public string? PlanTierDefinitionsJson { get; set; }
///
/// Container SAS URL used by maintenance to publish platform stats JSON files.
/// Expected format: https://{account}.blob.core.windows.net/{container}?sv=...&sig=...
///
public string PlatformStatsBlobSasUrl { get; set; } = string.Empty;
///
/// Idle timeout in minutes for periodic collectors (Performance, AgentSelfMetrics).
/// When no real enrollment event (app install, ESP phase change, etc.) is detected
/// within this window, collectors stop automatically to prevent session bloat.
/// They restart automatically when new enrollment activity is detected.
/// 0 = disabled (collectors run indefinitely). Default: 15 minutes.
///
public int CollectorIdleTimeoutMinutes { get; set; } = 15;
///
/// DAD liveness threshold in minutes. When the V2 DesktopArrivalDetector polls for
/// this duration without detecting either an excluded-user explorer.exe or a real
/// user desktop, it emits a single desktop_detector_no_candidate observability
/// event. State-change-only — NOT a periodic heartbeat; max 1 event per detector
/// lifetime. Used to distinguish "user never logged in" from "detector wiring dead
/// post-reboot" in sessions where desktop_arrived is missing.
/// 0 = disabled (event never fires). Default: 10 minutes.
///
public int DesktopDetectorNoCandidateTimeoutMinutes { get; set; } = 10;
///
/// Maintenance alarm threshold: sessions with more events than this value trigger an
/// ExcessiveSessionEvents ops alert (dispatched to Telegram/Teams).
/// 0 = disabled. Default: 2000 (largest real sessions observed are ~500).
///
public int ExcessiveEventCountThreshold { get; set; } = 2000;
///
/// Auto-action mode for runaway sessions whose EventCount exceeds
/// . One of "Off", "Block",
/// or "Kill". Off (default) keeps the warn-only behaviour;
/// Block stops the device's uploads for ;
/// Kill issues a remote self-destruct signal. Action runs in the 2h maintenance batch,
/// is idempotent per session via ExcessiveEventsAutoActioned, and never downgrades
/// an existing Kill to Block.
///
public string ExcessiveEventAutoActionMode { get; set; } = "Off";
///
/// Event-count threshold for auto-block/kill. Must be greater than
/// so operators see the warn first.
/// 0 disables regardless of . Default: 2500.
///
public int ExcessiveEventAutoActionThreshold { get; set; } = 2500;
///
/// Duration in hours for the device block created by the auto-action path. Default: 24.
///
public int ExcessiveEventAutoActionDurationHours { get; set; } = 24;
// MaxSessionWindowHours / MaintenanceBlockDurationHours were removed 2026-07-22 with the
// time-window auto-block. That detector blocked on session span alone, so every enrollment
// that spanned a night qualified — including 23-event sessions. The event-count settings
// above are now the only automatic block. Stored rows keep the two columns until their
// next write; nothing reads them.
///
/// Retention period in days for operational events in the OpsEvents table.
/// Events older than this are deleted by the periodic maintenance job.
/// 0 = no cleanup (not recommended). Default: 90 days.
///
public int OpsEventRetentionDays { get; set; } = 90;
///
/// Cooldown in hours between repeat SLA breach notifications for the same
/// tenant + breach type. Persisted via SlaTenantStatus so it survives Function
/// host recycles. Default: 24 hours (one notification per day). Range: 1-168.
///
public int SlaNotificationCooldownHours { get; set; } = 24;
// ===== FEEDBACK SETTINGS =====
///
/// Global kill-switch for the in-app feedback prompt.
/// When false, no user sees the feedback bubble regardless of other settings.
/// Default: true.
///
public bool FeedbackEnabled { get; set; } = true;
///
/// Minimum tenant age in days before users are prompted for feedback.
/// Prevents asking brand-new tenants who haven't had meaningful experience yet.
/// Default: 14 days.
///
public int FeedbackMinTenantAgeDays { get; set; } = 14;
///
/// Cooldown in days after a user interacts with the feedback prompt
/// before they are prompted again. 0 = never re-prompt (single wave only).
/// Default: 60 days.
///
public int FeedbackCooldownDays { get; set; } = 60;
// ===== DIAGNOSTICS LOG PATHS =====
///
/// JSON-serialized list of global diagnostics log paths/wildcards
/// to include in the diagnostics ZIP package for all tenants.
/// Each entry: { "path": "...", "description": "...", "isBuiltIn": true }
///
public string DiagnosticsGlobalLogPathsJson { get; set; } = default!;
///
/// Returns the deserialized list of global diagnostics log paths.
///
public List GetDiagnosticsGlobalLogPaths()
{
if (string.IsNullOrEmpty(DiagnosticsGlobalLogPathsJson))
return new List();
try
{
return JsonSerializer.Deserialize>(DiagnosticsGlobalLogPathsJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new List();
}
catch
{
return new List();
}
}
// ===== DIAGNOSTICS DOWNLOAD LIMITS =====
///
/// Maximum allowed diagnostics download size in MB.
/// Blobs exceeding this are rejected before streaming (413).
/// 0 = no limit. Default: 500 MB.
///
public int MaxDiagnosticsDownloadSizeMB { get; set; } = 500;
///
/// Timeout in seconds for the entire diagnostics download+stream operation.
/// 0 = no timeout. Default: 120 seconds.
///
public int DiagnosticsDownloadTimeoutSeconds { get; set; } = 120;
// ===== MCP ACCESS CONTROL =====
///
/// Controls who can access the remote MCP server.
/// "Disabled" = MCP off, "WhitelistOnly" = GlobalAdmins + McpUsers table (default),
/// "AllMembers" = any authenticated user.
///
public string McpAccessPolicy { get; set; } = nameof(Models.McpAccessPolicy.WhitelistOnly);
// ===== VULNERABILITY CORRELATION SETTINGS =====
///
/// NVD API key for higher rate limits (50 req/30s vs 5 req/30s without key).
/// Free registration at https://nvd.nist.gov/developers/request-an-api-key
/// null = operate without API key (slower, still functional).
///
public string NvdApiKey { get; set; } = default!;
// ===== OPS EVENT ALERTING =====
///
/// JSON-serialized list of OpsAlertRule objects defining which event types
/// trigger notifications. Provider-agnostic — rules apply to all enabled providers.
///
public string OpsAlertRulesJson { get; set; } = default!;
///
/// Returns the deserialized list of ops alert rules.
///
public List GetOpsAlertRules()
{
if (string.IsNullOrEmpty(OpsAlertRulesJson))
return new List();
try
{
return JsonSerializer.Deserialize>(OpsAlertRulesJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new List();
}
catch
{
return new List();
}
}
/// Whether the Telegram alert provider is enabled. Default: false.
public bool OpsAlertTelegramEnabled { get; set; }
/// Telegram chat ID for ops alerts (e.g. ITEngineer channel).
public string OpsAlertTelegramChatId { get; set; } = default!;
/// Whether the Teams alert provider is enabled. Default: false.
public bool OpsAlertTeamsEnabled { get; set; }
/// Teams Workflow webhook URL for ops alerts.
public string OpsAlertTeamsWebhookUrl { get; set; } = default!;
/// Whether the Slack alert provider is enabled. Default: false.
public bool OpsAlertSlackEnabled { get; set; }
/// Slack Incoming Webhook URL for ops alerts.
public string OpsAlertSlackWebhookUrl { get; set; } = default!;
// ===== PER-LINE AGENT HASH ORACLE =====
// Per-major-line schema. GetAgentConfigFunction parses X-Agent-Version-Major and
// dispatches via GetAgentLine(int major). V2 is the only wired line (the V1 line
// was retired with the legacy agent); future V3 = add new field set + 1 switch
// arm in GetAgentLine. See .claude/plans/v2-cutover.md.
/// Version string of the latest published V2 agent (e.g. "2.0.647").
public string LatestAgentV2Version { get; set; } = default!;
/// SHA-256 (lowercase hex) of the latest published V2 agent ZIP.
public string LatestAgentV2Sha256 { get; set; } = default!;
/// SHA-256 (lowercase hex) of the latest published V2 agent EXE.
public string LatestAgentV2ExeSha256 { get; set; } = default!;
/// Version string of the latest published V2 bootstrap script.
public string LatestBootstrapV2ScriptVersion { get; set; } = default!;
///
/// When true, the agent's self-updater is allowed to install a version strictly lower
/// than the one it is currently running. Default: false (forward-only updates; prevents
/// dev builds from being silently downgraded via the runtime_hash_mismatch force path).
/// Set to true only for controlled rollback scenarios — flip back to false immediately afterwards.
/// Single global flag; applies to whichever line the calling agent runs on.
///
public bool AllowAgentDowngrade { get; set; } = false;
///
/// Per-line view of the agent integrity fields. Backend dispatches by X-Agent-Version-Major.
/// Returns empty strings (not null) if the requested line has no published build yet.
///
public AgentLineHashes GetAgentLine(int major)
{
switch (major)
{
case 2:
return new AgentLineHashes(
LatestAgentV2Version ?? string.Empty,
LatestAgentV2Sha256 ?? string.Empty,
LatestAgentV2ExeSha256 ?? string.Empty,
LatestBootstrapV2ScriptVersion ?? string.Empty);
default:
// Unknown major (retired V1 line, very old agent, or a future line not
// yet wired). Return empty so callers degrade gracefully (skip integrity check).
return new AgentLineHashes(string.Empty, string.Empty, string.Empty, string.Empty);
}
}
// ===== AGENT ENDPOINT MIGRATION (config-channel re-home) =====
// The agent's API base URL is compiled into the binary (no custom domain possible in
// front of the API — mTLS breaks behind a proxy, Flex custom-domain TLS is broken).
// These settings let the OLD backend re-home agents to a NEW base URL via the
// agent/config response during a backend or per-tenant region move. Values are
// validated against AgentEndpointMigrationRules before being served; invalid values
// are logged and ignored (fail-safe: no migration).
///
/// Global migration target served to ALL agents as
/// AgentConfigResponse.MigrateToApiBaseUrl (e.g.
/// "https://autopilotmonitor-api-us.azurewebsites.net"). Empty = no global migration.
/// Set this on the backend being ABANDONED; clear it once the migration window closes.
/// Per-tenant overrides in win over this.
///
public string AgentMigrateApiBaseUrl { get; set; } = string.Empty;
///
/// JSON object mapping tenantId → migration target URL for per-tenant moves (e.g. one
/// tenant relocating EU→US). An entry with an EMPTY string value pins that tenant to
/// the current backend even while is set (staged
/// rollout). Example:
/// {"11111111-...": "https://autopilotmonitor-api-us.azurewebsites.net", "22222222-...": ""}
///
public string AgentMigrateTenantOverridesJson { get; set; } = string.Empty;
///
/// Returns the deserialized per-tenant migration overrides (case-insensitive tenant
/// keys). Falls back to an empty map for null/empty/invalid JSON so a malformed value
/// degrades to "global setting applies" instead of failing config serving.
///
public Dictionary GetAgentMigrateTenantOverrides()
{
var empty = new Dictionary(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(AgentMigrateTenantOverridesJson))
return empty;
try
{
var parsed = JsonSerializer.Deserialize>(
AgentMigrateTenantOverridesJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return parsed == null
? empty
: new Dictionary(parsed, StringComparer.OrdinalIgnoreCase);
}
catch
{
return empty;
}
}
// ===== MODERN DEPLOYMENT NOISE SUPPRESSION =====
///
/// JSON-serialized list of Windows ModernDeployment EventIDs that are considered
/// harmless. Matching events (Level 2 Error or Level 3 Warning) are downgraded
/// to Debug severity by the agent — they stay visible for troubleshooting but
/// do not surface as Error/Warning in the session timeline and are ignored by
/// the stall-probe anomaly scan. Level 1 Critical is never downgraded.
/// Example: "[100, 1005, 1010]"
///
public string ModernDeploymentHarmlessEventIdsJson { get; set; } = default!;
///
/// Returns the deserialized list of harmless ModernDeployment EventIDs.
/// Falls back to the built-in defaults [100, 1005, 1010] when the JSON is
/// null/empty/invalid so new agents always receive a sensible baseline.
///
public List GetModernDeploymentHarmlessEventIds()
{
var defaults = new List { 100, 1005, 1010 };
if (string.IsNullOrWhiteSpace(ModernDeploymentHarmlessEventIdsJson))
return defaults;
try
{
var parsed = JsonSerializer.Deserialize>(ModernDeploymentHarmlessEventIdsJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return parsed ?? defaults;
}
catch
{
return defaults;
}
}
// ===== WHITEGLOVE SEALING PATTERN IDS (Plan §M5, M4.4.4 / M4.4.5.e) =====
///
/// JSON-serialized list of IDs whose match is re-emitted by
/// the V2 agent as a WhiteGloveSealingPatternDetected DecisionSignal (in addition
/// to the normal ime_pattern_match event). Example: "[\"wg-seal-1\",\"wg-seal-2\"]".
///
/// Default null/empty = feature off (M3-compatible, no regression risk). Only IDs in
/// this list count as sealing signals; other IME pattern matches follow the regular
/// event path. Global-only — no per-tenant override (plan §M5 M4.4.5.e decision).
///
///
public string WhiteGloveSealingPatternIdsJson { get; set; } = default!;
///
/// Returns the deserialized list of WhiteGlove sealing pattern IDs. Falls back to an
/// empty list for null/empty/invalid JSON so the feature stays off by default
/// (M3-compatible).
///
public List GetWhiteGloveSealingPatternIds()
{
if (string.IsNullOrWhiteSpace(WhiteGloveSealingPatternIdsJson))
return new List();
try
{
return JsonSerializer.Deserialize>(WhiteGloveSealingPatternIdsJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new List();
}
catch
{
return new List();
}
}
///
/// Whether vulnerability correlation is globally enabled.
/// When false, agents still collect inventory but backend skips correlation.
/// Default: true
///
public bool VulnerabilityCorrelationEnabled { get; set; } = true;
///
/// Feature flag for V2 Decision Engine index-table dual-write (Plan §M5.d).
/// When true, IngestTelemetryFunction enqueues telemetry-index-reconcile
/// envelopes after committing each primary Signals / DecisionTransitions
/// row; a queue-triggered handler (M5.d.3) then writes the 0–3 applicable index rows.
/// Default: false — enables controlled rollout. The 2h reconcile timer (M5.d.4) is
/// the safety-net for queue failures even once the flag is on.
///
public bool EnableIndexDualWrite { get; set; } = false;
///
/// When true, new tenant signups are activated automatically ~1 minute after first
/// sign-in (tenant-auto-approve queue worker). When false, every signup waits for a
/// manual Global Admin approval in Tenant Management. Default false — the operator
/// opts into auto-activation and can flip it off at any time (e.g. on abuse) to
/// return to manual vetting; messages already in the queue are then dropped, not
/// parked. Round-tripped via the 4-file web chain
/// (memory feedback_admin_config_ui_roundtrip).
///
public bool AutoApproveNewTenants { get; set; } = false;
///
/// When true, the first fleet-wide sighting of a new IME version enqueues an
/// archive job (ime-msi-archive queue) that downloads the installer from the
/// CSP-reported URL into the ime-archive blob container. Roughly one download
/// per Microsoft IME release (~monthly). Default true — flip off to pause archiving
/// (queued messages then wait, they are not dropped). Round-tripped via the 4-file
/// web chain (memory feedback_admin_config_ui_roundtrip).
///
public bool ImeMsiArchivingEnabled { get; set; } = true;
///
/// Size cap for the IME installer download in MB (Content-Length preflight AND
/// streamed-byte guard). The MSI is ~12 MB today; the generous cap exists only so a
/// tampered/wrong URL cannot stream gigabytes into the archive while still never
/// missing a legitimately grown installer. 0 = no limit. Default: 250 MB.
///
public int MaxImeMsiDownloadSizeMB { get; set; } = 250;
///
/// When true, the dual app-registration self-service homing flip is active: the Graph
/// consent flow funnels legacy-homed tenants to the primary (new) app registration and
/// auto-flips TenantConfiguration.HomedAppClientId once admin consent for the
/// primary app is verified; tenant admins may also flip manually via
/// POST config/{tenantId}/app-homing. When false (kill switch) all of the above
/// stops immediately — consent URLs mint for the homed app again and only Global Admins
/// can flip. Default false — the operator enables it after the dual-app config swap.
/// Round-tripped via the 4-file web chain (memory feedback_admin_config_ui_roundtrip).
///
public bool SelfServiceAppHomingEnabled { get; set; } = false;
// ===== CASCADE-DELETE GLOBAL KILL-SWITCH =====
///
/// Global emergency kill-switch for the cascade-deletion subsystem (Plan §1 P8 / §9).
/// When true:
///
/// - cascade producers return 503 Service Unavailable;
/// - the cascade worker returns its message to the queue on entry without
/// processing.
///
/// Round-tripped via the 4-file web chain (memory feedback_admin_config_ui_roundtrip)
/// so admin saves preserve it. Default false.
///
public bool SessionDeletionKillSwitch { get; set; } = false;
///
/// Last successful CISA KEV catalog sync timestamp (UTC ISO 8601).
/// Updated by VulnerabilityDataSyncFunction (daily timer) and TriggerVulnerabilityDataSyncFunction
/// (manual /api/vulnerability/sync). Pre-existing field — semantically means "last KEV sync"
/// since KEV is the only live data refresh that ran via the manual endpoint historically.
///
public string VulnerabilityDataLastSyncUtc { get; set; } = default!;
///
/// Last successful MSRC CVRF index refresh timestamp (UTC ISO 8601).
/// Updated by VulnerabilityDataSyncFunction (daily timer) and TriggerMsrcSyncFunction
/// (manual /api/vulnerability/sync-msrc). Empty/null means MSRC has never refreshed
/// successfully since this field was introduced.
///
public string MsrcLastSyncUtc { get; set; } = default!;
///
/// Returns a shallow copy with all secret-bearing string fields replaced by
/// (empty values are left empty). Used when
/// serving the global config to a read-only GlobalReader so SAS URLs / API keys / webhook URLs
/// (which could enable external mutations) never leave the backend. The original (cached) instance
/// is never mutated.
///
/// SECURITY: this is a deny-list — every new secret string field MUST be added here.
/// AdminConfigurationRedactionTests guards against drift.
///
public AdminConfiguration RedactedCopyForReader()
{
var copy = (AdminConfiguration)MemberwiseClone();
copy.PlatformStatsBlobSasUrl = Redact(copy.PlatformStatsBlobSasUrl);
copy.NvdApiKey = Redact(copy.NvdApiKey);
copy.OpsAlertTeamsWebhookUrl = Redact(copy.OpsAlertTeamsWebhookUrl);
copy.OpsAlertSlackWebhookUrl = Redact(copy.OpsAlertSlackWebhookUrl);
return copy;
static string Redact(string? value)
=> string.IsNullOrEmpty(value) ? (value ?? string.Empty) : Constants.RedactedSecretPlaceholder;
}
///
/// Creates default configuration
///
public static AdminConfiguration CreateDefault()
{
return new AdminConfiguration
{
PartitionKey = "GlobalConfig",
RowKey = "config",
LastUpdated = DateTime.UtcNow,
UpdatedBy = "System",
GlobalRateLimitRequestsPerMinute = 100,
UserRateLimitRequestsPerMinute = 120,
GlobalAdminRateLimitRequestsPerMinute = 600,
PlatformStatsBlobSasUrl = string.Empty,
CollectorIdleTimeoutMinutes = 15,
DesktopDetectorNoCandidateTimeoutMinutes = 10
};
}
}
///
/// Per-major-line snapshot of the agent integrity fields.
/// Returned by .
///
public sealed class AgentLineHashes
{
public string Version { get; }
public string ZipSha256 { get; }
public string ExeSha256 { get; }
public string BootstrapScriptVersion { get; }
public AgentLineHashes(string version, string zipSha256, string exeSha256, string bootstrapScriptVersion)
{
Version = version ?? string.Empty;
ZipSha256 = zipSha256 ?? string.Empty;
ExeSha256 = exeSha256 ?? string.Empty;
BootstrapScriptVersion = bootstrapScriptVersion ?? string.Empty;
}
}
}