using System.Threading; using Azure; using Azure.Data.Tables; using Azure.Identity; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Shared; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace AutopilotMonitor.Functions.Services { /// /// Service for managing Azure Table Storage operations. /// Split into partial class files by domain: /// - TableStorageService.cs (this file: core, initialization, helpers) /// - TableStorageService.Sessions.cs (sessions, events, mapping) /// - TableStorageService.Rules.cs (gather/analyze rules, rule states, IME patterns) /// - TableStorageService.Metrics.cs (usage metrics, platform stats, user activity, app installs) /// - TableStorageService.Maintenance.cs (audit logs, data retention, deletion helpers) /// public partial class TableStorageService { private readonly TableServiceClient _tableServiceClient; private readonly ILogger _logger; private bool _tablesInitialized = false; private readonly object _initLock = new object(); public TableStorageService(IConfiguration configuration, ILogger logger) { _logger = logger; var connectionString = configuration["AzureTableStorageConnectionString"]; var storageAccountName = configuration["AzureStorageAccountName"]; if (!string.IsNullOrEmpty(storageAccountName)) { // Managed Identity: use DefaultAzureCredential with storage account name var tableUri = new Uri($"https://{storageAccountName}.table.core.windows.net"); _tableServiceClient = new TableServiceClient(tableUri, new DefaultAzureCredential()); _logger.LogInformation("Table Storage initialized with Managed Identity (account: {Account})", storageAccountName); } else if (!string.IsNullOrEmpty(connectionString)) { // Fallback: connection string (local dev, legacy) _tableServiceClient = new TableServiceClient(connectionString); _logger.LogInformation("Table Storage initialized with connection string"); } else { throw new InvalidOperationException( "Table Storage not configured. Set either 'AzureStorageAccountName' (for Managed Identity) or 'AzureTableStorageConnectionString'."); } } /// /// Test seam: construct directly from a (possibly Moq'd) . /// Used by xUnit so the storage-touching helpers in the partial classes (Deletion, /// Inventory, …) can be exercised against the SDK's virtual surface without hitting Azure. /// Public (not internal) because Moq's dynamic proxy assembly cannot see internal ctors /// even via InternalsVisibleTo. /// public TableStorageService(TableServiceClient tableServiceClient, ILogger logger) { _tableServiceClient = tableServiceClient; _logger = logger; } /// /// Returns a TableClient for the specified table name. /// Used by services that need direct table access (e.g. VulnerabilityCorrelationService). /// public TableClient GetTableClient(string tableName) { return _tableServiceClient.GetTableClient(tableName); } /// /// Initializes all Azure Table Storage tables. /// This method is idempotent and safe to call multiple times. /// Should be called at application startup. /// public async Task InitializeTablesAsync() { if (_tablesInitialized) { _logger.LogDebug("Tables already initialized, skipping"); return; } lock (_initLock) { if (_tablesInitialized) return; } _logger.LogInformation("Initializing Azure Table Storage tables..."); var stopwatch = System.Diagnostics.Stopwatch.StartNew(); var successCount = 0; var failCount = 0; await Parallel.ForEachAsync( Constants.TableNames.All, new ParallelOptions { MaxDegreeOfParallelism = 8 }, async (tableName, ct) => { try { await _tableServiceClient.CreateTableIfNotExistsAsync(tableName, ct); _logger.LogDebug("Table '{TableName}' initialized", tableName); Interlocked.Increment(ref successCount); } catch (Exception ex) { _logger.LogError(ex, "Failed to initialize table '{TableName}'", tableName); Interlocked.Increment(ref failCount); } }); stopwatch.Stop(); _logger.LogInformation("Table initialization completed in {ElapsedMs}ms: {Success} succeeded, {Failed} failed", stopwatch.ElapsedMilliseconds, successCount, failCount); // CPE mapping seed is imported via Admin UI "Re-Seed Mappings" button // (pulls from GitHub, not embedded resource). No auto-import at startup. lock (_initLock) { _tablesInitialized = failCount == 0; } } /// /// Gets the TableServiceClient for direct access (used by other services) /// public TableServiceClient GetTableServiceClient() => _tableServiceClient; // ===== HELPER METHODS ===== /// /// Safely reads an Int32 property from a TableEntity. /// Returns null instead of throwing when the property has a different type (legacy data). /// private int? SafeGetInt32(TableEntity entity, string key) { try { return entity.GetInt32(key); } catch (InvalidOperationException) { _logger.LogWarning("Property '{Key}' on entity {PK}/{RK} is not Int32, attempting string parse", key, entity.PartitionKey, entity.RowKey); var str = entity.GetString(key); if (str != null && int.TryParse(str, out var parsed)) return parsed; return null; } } /// /// Safely reads a DateTime property from a TableEntity. /// Returns null instead of throwing when the property has a different type (legacy data). /// private DateTime? SafeGetDateTime(TableEntity entity, string key) { try { return entity.GetDateTime(key); } catch (InvalidOperationException) { _logger.LogWarning("Property '{Key}' on entity {PK}/{RK} is not DateTime, attempting string parse", key, entity.PartitionKey, entity.RowKey); var str = entity.GetString(key); if (str != null && DateTime.TryParse(str, out var parsed)) return parsed; return null; } } private T DeserializeJson(string? json) where T : new() { if (string.IsNullOrEmpty(json)) return new T(); try { return JsonConvert.DeserializeObject(json) ?? new T(); } catch { return new T(); } } /// /// Deserializes MatchedConditions JSON and normalizes nested JObject/JArray values /// to plain Dictionary/List so System.Text.Json can serialize them correctly. /// private Dictionary DeserializeMatchedConditions(string? json) { if (string.IsNullOrEmpty(json)) return new Dictionary(); try { var raw = JsonConvert.DeserializeObject>(json) ?? new Dictionary(); var result = new Dictionary(); foreach (var kv in raw) result[kv.Key] = NormalizeJToken(kv.Value); return result; } catch { return new Dictionary(); } } private static object NormalizeJToken(object? value) { if (value is Newtonsoft.Json.Linq.JObject jObj) { var dict = new Dictionary(); foreach (var prop in jObj.Properties()) dict[prop.Name] = NormalizeJToken(prop.Value); return dict; } if (value is Newtonsoft.Json.Linq.JArray jArr) { var list = new List(); foreach (var item in jArr) list.Add(NormalizeJToken(item)); return list; } if (value is Newtonsoft.Json.Linq.JValue jVal) return jVal.Value ?? string.Empty; return value ?? string.Empty; } private string[] DeserializeJsonArray(string? json) { if (string.IsNullOrEmpty(json)) return Array.Empty(); try { return JsonConvert.DeserializeObject(json) ?? Array.Empty(); } catch { return Array.Empty(); } } /// /// Deserializes event data JSON and converts JToken objects to native .NET types /// private Dictionary DeserializeEventData(string? dataJson) { if (string.IsNullOrEmpty(dataJson)) return new Dictionary(); try { var deserialized = JsonConvert.DeserializeObject>(dataJson); // Convert all JToken values to native types (shared with the ingest paths). return Functions.Ingest.EventDataNormalizer.NormalizeMap(deserialized); } catch { // JSON may be truncated (64KB Table Storage limit) — preserve the raw // string so the UI can still display it for debugging. return new Dictionary { ["_rawDataJson"] = dataJson }; } } } }