mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
54044c5d9c
Classic UD fleets held at the Device-ESP timeout wall by one slow blocking app showed ~100% Failed (tenant c9787ba2: 948/1000 esp_terminal_failure) although the profile allows "Continue anyway" and users demonstrably reach the desktop — the classic advisory defang requires AccountSetupEnteredUtc and never applied there. New operator-set tenant opt-in EnableEspContinueAnywayObservation (ConfigVersion 36 -> 37, default off): - DecisionCore: a Device-phase EspTerminalFailure with ContinueAnyway allowed + opt-in observed (EspConfigDetected payload fact — kernel stays config-free) + Classic/Unknown non-pre-prov scenario defangs into the advisory rail with a 60-min AdvisoryCompletion window. Real end via the DAD-validated real-user desktop + Hello gate (IME user-session gate is structurally unavailable without the AccountSetup anchor): eager completion on DesktopArrived, category recovery via the 4910a5a5 hook, expiry un-defangs to the original esp_terminal_failure — every observed session still gets a terminal verdict. - Honest outcome: enrollment_complete under an unresolved advisory carries espSoftFailure/completionSource (also on the classic post-AccountSetup advisory path); backend stamps EspSoftFailure/CompletionSource on the Sessions row (+ index mirror, terminal-tuple preservation); web renders an amber "with issues" badge plus a session-detail banner. No new terminal status — Succeeded + marker. - Agent: remote-config field merged into AgentConfiguration; stamped into both esp_config_detected emitters (orchestrator bootstrap via live Func<bool> seam, DeviceInfoCollector via host/factory pass-through). - Admin UI: GA-only toggle in the tenant management modal (next to Bootstrap Token) + stored/runtime rows in the tenant config report; GA-gated on both write paths (PUT silently reverts for tenant admins, field patch rejects with 400). Tests: 13 new ContinueAnywayObservationTests; DecisionCore 513, Agent V2 2162, Functions 3948, SummaryDialog 44, vitest 632, tsc clean. Docs: docs/agent/continue-anyway-observation.md (OKF) + index/log. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
279 lines
16 KiB
C#
279 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using AutopilotMonitor.Shared.Models;
|
|
using AutopilotMonitor.Shared.Models.Vulnerability;
|
|
using AutopilotMonitor.Shared.Pagination;
|
|
|
|
namespace AutopilotMonitor.Shared.DataAccess
|
|
{
|
|
/// <summary>
|
|
/// Repository for session and event data.
|
|
/// Covers: Sessions, SessionsIndex, Events tables.
|
|
/// </summary>
|
|
public interface ISessionRepository
|
|
{
|
|
// --- Session CRUD ---
|
|
Task<bool> StoreSessionAsync(SessionRegistration registration);
|
|
Task<SessionSummary?> GetSessionAsync(string tenantId, string sessionId);
|
|
Task<string?> FindSessionTenantIdAsync(string sessionId);
|
|
/// <summary>
|
|
/// Returns all sessions for <paramref name="tenantId"/> newest-first.
|
|
/// Optional <paramref name="days"/> scopes to the last N days. No row cap —
|
|
/// drains the underlying paged scan; for large tenants prefer
|
|
/// <see cref="GetSessionsPageAsync"/>.
|
|
/// </summary>
|
|
Task<List<SessionSummary>> GetSessionsAsync(string tenantId, int? days = null);
|
|
|
|
/// <summary>
|
|
/// Cross-tenant variant of <see cref="GetSessionsAsync"/> (Global Admin).
|
|
/// <paramref name="tenantIdFilter"/> optionally restricts to a single tenant.
|
|
/// <paramref name="allowedTenantIds"/> (when non-null) bounds the cross-tenant fan-out to that
|
|
/// subset — used by delegated ("MSP") callers so the aggregate covers only their managed tenants.
|
|
/// </summary>
|
|
Task<List<SessionSummary>> GetAllSessionsAsync(
|
|
string? tenantIdFilter = null, int? days = null, IReadOnlyCollection<string>? allowedTenantIds = null);
|
|
|
|
/// <summary>
|
|
/// Reads a single page of sessions for <paramref name="tenantId"/>, newest-first
|
|
/// (SessionsIndex inverted-tick RowKey order). The opaque <c>NextRawToken</c>
|
|
/// on the returned <see cref="RawPage{T}"/> is the Azure-Tables continuation
|
|
/// the function layer wraps with the wire envelope. Optional <paramref name="days"/>
|
|
/// scopes to the last N days via RowKey range; <c>null</c> means all time.
|
|
/// </summary>
|
|
Task<RawPage<SessionSummary>> GetSessionsPageAsync(
|
|
string tenantId, int? days, int pageSize, string? continuation);
|
|
|
|
/// <summary>
|
|
/// Cross-tenant variant of <see cref="GetSessionsPageAsync"/> (Global Admin).
|
|
/// <paramref name="tenantIdFilter"/> optionally restricts to a single tenant.
|
|
/// <paramref name="allowedTenantIds"/> (when non-null) bounds the cross-tenant fan-out to that
|
|
/// subset — used by delegated ("MSP") callers so the aggregate covers only their managed tenants.
|
|
/// </summary>
|
|
Task<RawPage<SessionSummary>> GetAllSessionsPageAsync(
|
|
string? tenantIdFilter, int? days, int pageSize, string? continuation,
|
|
IReadOnlyCollection<string>? allowedTenantIds = null);
|
|
|
|
/// <summary>
|
|
/// Server-side aggregation for the dashboard stats cards (per-tenant scope).
|
|
/// Drains the SessionsIndex for the last <paramref name="days"/> days and computes
|
|
/// counts in a single pass — the client never derives these from a paginated list.
|
|
/// </summary>
|
|
Task<SessionStats> GetSessionStatsAsync(string tenantId, int days);
|
|
|
|
/// <summary>
|
|
/// Cross-tenant variant of <see cref="GetSessionStatsAsync"/> (Global Admin).
|
|
/// <paramref name="tenantIdFilter"/> optionally restricts to a single tenant
|
|
/// (routes through the per-tenant index for cheaper scan).
|
|
/// <paramref name="allowedTenantIds"/> (when non-null) bounds the aggregate to that subset
|
|
/// — used by delegated ("MSP") callers so the cards cover only their managed tenants.
|
|
/// </summary>
|
|
Task<SessionStats> GetAllSessionStatsAsync(
|
|
string? tenantIdFilter, int days, IReadOnlyCollection<string>? allowedTenantIds = null);
|
|
|
|
// --- Session Updates ---
|
|
Task<bool> UpdateSessionStatusAsync(
|
|
string tenantId, string sessionId, SessionStatus status,
|
|
EnrollmentPhase? currentPhase = null, string? failureReason = null,
|
|
DateTime? completedAt = null, DateTime? earliestEventTimestamp = null,
|
|
DateTime? latestEventTimestamp = null, bool? isPreProvisioned = null,
|
|
bool? isUserDriven = null, DateTime? resumedAt = null,
|
|
DateTime? stalledAt = null, bool clearStalledAt = false, bool clearFailureReason = false,
|
|
string? failureSource = null, string? adminMarkedAction = null,
|
|
string? failureSnapshotJson = null, bool allowTerminalReclassification = false,
|
|
bool espSoftFailure = false, string? completionSource = null);
|
|
/// <summary>
|
|
/// Increments per-session counters via read-modify-write. Returns the post-merge session
|
|
/// snapshot (the RMW read with the applied increments) so hot-path callers can skip a
|
|
/// follow-up <see cref="GetSessionAsync"/>; null when the row is missing or the update
|
|
/// could not be applied (caller falls back to its own read).
|
|
/// </summary>
|
|
Task<SessionSummary?> IncrementSessionEventCountAsync(
|
|
string tenantId, string sessionId, int increment,
|
|
DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null,
|
|
EnrollmentPhase? currentPhase = null,
|
|
int platformScriptIncrement = 0, int remediationScriptIncrement = 0,
|
|
int rebootIncrement = 0);
|
|
/// <summary>
|
|
/// Reconciles the stored EventCount and RebootCount with the authoritative row counts
|
|
/// from the Events table (rows dedupe on deterministic RowKeys; the read-modify-write
|
|
/// increments do not). Call as the last counter write on terminal ingest batches.
|
|
/// </summary>
|
|
Task ReconcileSessionCountersAsync(string tenantId, string sessionId);
|
|
Task UpdateSessionDiagnosticsBlobAsync(
|
|
string tenantId, string sessionId, string blobName, string? destination = null);
|
|
Task SetSessionPreProvisionedAsync(string tenantId, string sessionId, bool isPreProvisioned,
|
|
SessionStatus? status = null, bool? isUserDriven = null);
|
|
Task UpdateSessionGeoAsync(string tenantId, string sessionId,
|
|
string? country, string? region, string? city, string? loc);
|
|
Task UpdateSessionImeAgentVersionAsync(string tenantId, string sessionId, string version);
|
|
/// <summary>
|
|
/// Persists the session-wide average agent→backend HTTP latency derived from the
|
|
/// cumulative counters of the latest <c>agent_metrics_snapshot</c>. Last-write-wins
|
|
/// (cumulative counters make replays idempotent); fail-soft like the other
|
|
/// supplementary-field updaters.
|
|
/// </summary>
|
|
Task UpdateSessionNetworkLatencyAsync(string tenantId, string sessionId,
|
|
double avgLatencyMs, int requestCount);
|
|
/// <summary>
|
|
/// Persists the active network connection type ("WiFi" or "Ethernet") from the
|
|
/// latest <c>network_interface_info</c> event. Last-write-wins; fail-soft like
|
|
/// the other supplementary-field updaters.
|
|
/// </summary>
|
|
Task UpdateSessionConnectionTypeAsync(string tenantId, string sessionId,
|
|
string connectionType);
|
|
|
|
// --- Server→Agent Actions ---
|
|
/// <summary>
|
|
/// Queues a <see cref="ServerAction"/> for delivery on the next ingest call.
|
|
/// Dedup-by-Type: queueing the same action type twice replaces the existing entry
|
|
/// while preserving the earliest QueuedAt for TTL purposes.
|
|
/// </summary>
|
|
Task<bool> QueueServerActionAsync(string tenantId, string sessionId, ServerAction action);
|
|
|
|
/// <summary>
|
|
/// Reads the pending-action queue and atomically clears it for delivery on the ingest response.
|
|
/// At-least-once delivery semantics: concurrent callers may both observe the same actions.
|
|
/// </summary>
|
|
Task<List<ServerAction>> FetchAndClearPendingActionsAsync(string tenantId, string sessionId);
|
|
|
|
// --- Excessive-Event Detection ---
|
|
/// <summary>
|
|
/// Returns sessions in <paramref name="tenantId"/> whose EventCount exceeds <paramref name="threshold"/>.
|
|
/// Used by maintenance to surface runaway sessions (likely agent loop bugs).
|
|
/// </summary>
|
|
Task<List<SessionSummary>> GetSessionsWithEventCountAboveAsync(string tenantId, int threshold);
|
|
|
|
/// <summary>
|
|
/// Marks the session as already-alerted so maintenance only emits one ops event per runaway session.
|
|
/// </summary>
|
|
Task MarkExcessiveEventsAlertedAsync(string tenantId, string sessionId);
|
|
|
|
/// <summary>
|
|
/// Marks the session as already-auto-actioned so maintenance only blocks/kills the
|
|
/// device once per runaway session. Independent of <see cref="MarkExcessiveEventsAlertedAsync"/>.
|
|
/// </summary>
|
|
Task MarkExcessiveEventsAutoActionedAsync(string tenantId, string sessionId);
|
|
|
|
/// <summary>
|
|
/// Open (non-terminal: Pending / InProgress / Stalled / AwaitingUser) sessions of the same
|
|
/// physical device, identified by SerialNumber within the tenant partition. Narrow
|
|
/// projection — used by the registration supersede pass to resolve orphaned predecessor
|
|
/// sessions when a device re-registers under a new session id
|
|
/// (misclassification audit 2026-07-16: WhiteGlove Part 2 under a fresh id left the
|
|
/// Part-1 row Pending forever).
|
|
/// </summary>
|
|
Task<List<SessionSummary>> GetOpenSessionsForDeviceAsync(string tenantId, string serialNumber);
|
|
|
|
// --- IME Version History ---
|
|
Task<bool> RecordImeVersionAsync(string version, string tenantId, string sessionId);
|
|
Task<List<ImeVersionHistoryEntry>> GetImeVersionHistoryAsync();
|
|
|
|
// --- Events ---
|
|
Task<bool> StoreEventAsync(EnrollmentEvent evt);
|
|
Task<List<EnrollmentEvent>> StoreEventsBatchAsync(List<EnrollmentEvent> events);
|
|
Task<List<EnrollmentEvent>> GetSessionEventsAsync(string tenantId, string sessionId, int maxResults = 1000);
|
|
/// <summary>
|
|
/// Strict variant of <see cref="GetSessionEventsAsync"/>: storage failures PROPAGATE
|
|
/// instead of degrading to an empty list. Queue-worker paths (rule analysis,
|
|
/// vulnerability correlation) require this so a transient read failure is retried via
|
|
/// visibility timeout instead of silently producing an empty analysis.
|
|
/// </summary>
|
|
Task<List<EnrollmentEvent>> GetSessionEventsStrictAsync(string tenantId, string sessionId, int maxResults = 1000);
|
|
Task<List<EnrollmentEvent>> GetSessionEventsByTypeAsync(string tenantId, string sessionId, string eventType, int maxResults = 200);
|
|
|
|
/// <summary>
|
|
/// Reads a single page of session events. The returned <see cref="RawPage{T}"/>
|
|
/// carries the underlying store's opaque continuation token for the next page,
|
|
/// or <c>null</c> when the page just read was the last. Items in each page are
|
|
/// sorted by <c>Sequence</c> ascending; cross-page ordering follows the table's
|
|
/// RowKey iteration (Timestamp+Sequence) and may diverge from strict Sequence
|
|
/// order across clock-jump windows — consumers stitching multiple pages should
|
|
/// re-sort by <c>Sequence</c> after merging.
|
|
/// </summary>
|
|
Task<RawPage<EnrollmentEvent>> GetSessionEventsPageAsync(
|
|
string tenantId, string sessionId, int pageSize, string? continuation);
|
|
|
|
// --- Raw (literal-row) reads ---
|
|
// These power the deliberately-unenriched /api/raw/* tools: they return the stored
|
|
// Azure-Table columns verbatim (as plain dictionaries — no DTO mapping, no error-code
|
|
// enrichment) so the "raw" tools honour their name. The enriched equivalents are
|
|
// SearchSessionsPageAsync / GetSessionEvents*Async above.
|
|
|
|
/// <summary>
|
|
/// Literal-row sibling of <see cref="SearchSessionsPageAsync"/>: same filter + pagination
|
|
/// semantics, but yields the raw <c>SessionsIndex</c> rows (every stored column, PascalCase,
|
|
/// incl. PartitionKey/RowKey/Timestamp) instead of curated <see cref="SessionSummary"/> DTOs.
|
|
/// </summary>
|
|
Task<RawPage<IReadOnlyDictionary<string, object?>>> SearchSessionsRawPageAsync(
|
|
string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation);
|
|
|
|
/// <summary>
|
|
/// Literal-row sibling of <see cref="GetSessionEventsPageAsync"/>: yields the raw
|
|
/// <c>Events</c> rows for a session (DataJson as the stored string, Severity/Phase as the
|
|
/// stored ints) instead of mapped <see cref="EnrollmentEvent"/> objects. Items are ordered
|
|
/// by Sequence ascending.
|
|
/// </summary>
|
|
Task<RawPage<IReadOnlyDictionary<string, object?>>> GetSessionEventsRawPageAsync(
|
|
string tenantId, string sessionId, int pageSize, string? continuation);
|
|
|
|
/// <summary>
|
|
/// Literal-row sibling of <see cref="GetSessionEventsByTypeAsync"/>: yields raw
|
|
/// <c>Events</c> rows of one event type for a session, ordered by Sequence ascending.
|
|
/// </summary>
|
|
Task<List<IReadOnlyDictionary<string, object?>>> GetSessionEventsRawByTypeAsync(
|
|
string tenantId, string sessionId, string eventType, int maxResults = 200);
|
|
|
|
// --- Search ---
|
|
Task<List<QuickSearchResult>> QuickSearchSessionsAsync(string? tenantId, string query, int limit = 10);
|
|
Task<List<SessionSummary>> SearchSessionsAsync(string? tenantId, SessionSearchFilter filter);
|
|
|
|
/// <summary>
|
|
/// Paged variant of <see cref="SearchSessionsAsync"/>. Used by the
|
|
/// <c>/api/search/sessions</c> + <c>/api/global/search/sessions</c> endpoints.
|
|
/// The <see cref="SessionSearchFilter.Limit"/> field is ignored — pagination
|
|
/// is driven by <paramref name="pageSize"/> + <paramref name="continuation"/>.
|
|
/// </summary>
|
|
Task<RawPage<SessionSummary>> SearchSessionsPageAsync(
|
|
string? tenantId, SessionSearchFilter filter, int pageSize, string? continuation);
|
|
/// <summary>
|
|
/// Paged walk of the
|
|
/// <c>EventTypeIndex</c> table page-by-page so callers can drain large
|
|
/// result sets via <c>nextLink</c>. Replaces the legacy hard-coded
|
|
/// <c>limit:20</c> session-index lookup that silently dropped recall on
|
|
/// large tenants (mcp-pagination-rollout PR-6, audit finding D4).
|
|
/// </summary>
|
|
Task<RawPage<SessionSummary>> SearchSessionsByEventPageAsync(
|
|
string? tenantId, string eventType, string? source, string? severity, string? phase,
|
|
int pageSize, string? continuation);
|
|
Task<List<SessionSummary>> SearchSessionsByCveAsync(
|
|
string? tenantId, string cveId, double? minCvssScore, string? overallRisk, int limit = 50);
|
|
|
|
/// <summary>
|
|
/// Paged variant of <see cref="SearchSessionsByCveAsync"/>. Walks the
|
|
/// CveIndex partition page-by-page so callers can drain every device
|
|
/// affected by a CVE — the legacy unpaged variant capped at <c>limit*2</c>
|
|
/// candidates and silently dropped the rest.
|
|
/// </summary>
|
|
Task<RawPage<SessionSummary>> SearchSessionsByCvePageAsync(
|
|
string? tenantId, string cveId, double? minCvssScore, string? overallRisk,
|
|
int pageSize, string? continuation);
|
|
|
|
/// <summary>
|
|
/// Scans the CveIndex for fleet-wide vulnerability aggregation. Tenant-scoped
|
|
/// (<paramref name="tenantId"/> set) targets the tenant's partition prefix
|
|
/// (cheap); cross-tenant (null) is a bounded full scan. Returns up to
|
|
/// <paramref name="maxRows"/> projected rows plus a flag indicating the cap
|
|
/// was hit, so callers can surface partial-result truncation honestly.
|
|
/// </summary>
|
|
Task<(IReadOnlyList<CveExposureEntry> Rows, bool Truncated)> ScanCveIndexAsync(
|
|
string? tenantId, int maxRows, CancellationToken ct = default);
|
|
|
|
// --- Agent Indexes ---
|
|
Task UpsertEventTypeIndexBatchAsync(string tenantId, string sessionId, IEnumerable<EnrollmentEvent> events);
|
|
Task UpsertDeviceSnapshotAsync(string tenantId, string sessionId, IEnumerable<EnrollmentEvent> events);
|
|
Task UpsertCveIndexEntriesAsync(string tenantId, string sessionId, List<Dictionary<string, object>> findings);
|
|
}
|
|
}
|