Files
AutopilotMonitor/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs
T
Oliver Kieselbach ef530fb890 feat(agent/v2): DAD liveness + agent_shutting_down gap closure
DAD liveness (3 new state-change-only events, NOT periodic, max 3 per
detector lifetime): desktop_detector_started / _first_poll /
_no_candidate. Distinguishes "agent dead post-reboot" from "DAD wiring
broken" from "user never logged in" in sessions missing desktop_arrived
(the ca9e3c59 timeout pattern). Threshold via global AdminConfiguration
.DesktopDetectorNoCandidateTimeoutMinutes (default 10, 0 = disabled),
round-trip wired through TableConfigRepository, GetAgentConfigFunction
(ConfigVersion 27 -> 28), CollectorConfiguration, DefaultComponentFactory,
DesktopArrivalHost + admin UI. Also fixes a peripheral-loss bug: V2's
DesktopArrivalDetector.OnTraceEvent callback was never wired, so the
existing desktop_excluded_user and desktop_real_user_detected events
were dead code in V2. Now routed via InformationalEventPost in the host.

Shutdown gap closure (4 new agent_shutting_down emit paths): ctrl_c,
process_exit, unhandled_exception (with exception type + 500-char-
truncated message), runtime_host_exit (defensive finally fallback).
Cross-path Interlocked idempotency gate shared with EnrollmentTermination
Handler via new tryClaimShutdownEvent ctor param prevents double-emit
when Ctrl+C races a real Terminated transition.

Event-type unification (V2 only): CreateAuthThresholdHandler now emits
agent_shutting_down with data.reason=auth_failure (was agent_shutdown).
EnrollmentTerminationHandler reason vocabulary unified to lowercased
tags (decision_terminal, max_lifetime). V1 keeps legacy agent_shutdown
(phasing out). Web/MCP consumers (eventHelpers, whiteGloveSplit test
fixtures, gather-rules eventTypes catalog, MCP semantic-event list,
docs) treat both as synonyms.

Tests: 3411 green (1538 backend + 1065 V2 core + 530 V1 core + 294
DecisionCore + 347 vitest). TypeScript noEmit clean. 5 new DAD-liveness
tests, 5 new gap-path tests, 2 new idempotency tests, 3 reason-vocab
updates.

Not in this PR: cross-process death detection for the actual ca9e3c59
silent-post-reboot-death pattern (Logon-Trigger / external watchdog) -
separate follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 09:39:07 +02:00

483 lines
22 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Text.Json;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Global platform configuration managed by Global Admins
/// Stored in Azure Table Storage with single instance
/// PartitionKey = "GlobalConfig"
/// RowKey = "config"
/// </summary>
public class AdminConfiguration
{
/// <summary>
/// Partition key (always "GlobalConfig")
/// </summary>
public string PartitionKey { get; set; } = "GlobalConfig";
/// <summary>
/// Row key (always "config")
/// </summary>
public string RowKey { get; set; } = "config";
/// <summary>
/// When the configuration was last updated
/// </summary>
public DateTime LastUpdated { get; set; }
/// <summary>
/// Updated by (Global Admin user email)
/// </summary>
public string UpdatedBy { get; set; } = default!;
// ===== RATE LIMITING SETTINGS =====
/// <summary>
/// Global default rate limit: Maximum requests per minute per device
/// This applies to all tenants unless they have a custom override
/// Default: 100
/// </summary>
public int GlobalRateLimitRequestsPerMinute { get; set; } = 100;
/// <summary>
/// Per-user rate limit for standard users (Tenant Admins, Operators, Viewers).
/// Requests per minute keyed by UPN. Default: 120.
/// </summary>
public int UserRateLimitRequestsPerMinute { get; set; } = 120;
/// <summary>
/// Per-user rate limit for Global Admins.
/// Higher budget but not exempt. Default: 600.
/// </summary>
public int GlobalAdminRateLimitRequestsPerMinute { get; set; } = 600;
/// <summary>
/// JSON-serialized plan tier definitions mapping tier name to rate limits and features.
/// Example: {"free":{"apiRateLimit":60},"pro":{"apiRateLimit":300},"enterprise":{"apiRateLimit":1000}}
/// </summary>
public string? PlanTierDefinitionsJson { get; set; }
/// <summary>
/// Container SAS URL used by maintenance to publish platform stats JSON files.
/// Expected format: https://{account}.blob.core.windows.net/{container}?sv=...&sig=...
/// </summary>
public string PlatformStatsBlobSasUrl { get; set; } = string.Empty;
/// <summary>
/// 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.
/// </summary>
public int CollectorIdleTimeoutMinutes { get; set; } = 15;
/// <summary>
/// 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 <c>desktop_detector_no_candidate</c> 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 <c>desktop_arrived</c> is missing.
/// 0 = disabled (event never fires). Default: 10 minutes.
/// </summary>
public int DesktopDetectorNoCandidateTimeoutMinutes { get; set; } = 10;
/// <summary>
/// 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).
/// </summary>
public int ExcessiveEventCountThreshold { get; set; } = 2000;
// ===== MAINTENANCE AUTO-BLOCK SETTINGS =====
/// <summary>
/// Max active data window in hours for maintenance auto-block detection.
/// Sessions with LastEventAt within the last MaxSessionWindowHours AND StartedAt older
/// than MaxSessionWindowHours will have their device blocked by the nightly maintenance function.
/// 0 = disabled. Default: 24.
/// </summary>
public int MaxSessionWindowHours { get; set; } = 24;
/// <summary>
/// Duration in hours for maintenance-triggered device blocks (excessive data senders).
/// Default: 12.
/// </summary>
public int MaintenanceBlockDurationHours { get; set; } = 12;
/// <summary>
/// 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.
/// </summary>
public int OpsEventRetentionDays { get; set; } = 90;
/// <summary>
/// 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.
/// </summary>
public int SlaNotificationCooldownHours { get; set; } = 24;
// ===== FEEDBACK SETTINGS =====
/// <summary>
/// Global kill-switch for the in-app feedback prompt.
/// When false, no user sees the feedback bubble regardless of other settings.
/// Default: true.
/// </summary>
public bool FeedbackEnabled { get; set; } = true;
/// <summary>
/// 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.
/// </summary>
public int FeedbackMinTenantAgeDays { get; set; } = 14;
/// <summary>
/// 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.
/// </summary>
public int FeedbackCooldownDays { get; set; } = 60;
// ===== DIAGNOSTICS LOG PATHS =====
/// <summary>
/// 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 }
/// </summary>
public string DiagnosticsGlobalLogPathsJson { get; set; } = default!;
/// <summary>
/// Returns the deserialized list of global diagnostics log paths.
/// </summary>
public List<DiagnosticsLogPath> GetDiagnosticsGlobalLogPaths()
{
if (string.IsNullOrEmpty(DiagnosticsGlobalLogPathsJson))
return new List<DiagnosticsLogPath>();
try
{
return JsonSerializer.Deserialize<List<DiagnosticsLogPath>>(DiagnosticsGlobalLogPathsJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new List<DiagnosticsLogPath>();
}
catch
{
return new List<DiagnosticsLogPath>();
}
}
// ===== DIAGNOSTICS DOWNLOAD LIMITS =====
/// <summary>
/// Maximum allowed diagnostics download size in MB.
/// Blobs exceeding this are rejected before streaming (413).
/// 0 = no limit. Default: 500 MB.
/// </summary>
public int MaxDiagnosticsDownloadSizeMB { get; set; } = 500;
/// <summary>
/// Timeout in seconds for the entire diagnostics download+stream operation.
/// 0 = no timeout. Default: 120 seconds.
/// </summary>
public int DiagnosticsDownloadTimeoutSeconds { get; set; } = 120;
// ===== MCP ACCESS CONTROL =====
/// <summary>
/// Controls who can access the remote MCP server.
/// "Disabled" = MCP off, "WhitelistOnly" = GlobalAdmins + McpUsers table (default),
/// "AllMembers" = any authenticated user.
/// </summary>
public string McpAccessPolicy { get; set; } = nameof(Models.McpAccessPolicy.WhitelistOnly);
// ===== VULNERABILITY CORRELATION SETTINGS =====
/// <summary>
/// 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).
/// </summary>
public string NvdApiKey { get; set; } = default!;
// ===== OPS EVENT ALERTING =====
/// <summary>
/// JSON-serialized list of OpsAlertRule objects defining which event types
/// trigger notifications. Provider-agnostic — rules apply to all enabled providers.
/// </summary>
public string OpsAlertRulesJson { get; set; } = default!;
/// <summary>
/// Returns the deserialized list of ops alert rules.
/// </summary>
public List<OpsAlertRule> GetOpsAlertRules()
{
if (string.IsNullOrEmpty(OpsAlertRulesJson))
return new List<OpsAlertRule>();
try
{
return JsonSerializer.Deserialize<List<OpsAlertRule>>(OpsAlertRulesJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new List<OpsAlertRule>();
}
catch
{
return new List<OpsAlertRule>();
}
}
/// <summary>Whether the Telegram alert provider is enabled. Default: false.</summary>
public bool OpsAlertTelegramEnabled { get; set; }
/// <summary>Telegram chat ID for ops alerts (e.g. ITEngineer channel).</summary>
public string OpsAlertTelegramChatId { get; set; } = default!;
/// <summary>Whether the Teams alert provider is enabled. Default: false.</summary>
public bool OpsAlertTeamsEnabled { get; set; }
/// <summary>Teams Workflow webhook URL for ops alerts.</summary>
public string OpsAlertTeamsWebhookUrl { get; set; } = default!;
/// <summary>Whether the Slack alert provider is enabled. Default: false.</summary>
public bool OpsAlertSlackEnabled { get; set; }
/// <summary>Slack Incoming Webhook URL for ops alerts.</summary>
public string OpsAlertSlackWebhookUrl { get; set; } = default!;
// ===== PER-LINE AGENT HASH ORACLE =====
// Symmetric per-major-line schema. Each line (V1, V2, future V3...) has its
// own field set. GetAgentConfigFunction parses X-Agent-Version-Major and
// dispatches via GetAgentLine(int major). Future V3 = add new field set + 1
// switch arm in GetAgentLine. See .claude/plans/v2-cutover.md.
/// <summary>Version string of the latest published V1 agent (e.g. "1.0.706").</summary>
public string LatestAgentV1Version { get; set; } = default!;
/// <summary>SHA-256 (lowercase hex) of the latest published V1 agent ZIP.</summary>
public string LatestAgentV1Sha256 { get; set; } = default!;
/// <summary>SHA-256 (lowercase hex) of the latest published V1 agent EXE.</summary>
public string LatestAgentV1ExeSha256 { get; set; } = default!;
/// <summary>Version string of the latest published V1 bootstrap script.</summary>
public string LatestBootstrapV1ScriptVersion { get; set; } = default!;
/// <summary>Version string of the latest published V2 agent (e.g. "2.0.647").</summary>
public string LatestAgentV2Version { get; set; } = default!;
/// <summary>SHA-256 (lowercase hex) of the latest published V2 agent ZIP.</summary>
public string LatestAgentV2Sha256 { get; set; } = default!;
/// <summary>SHA-256 (lowercase hex) of the latest published V2 agent EXE.</summary>
public string LatestAgentV2ExeSha256 { get; set; } = default!;
/// <summary>Version string of the latest published V2 bootstrap script.</summary>
public string LatestBootstrapV2ScriptVersion { get; set; } = default!;
/// <summary>
/// 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 <c>runtime_hash_mismatch</c> 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.
/// </summary>
public bool AllowAgentDowngrade { get; set; } = false;
/// <summary>
/// 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.
/// </summary>
public AgentLineHashes GetAgentLine(int major)
{
switch (major)
{
case 1:
return new AgentLineHashes(
LatestAgentV1Version ?? string.Empty,
LatestAgentV1Sha256 ?? string.Empty,
LatestAgentV1ExeSha256 ?? string.Empty,
LatestBootstrapV1ScriptVersion ?? string.Empty);
case 2:
return new AgentLineHashes(
LatestAgentV2Version ?? string.Empty,
LatestAgentV2Sha256 ?? string.Empty,
LatestAgentV2ExeSha256 ?? string.Empty,
LatestBootstrapV2ScriptVersion ?? string.Empty);
default:
// Unknown major (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);
}
}
// ===== MODERN DEPLOYMENT NOISE SUPPRESSION =====
/// <summary>
/// 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]"
/// </summary>
public string ModernDeploymentHarmlessEventIdsJson { get; set; } = default!;
/// <summary>
/// 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.
/// </summary>
public List<int> GetModernDeploymentHarmlessEventIds()
{
var defaults = new List<int> { 100, 1005, 1010 };
if (string.IsNullOrWhiteSpace(ModernDeploymentHarmlessEventIdsJson))
return defaults;
try
{
var parsed = JsonSerializer.Deserialize<List<int>>(ModernDeploymentHarmlessEventIdsJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return parsed ?? defaults;
}
catch
{
return defaults;
}
}
// ===== WHITEGLOVE SEALING PATTERN IDS (Plan §M5, M4.4.4 / M4.4.5.e) =====
/// <summary>
/// JSON-serialized list of <see cref="ImeLogPattern"/> IDs whose match is re-emitted by
/// the V2 agent as a <c>WhiteGloveSealingPatternDetected</c> DecisionSignal (in addition
/// to the normal <c>ime_pattern_match</c> event). Example: <c>"[\"wg-seal-1\",\"wg-seal-2\"]"</c>.
/// <para>
/// 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).
/// </para>
/// </summary>
public string WhiteGloveSealingPatternIdsJson { get; set; } = default!;
/// <summary>
/// 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).
/// </summary>
public List<string> GetWhiteGloveSealingPatternIds()
{
if (string.IsNullOrWhiteSpace(WhiteGloveSealingPatternIdsJson))
return new List<string>();
try
{
return JsonSerializer.Deserialize<List<string>>(WhiteGloveSealingPatternIdsJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new List<string>();
}
catch
{
return new List<string>();
}
}
/// <summary>
/// Whether vulnerability correlation is globally enabled.
/// When false, agents still collect inventory but backend skips correlation.
/// Default: true
/// </summary>
public bool VulnerabilityCorrelationEnabled { get; set; } = true;
/// <summary>
/// Feature flag for V2 Decision Engine index-table dual-write (Plan §M5.d).
/// When true, <c>IngestTelemetryFunction</c> enqueues <c>telemetry-index-reconcile</c>
/// envelopes after committing each primary <c>Signals</c> / <c>DecisionTransitions</c>
/// 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.
/// </summary>
public bool EnableIndexDualWrite { get; set; } = false;
// ===== CASCADE-DELETE GLOBAL KILL-SWITCH =====
/// <summary>
/// Global emergency kill-switch for the cascade-deletion subsystem (Plan §1 P8 / §9).
/// When true:
/// <list type="bullet">
/// <item>V2 cascade producers return 503 Service Unavailable;</item>
/// <item>legacy direct-delete paths also return 503 (so the switch is truly global);</item>
/// <item>the cascade worker (PR4) returns its message to the queue on entry without
/// processing.</item>
/// </list>
/// Independent of per-tenant <see cref="TenantConfiguration.EnableCascadeDeleteV2"/>:
/// flag selects path, kill-switch halts everything. Round-tripped via the 4-file web
/// chain (memory <c>feedback_admin_config_ui_roundtrip</c>) so admin saves preserve it.
/// Default false.
/// </summary>
public bool SessionDeletionKillSwitch { get; set; } = false;
/// <summary>
/// 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.
/// </summary>
public string VulnerabilityDataLastSyncUtc { get; set; } = default!;
/// <summary>
/// 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.
/// </summary>
public string MsrcLastSyncUtc { get; set; } = default!;
/// <summary>
/// Creates default configuration
/// </summary>
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,
MaxSessionWindowHours = 24,
MaintenanceBlockDurationHours = 12
};
}
}
/// <summary>
/// Per-major-line snapshot of the agent integrity fields.
/// Returned by <see cref="AdminConfiguration.GetAgentLine(int)"/>.
/// </summary>
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;
}
}
}