diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs new file mode 100644 index 00000000..f2bef573 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs @@ -0,0 +1,57 @@ +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Functions.DataAccess.TableStorage; +using Microsoft.Extensions.DependencyInjection; + +namespace AutopilotMonitor.Functions.DataAccess +{ + /// + /// Extension methods for registering Data Access Layer services. + /// Currently registers Azure Table Storage implementations. + /// To switch to Cosmos DB, replace the implementation registrations here. + /// + public static class DataAccessServiceExtensions + { + /// + /// Registers all Data Access Layer services with Table Storage implementations. + /// + public static IServiceCollection AddTableStorageDataAccess(this IServiceCollection services) + { + // Event publisher (no-op by default; replace with Event Hub/Service Bus implementation) + services.AddSingleton(); + + // Storage initializer + services.AddSingleton(); + + // Repository implementations (delegate to existing TableStorageService) + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + return services; + } + + /// + /// Replaces the default NullDataEventPublisher with a custom implementation. + /// Call this AFTER AddTableStorageDataAccess() to override. + /// Example: services.AddEventStreaming<EventHubPublisher>(); + /// + public static IServiceCollection AddEventStreaming(this IServiceCollection services) + where TPublisher : class, IDataEventPublisher + { + // Remove existing registration and replace + var descriptor = services.FirstOrDefault(d => d.ServiceType == typeof(IDataEventPublisher)); + if (descriptor != null) + services.Remove(descriptor); + + services.AddSingleton(); + return services; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs new file mode 100644 index 00000000..a6f93873 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs @@ -0,0 +1,378 @@ +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using Azure; +using Azure.Data.Tables; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.DataAccess.TableStorage +{ + /// + /// Table Storage implementation of IAdminRepository. + /// Performs direct CRUD against GlobalAdmins, TenantAdmins, and ApiKeys tables. + /// + public class TableAdminRepository : IAdminRepository + { + private readonly TableClient _globalAdminsTableClient; + private readonly TableClient _tenantAdminsTableClient; + private readonly TableClient _apiKeysTableClient; + private readonly ILogger _logger; + + public TableAdminRepository( + TableStorageService storage, + ILogger logger) + { + _logger = logger; + _globalAdminsTableClient = storage.GetTableClient(Constants.TableNames.GlobalAdmins); + _tenantAdminsTableClient = storage.GetTableClient(Constants.TableNames.TenantAdmins); + _apiKeysTableClient = storage.GetTableClient(Constants.TableNames.ApiKeys); + } + + // --- Global Admins --- + + public async Task IsGlobalAdminAsync(string upn) + { + if (string.IsNullOrWhiteSpace(upn)) + return false; + + try + { + var normalizedUpn = upn.ToLowerInvariant(); + var entity = await _globalAdminsTableClient.GetEntityAsync( + "GlobalAdmins", normalizedUpn); + return entity?.Value != null && entity.Value.IsEnabled; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking global admin status for {Upn}", upn); + return false; + } + } + + public async Task> GetAllGlobalAdminsAsync() + { + var admins = new List(); + await foreach (var entity in _globalAdminsTableClient.QueryAsync( + filter: $"PartitionKey eq 'GlobalAdmins'")) + { + admins.Add(new GlobalAdminEntry + { + Upn = entity.Upn, + IsEnabled = entity.IsEnabled, + AddedAt = entity.AddedDate, + AddedBy = entity.AddedBy + }); + } + return admins; + } + + public async Task AddGlobalAdminAsync(string upn, string addedBy) + { + upn = upn.ToLowerInvariant(); + addedBy = addedBy.ToLowerInvariant(); + + var entity = new GlobalAdminEntity + { + PartitionKey = "GlobalAdmins", + RowKey = upn, + Upn = upn, + IsEnabled = true, + AddedDate = DateTime.UtcNow, + AddedBy = addedBy + }; + + await _globalAdminsTableClient.UpsertEntityAsync(entity); + return true; + } + + public async Task RemoveGlobalAdminAsync(string upn) + { + upn = upn.ToLowerInvariant(); + await _globalAdminsTableClient.DeleteEntityAsync("GlobalAdmins", upn); + return true; + } + + public async Task DisableGlobalAdminAsync(string upn) + { + upn = upn.ToLowerInvariant(); + + try + { + var result = await _globalAdminsTableClient.GetEntityAsync( + "GlobalAdmins", upn); + var entity = result.Value; + if (entity != null) + { + entity.IsEnabled = false; + await _globalAdminsTableClient.UpdateEntityAsync(entity, ETag.All); + } + return true; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return false; + } + } + + // --- Tenant Members --- + + public async Task> GetTenantMembersAsync(string tenantId) + { + tenantId = tenantId.ToLowerInvariant(); + var members = new List(); + + await foreach (var entity in _tenantAdminsTableClient.QueryAsync( + filter: $"PartitionKey eq '{tenantId}'")) + { + members.Add(MapToTenantMember(entity)); + } + + return members; + } + + public async Task AddTenantMemberAsync(string tenantId, string upn, string addedBy, string role, bool canManageBootstrapTokens = false) + { + tenantId = tenantId.ToLowerInvariant(); + upn = upn.ToLowerInvariant(); + addedBy = addedBy.ToLowerInvariant(); + + var entity = new TenantAdminEntity + { + PartitionKey = tenantId, + RowKey = upn, + TenantId = tenantId, + Upn = upn, + IsEnabled = true, + AddedDate = DateTime.UtcNow, + AddedBy = addedBy, + Role = role, + CanManageBootstrapTokens = canManageBootstrapTokens + }; + + await _tenantAdminsTableClient.UpsertEntityAsync(entity); + return true; + } + + public async Task RemoveTenantMemberAsync(string tenantId, string upn) + { + tenantId = tenantId.ToLowerInvariant(); + upn = upn.ToLowerInvariant(); + await _tenantAdminsTableClient.DeleteEntityAsync(tenantId, upn); + return true; + } + + public async Task UpdateMemberPermissionsAsync(string tenantId, string upn, string role, bool canManageBootstrapTokens) + { + tenantId = tenantId.ToLowerInvariant(); + upn = upn.ToLowerInvariant(); + + try + { + var result = await _tenantAdminsTableClient.GetEntityAsync(tenantId, upn); + var entity = result.Value; + if (entity == null) return false; + + entity.Role = role; + entity.CanManageBootstrapTokens = canManageBootstrapTokens; + await _tenantAdminsTableClient.UpdateEntityAsync(entity, ETag.All); + return true; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return false; + } + } + + public async Task SetTenantMemberEnabledAsync(string tenantId, string upn, bool isEnabled) + { + tenantId = tenantId.ToLowerInvariant(); + upn = upn.ToLowerInvariant(); + + try + { + var result = await _tenantAdminsTableClient.GetEntityAsync(tenantId, upn); + var entity = result.Value; + if (entity == null) return false; + + entity.IsEnabled = isEnabled; + await _tenantAdminsTableClient.UpdateEntityAsync(entity, ETag.All); + return true; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return false; + } + } + + public async Task GetTenantMemberAsync(string tenantId, string upn) + { + tenantId = tenantId.ToLowerInvariant(); + upn = upn.ToLowerInvariant(); + + try + { + var result = await _tenantAdminsTableClient.GetEntityAsync(tenantId, upn); + var entity = result.Value; + if (entity == null) return null; + return MapToTenantMember(entity); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting tenant member {Upn} for tenant {TenantId}", upn, tenantId); + return null; + } + } + + public async Task IsTenantAdminAsync(string tenantId, string upn) + { + var member = await GetTenantMemberAsync(tenantId, upn); + if (member == null || !member.IsEnabled) return false; + // Only true for Admin role (null Role = Admin for backward compat) + return member.Role == null || member.Role == Constants.TenantRoles.Admin; + } + + public async Task IsTenantMemberAsync(string tenantId, string upn) + { + var member = await GetTenantMemberAsync(tenantId, upn); + if (member == null || !member.IsEnabled) return false; + return member.Role != Constants.TenantRoles.Viewer; + } + + // --- API Keys --- + + public async Task ValidateApiKeyAsync(string apiKeyHash) + { + await foreach (var entity in _apiKeysTableClient.QueryAsync( + filter: $"KeyHash eq '{apiKeyHash}'")) + { + return MapEntityToApiKeyEntry(entity); + } + return null; + } + + public async Task StoreApiKeyAsync(string tenantId, ApiKeyEntry entry) + { + var entity = new TableEntity(tenantId, entry.KeyId) + { + ["KeyHash"] = entry.KeyHash, + ["Label"] = entry.Name, + ["Scope"] = entry.Scope, + ["TenantId"] = entry.TenantId, + ["CreatedBy"] = entry.CreatedBy, + ["CreatedAt"] = entry.CreatedAt, + ["IsActive"] = entry.IsActive, + ["RequestCount"] = entry.RequestCount, + }; + + if (entry.ExpiresAt.HasValue) + entity["ExpiresAt"] = new DateTimeOffset(entry.ExpiresAt.Value, TimeSpan.Zero); + + await _apiKeysTableClient.UpsertEntityAsync(entity); + return true; + } + + public async Task> GetApiKeysAsync(string tenantId) + { + var keys = new List(); + await foreach (var entity in _apiKeysTableClient.QueryAsync( + filter: $"PartitionKey eq '{tenantId}'")) + { + keys.Add(MapEntityToApiKeyEntry(entity)); + } + return keys; + } + + public async Task> GetAllApiKeysAsync() + { + var keys = new List(); + await foreach (var entity in _apiKeysTableClient.QueryAsync()) + { + keys.Add(MapEntityToApiKeyEntry(entity)); + } + return keys; + } + + public async Task GetApiKeyAsync(string partitionKey, string keyId) + { + try + { + var result = await _apiKeysTableClient.GetEntityAsync(partitionKey, keyId); + return MapEntityToApiKeyEntry(result.Value); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + } + + public async Task RevokeApiKeyAsync(string tenantId, string keyId) + { + try + { + await _apiKeysTableClient.DeleteEntityAsync(tenantId, keyId); + return true; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return false; + } + } + + public async Task IncrementApiKeyRequestCountAsync(string partitionKey, string keyId) + { + try + { + var result = await _apiKeysTableClient.GetEntityAsync(partitionKey, keyId); + var entity = result.Value; + var count = entity.TryGetValue("RequestCount", out var c) ? Convert.ToInt64(c) : 0L; + entity["RequestCount"] = count + 1; + await _apiKeysTableClient.UpdateEntityAsync(entity, entity.ETag); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to increment request count for key {KeyId}", keyId); + } + } + + // --- Helpers --- + + private static TenantMember MapToTenantMember(TenantAdminEntity entity) + { + return new TenantMember + { + Upn = entity.Upn, + TenantId = entity.TenantId, + Role = entity.Role ?? Constants.TenantRoles.Admin, + IsEnabled = entity.IsEnabled, + CanManageBootstrapTokens = entity.CanManageBootstrapTokens, + AddedAt = entity.AddedDate, + AddedBy = entity.AddedBy + }; + } + + private static ApiKeyEntry MapEntityToApiKeyEntry(TableEntity entity) + { + return new ApiKeyEntry + { + KeyId = entity.RowKey, + TenantId = entity.GetString("TenantId") ?? entity.PartitionKey, + KeyHash = entity.GetString("KeyHash") ?? string.Empty, + Name = entity.GetString("Label") ?? string.Empty, + Scope = entity.GetString("Scope") ?? "tenant", + CreatedBy = entity.GetString("CreatedBy") ?? string.Empty, + CreatedAt = entity.GetDateTimeOffset("CreatedAt")?.UtcDateTime ?? DateTime.MinValue, + ExpiresAt = entity.GetDateTimeOffset("ExpiresAt")?.UtcDateTime, + IsActive = entity.GetBoolean("IsActive") ?? true, + RequestCount = entity.TryGetValue("RequestCount", out var rc) ? Convert.ToInt64(rc) : 0L, + }; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableBootstrapRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableBootstrapRepository.cs new file mode 100644 index 00000000..cc41e00f --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableBootstrapRepository.cs @@ -0,0 +1,275 @@ +using System.Security.Cryptography; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Azure; +using Azure.Data.Tables; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.DataAccess.TableStorage +{ + /// + /// Table Storage implementation of IBootstrapRepository. + /// Uses a dual-partition scheme: + /// - Main entity: PK=TenantId, RK=ShortCode (for tenant-scoped listing) + /// - Lookup entity: PK="CodeLookup", RK=ShortCode (for anonymous code validation) + /// + public class TableBootstrapRepository : IBootstrapRepository + { + private readonly TableClient _tableClient; + private readonly ILogger _logger; + + private const string CodeLookupPartition = "CodeLookup"; + + public TableBootstrapRepository( + TableStorageService storage, + ILogger logger) + { + _logger = logger; + _tableClient = storage.GetTableClient(Constants.TableNames.BootstrapSessions); + } + + /// + /// Creates a bootstrap session with both main + lookup entities. + /// The lookup entity insertion acts as a uniqueness guard for the short code. + /// + public async Task CreateBootstrapSessionAsync(BootstrapSession session) + { + try + { + // Insert lookup entity first (acts as uniqueness guard for short code) + var lookupEntity = new TableEntity(CodeLookupPartition, session.ShortCode) + { + { "TenantId", session.TenantId }, + { "Token", session.Token }, + { "ExpiresAt", session.ExpiresAt }, + { "IsRevoked", false } + }; + await _tableClient.AddEntityAsync(lookupEntity); + + // Insert main entity + var mainEntity = new TableEntity(session.TenantId, session.ShortCode) + { + { "Token", session.Token }, + { "CreatedAt", session.CreatedAt }, + { "ExpiresAt", session.ExpiresAt }, + { "CreatedByUpn", session.CreatedByUpn }, + { "IsRevoked", false }, + { "UsageCount", 0 }, + { "Label", session.Label ?? "" } + }; + await _tableClient.AddEntityAsync(mainEntity); + + _logger.LogInformation("Created bootstrap session {ShortCode} for tenant {TenantId}, expires {ExpiresAt}", + session.ShortCode, session.TenantId, session.ExpiresAt); + + return true; + } + catch (RequestFailedException ex) when (ex.Status == 409) + { + // Short code collision + _logger.LogWarning("Bootstrap short code collision: {ShortCode}", session.ShortCode); + return false; + } + } + + /// + /// Gets a bootstrap session by short code using the CodeLookup partition for O(1) lookup, + /// then reads the main entity for full details. + /// + public async Task GetBootstrapSessionByCodeAsync(string shortCode) + { + try + { + // O(1) lookup via the CodeLookup partition + var lookupResult = await _tableClient.GetEntityAsync(CodeLookupPartition, shortCode); + var lookup = lookupResult.Value; + + var isRevoked = lookup.GetBoolean("IsRevoked") ?? false; + var expiresAt = lookup.GetDateTime("ExpiresAt") ?? DateTime.MinValue; + + if (isRevoked) + { + _logger.LogWarning("Bootstrap code {ShortCode} has been revoked", shortCode); + return null; + } + + if (expiresAt < DateTime.UtcNow) + { + _logger.LogWarning("Bootstrap code {ShortCode} has expired (expired {ExpiresAt})", shortCode, expiresAt); + return null; + } + + var tenantId = lookup.GetString("TenantId") ?? ""; + + // Read main entity for full details + var mainResult = await _tableClient.GetEntityAsync(tenantId, shortCode); + return MapFromEntity(mainResult.Value); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + _logger.LogWarning("Bootstrap code {ShortCode} not found", shortCode); + return null; + } + } + + /// + /// Validates a bootstrap token by scanning the table for a matching Token property. + /// Acceptable performance because BootstrapSessions table is small. + /// + public async Task ValidateBootstrapTokenAsync(string token) + { + var query = _tableClient.QueryAsync( + filter: $"Token eq '{token}' and PartitionKey ne '{CodeLookupPartition}'", + maxPerPage: 1); + + BootstrapSession? session = null; + await foreach (var entity in query) + { + session = MapFromEntity(entity); + break; // We only need the first match + } + + if (session == null || session.IsRevoked || session.ExpiresAt < DateTime.UtcNow) + return null; + + return session; + } + + /// + /// Lists bootstrap sessions for a tenant (includes active and recently expired/revoked). + /// + public async Task> GetBootstrapSessionsAsync(string tenantId) + { + var cutoff = DateTime.UtcNow.AddHours(-24); + var sessions = new List(); + + var query = _tableClient.QueryAsync( + filter: $"PartitionKey eq '{tenantId}'"); + + await foreach (var entity in query) + { + var expiresAt = entity.GetDateTime("ExpiresAt") ?? DateTime.MinValue; + var isRevoked = entity.GetBoolean("IsRevoked") ?? false; + + // Skip sessions that expired more than 24h ago and are not active + if (expiresAt < cutoff && (isRevoked || expiresAt < DateTime.UtcNow)) + continue; + + sessions.Add(MapFromEntity(entity)); + } + + return sessions.OrderByDescending(s => s.CreatedAt).ToList(); + } + + /// + /// Revokes a bootstrap session. Looks up the tenant via CodeLookup partition first. + /// + public async Task RevokeBootstrapSessionAsync(string shortCode) + { + try + { + // Look up the tenant from the CodeLookup partition + var lookupResult = await _tableClient.GetEntityAsync(CodeLookupPartition, shortCode); + var lookup = lookupResult.Value; + var tenantId = lookup.GetString("TenantId") ?? ""; + + // Update main entity + var mainResult = await _tableClient.GetEntityAsync(tenantId, shortCode); + mainResult.Value["IsRevoked"] = true; + await _tableClient.UpdateEntityAsync(mainResult.Value, mainResult.Value.ETag, TableUpdateMode.Merge); + + // Update lookup entity + lookup["IsRevoked"] = true; + await _tableClient.UpdateEntityAsync(lookup, lookup.ETag, TableUpdateMode.Merge); + + _logger.LogInformation("Revoked bootstrap session {ShortCode} for tenant {TenantId}", shortCode, tenantId); + return true; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + _logger.LogWarning("Bootstrap session {ShortCode} not found during revoke", shortCode); + return false; + } + } + + /// + /// Increments usage count for a bootstrap session. + /// + public async Task IncrementBootstrapUsageAsync(string shortCode) + { + try + { + // Look up tenant from CodeLookup + var lookupResult = await _tableClient.GetEntityAsync(CodeLookupPartition, shortCode); + var tenantId = lookupResult.Value.GetString("TenantId") ?? ""; + + var mainResult = await _tableClient.GetEntityAsync(tenantId, shortCode); + var currentCount = mainResult.Value.GetInt32("UsageCount") ?? 0; + mainResult.Value["UsageCount"] = currentCount + 1; + await _tableClient.UpdateEntityAsync(mainResult.Value, mainResult.Value.ETag, TableUpdateMode.Merge); + + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to increment usage count for bootstrap code {ShortCode}", shortCode); + return false; + } + } + + /// + /// Deletes bootstrap sessions that expired more than 7 days ago. + /// Returns the count of deleted entities. + /// + public async Task CleanupExpiredAsync() + { + var cutoff = DateTime.UtcNow.AddDays(-7); + var toDelete = new List<(string partitionKey, string rowKey, ETag etag)>(); + + var query = _tableClient.QueryAsync( + filter: $"ExpiresAt lt datetime'{cutoff:yyyy-MM-ddTHH:mm:ssZ}'"); + + await foreach (var entity in query) + { + toDelete.Add((entity.PartitionKey, entity.RowKey, entity.ETag)); + } + + foreach (var (partitionKey, rowKey, etag) in toDelete) + { + try + { + await _tableClient.DeleteEntityAsync(partitionKey, rowKey, etag); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + // Already deleted — ignore + } + } + + if (toDelete.Count > 0) + _logger.LogInformation("Cleaned up {Count} expired bootstrap sessions", toDelete.Count); + + return toDelete.Count; + } + + // --- Helpers --- + + private static BootstrapSession MapFromEntity(TableEntity entity) + { + return new BootstrapSession + { + TenantId = entity.PartitionKey, + ShortCode = entity.RowKey, + Token = entity.GetString("Token"), + CreatedAt = entity.GetDateTime("CreatedAt") ?? DateTime.MinValue, + ExpiresAt = entity.GetDateTime("ExpiresAt") ?? DateTime.MinValue, + CreatedByUpn = entity.GetString("CreatedByUpn") ?? "", + IsRevoked = entity.GetBoolean("IsRevoked") ?? false, + UsageCount = entity.GetInt32("UsageCount") ?? 0, + Label = entity.GetString("Label") ?? "" + }; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs new file mode 100644 index 00000000..7681e3db --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -0,0 +1,582 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Azure; +using Azure.Data.Tables; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.DataAccess.TableStorage +{ + /// + /// Table Storage implementation of IConfigRepository. + /// Handles entity mapping and storage operations for tenant configuration, + /// admin configuration, preview whitelist, and preview config tables. + /// + public class TableConfigRepository : IConfigRepository + { + private readonly TableClient _tenantConfigTableClient; + private readonly TableClient _adminConfigTableClient; + private readonly TableClient _previewWhitelistTableClient; + private readonly TableClient _previewConfigTableClient; + private readonly ILogger _logger; + + public TableConfigRepository(TableStorageService storage, ILogger logger) + { + _logger = logger; + _tenantConfigTableClient = storage.GetTableClient(Constants.TableNames.TenantConfiguration); + _adminConfigTableClient = storage.GetTableClient(Constants.TableNames.AdminConfiguration); + _previewWhitelistTableClient = storage.GetTableClient(Constants.TableNames.PreviewWhitelist); + _previewConfigTableClient = storage.GetTableClient(Constants.TableNames.PreviewConfig); + } + + // --- Tenant Configuration --- + + public async Task GetTenantConfigurationAsync(string tenantId) + { + try + { + var entity = await _tenantConfigTableClient.GetEntityAsync(tenantId, "config"); + return ConvertFromTenantTableEntity(entity.Value); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading tenant configuration for {TenantId}", tenantId); + throw; + } + } + + public async Task SaveTenantConfigurationAsync(TenantConfiguration config) + { + try + { + var entity = ConvertToTenantTableEntity(config); + await _tenantConfigTableClient.UpsertEntityAsync(entity); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving tenant configuration for {TenantId}", config.TenantId); + return false; + } + } + + public async Task> GetAllTenantConfigurationsAsync() + { + try + { + var configurations = new List(); + + await foreach (var entity in _tenantConfigTableClient.QueryAsync(filter: "RowKey eq 'config'")) + { + var config = ConvertFromTenantTableEntity(entity); + configurations.Add(config); + } + + return configurations.OrderBy(c => c.TenantId).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading all tenant configurations"); + throw; + } + } + + // --- Admin Configuration --- + + public async Task GetAdminConfigurationAsync() + { + try + { + var entity = await _adminConfigTableClient.GetEntityAsync("GlobalConfig", "config"); + return ConvertFromAdminTableEntity(entity.Value); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading admin configuration"); + throw; + } + } + + public async Task SaveAdminConfigurationAsync(AdminConfiguration config) + { + try + { + var entity = ConvertToAdminTableEntity(config); + await _adminConfigTableClient.UpsertEntityAsync(entity); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving admin configuration"); + return false; + } + } + + // --- Preview Whitelist --- + + public async Task IsInPreviewWhitelistAsync(string tenantId) + { + try + { + var entity = await _previewWhitelistTableClient.GetEntityAsync(tenantId, "approved"); + return entity?.Value != null; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking preview whitelist for tenant {TenantId}", tenantId); + throw; + } + } + + public async Task AddToPreviewWhitelistAsync(string tenantId, string addedBy) + { + try + { + var entity = new PreviewWhitelistEntity + { + PartitionKey = tenantId, + RowKey = "approved", + ApprovedAt = DateTime.UtcNow, + ApprovedBy = addedBy + }; + + await _previewWhitelistTableClient.UpsertEntityAsync(entity); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error adding tenant {TenantId} to preview whitelist", tenantId); + return false; + } + } + + public async Task RemoveFromPreviewWhitelistAsync(string tenantId) + { + try + { + await _previewWhitelistTableClient.DeleteEntityAsync(tenantId, "approved"); + return true; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error removing tenant {TenantId} from preview whitelist", tenantId); + return false; + } + } + + public async Task> GetPreviewWhitelistAsync() + { + try + { + var results = new List(); + + await foreach (var entity in _previewWhitelistTableClient.QueryAsync( + filter: "RowKey eq 'approved'")) + { + results.Add(entity.PartitionKey); + } + + return results.OrderBy(t => t).ToList(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading preview whitelist"); + throw; + } + } + + // --- Preview Config --- + + public async Task> GetPreviewConfigAsync() + { + try + { + var config = new Dictionary(); + var entity = await _previewConfigTableClient.GetEntityAsync("TelegramBot", "config"); + + foreach (var kvp in entity.Value) + { + if (kvp.Key == "odata.etag" || kvp.Key == "PartitionKey" || kvp.Key == "RowKey" || kvp.Key == "Timestamp") + continue; + config[kvp.Key] = kvp.Value?.ToString() ?? string.Empty; + } + + return config; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return new Dictionary(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading preview config"); + throw; + } + } + + public async Task SavePreviewConfigAsync(string key, string value) + { + try + { + // Get existing entity or create new one + TableEntity entity; + try + { + var existing = await _previewConfigTableClient.GetEntityAsync("TelegramBot", "config"); + entity = existing.Value; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + entity = new TableEntity("TelegramBot", "config"); + } + + entity[key] = value; + await _previewConfigTableClient.UpsertEntityAsync(entity); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving preview config key {Key}", key); + return false; + } + } + + // --- Feedback --- + + public async Task GetFeedbackEntryAsync(string upn) + { + try + { + var entity = await _previewConfigTableClient.GetEntityAsync("Feedback", upn.ToLowerInvariant()); + return ConvertFromFeedbackTableEntity(entity.Value); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading feedback entry for {Upn}", upn); + throw; + } + } + + public async Task SaveFeedbackEntryAsync(FeedbackEntry entry) + { + try + { + var entity = new TableEntity("Feedback", entry.Upn.ToLowerInvariant()) + { + { "TenantId", entry.TenantId }, + { "DisplayName", entry.DisplayName }, + { "Rating", entry.Rating }, + { "Comment", entry.Comment }, + { "Dismissed", entry.Dismissed }, + { "Submitted", entry.Submitted }, + { "InteractedAt", entry.InteractedAt } + }; + + await _previewConfigTableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error saving feedback entry for {Upn}", entry.Upn); + throw; + } + } + + public async Task> GetAllFeedbackEntriesAsync() + { + try + { + var entries = new List(); + + await foreach (var entity in _previewConfigTableClient.QueryAsync( + filter: "PartitionKey eq 'Feedback'")) + { + entries.Add(ConvertFromFeedbackTableEntity(entity)); + } + + return entries; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error loading all feedback entries"); + throw; + } + } + + // --- Preview Notification Email --- + + public async Task GetNotificationEmailAsync(string tenantId) + { + try + { + var entity = await _previewWhitelistTableClient.GetEntityAsync(tenantId, "notification-email"); + return entity?.Value?.Email; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read notification email for tenant {TenantId}", tenantId); + return null; + } + } + + public async Task SaveNotificationEmailAsync(string tenantId, string? email) + { + if (string.IsNullOrWhiteSpace(email)) + { + // Clear: delete the row if it exists + try { await _previewWhitelistTableClient.DeleteEntityAsync(tenantId, "notification-email"); } + catch (RequestFailedException ex) when (ex.Status == 404) { /* already gone */ } + return; + } + + var entity = new PreviewNotificationEntity + { + PartitionKey = tenantId, + RowKey = "notification-email", + Email = email.Trim() + }; + await _previewWhitelistTableClient.UpsertEntityAsync(entity); + } + + // --- Tenant Configuration Entity Mapping --- + + private TableEntity ConvertToTenantTableEntity(TenantConfiguration config) + { + var entity = new TableEntity(config.TenantId, "config") + { + { "DomainName", config.DomainName }, + { "LastUpdated", config.LastUpdated }, + { "UpdatedBy", config.UpdatedBy }, + { "Disabled", config.Disabled }, + { "DisabledReason", config.DisabledReason }, + { "DisabledUntil", config.DisabledUntil }, + { "RateLimitRequestsPerMinute", config.RateLimitRequestsPerMinute }, + { "CustomRateLimitRequestsPerMinute", config.CustomRateLimitRequestsPerMinute }, + { "ManufacturerWhitelist", config.ManufacturerWhitelist }, + { "ModelWhitelist", config.ModelWhitelist }, + { "ValidateAutopilotDevice", config.ValidateAutopilotDevice }, + { "ValidateCorporateIdentifier", config.ValidateCorporateIdentifier }, + { "AllowInsecureAgentRequests", config.AllowInsecureAgentRequests }, + { "DataRetentionDays", config.DataRetentionDays }, + { "SessionTimeoutHours", config.SessionTimeoutHours }, + { "MaxNdjsonPayloadSizeMB", config.MaxNdjsonPayloadSizeMB }, + { "EnablePerformanceCollector", config.EnablePerformanceCollector }, + { "PerformanceCollectorIntervalSeconds", config.PerformanceCollectorIntervalSeconds }, + { "MaxAuthFailures", config.MaxAuthFailures }, + { "AuthFailureTimeoutMinutes", config.AuthFailureTimeoutMinutes }, + { "SelfDestructOnComplete", config.SelfDestructOnComplete }, + { "KeepLogFile", config.KeepLogFile }, + { "RebootOnComplete", config.RebootOnComplete }, + { "RebootDelaySeconds", config.RebootDelaySeconds }, + { "EnableGeoLocation", config.EnableGeoLocation }, + { "EnableImeMatchLog", config.EnableImeMatchLog }, + { "LogLevel", config.LogLevel }, + { "MaxBatchSize", config.MaxBatchSize }, + { "DiagnosticsBlobSasUrl", config.DiagnosticsBlobSasUrl }, + { "DiagnosticsUploadMode", config.DiagnosticsUploadMode }, + { "DiagnosticsLogPathsJson", config.DiagnosticsLogPathsJson }, + { "TeamsWebhookUrl", config.TeamsWebhookUrl }, + { "TeamsNotifyOnSuccess", config.TeamsNotifyOnSuccess }, + { "TeamsNotifyOnFailure", config.TeamsNotifyOnFailure }, + { "WebhookProviderType", config.WebhookProviderType }, + { "WebhookUrl", config.WebhookUrl }, + { "WebhookNotifyOnSuccess", config.WebhookNotifyOnSuccess }, + { "WebhookNotifyOnFailure", config.WebhookNotifyOnFailure }, + { "ShowScriptOutput", config.ShowScriptOutput }, + { "ShowEnrollmentSummary", config.ShowEnrollmentSummary }, + { "EnrollmentSummaryTimeoutSeconds", config.EnrollmentSummaryTimeoutSeconds }, + { "EnrollmentSummaryBrandingImageUrl", config.EnrollmentSummaryBrandingImageUrl }, + { "EnrollmentSummaryLaunchRetrySeconds", config.EnrollmentSummaryLaunchRetrySeconds }, + { "HelloWaitTimeoutSeconds", config.HelloWaitTimeoutSeconds }, + { "AgentMaxLifetimeMinutes", config.AgentMaxLifetimeMinutes }, + { "SendTraceEvents", config.SendTraceEvents }, + { "EnableLocalAdminAnalyzer", config.EnableLocalAdminAnalyzer }, + { "EnableSoftwareInventoryAnalyzer", config.EnableSoftwareInventoryAnalyzer }, + { "LocalAdminAllowedAccountsJson", config.LocalAdminAllowedAccountsJson }, + { "BootstrapTokenEnabled", config.BootstrapTokenEnabled }, + { "UnrestrictedModeEnabled", config.UnrestrictedModeEnabled }, + { "UnrestrictedMode", config.UnrestrictedMode }, + { "OnboardedAt", config.OnboardedAt } + }; + + return entity; + } + + private TenantConfiguration ConvertFromTenantTableEntity(TableEntity entity) + { + return new TenantConfiguration + { + TenantId = entity.PartitionKey, + DomainName = entity.GetString("DomainName") ?? "", + LastUpdated = entity.GetDateTime("LastUpdated") ?? DateTime.UtcNow, + UpdatedBy = entity.GetString("UpdatedBy") ?? "Unknown", + Disabled = entity.GetBoolean("Disabled") ?? false, + DisabledReason = entity.GetString("DisabledReason"), + DisabledUntil = entity.GetDateTime("DisabledUntil"), + RateLimitRequestsPerMinute = entity.GetInt32("RateLimitRequestsPerMinute") ?? 100, + CustomRateLimitRequestsPerMinute = entity.GetInt32("CustomRateLimitRequestsPerMinute"), + ManufacturerWhitelist = entity.GetString("ManufacturerWhitelist") ?? "Dell*,HP*,Lenovo*,Microsoft Corporation", + ModelWhitelist = entity.GetString("ModelWhitelist") ?? "*", + ValidateAutopilotDevice = entity.GetBoolean("ValidateAutopilotDevice") ?? entity.GetBoolean("ValidateSerialNumber") ?? false, + ValidateCorporateIdentifier = entity.GetBoolean("ValidateCorporateIdentifier") ?? false, + AllowInsecureAgentRequests = entity.GetBoolean("AllowInsecureAgentRequests") ?? false, + DataRetentionDays = entity.GetInt32("DataRetentionDays") ?? 90, + SessionTimeoutHours = entity.GetInt32("SessionTimeoutHours") ?? 5, + MaxNdjsonPayloadSizeMB = entity.GetInt32("MaxNdjsonPayloadSizeMB") ?? 5, + EnablePerformanceCollector = entity.GetBoolean("EnablePerformanceCollector") ?? false, + PerformanceCollectorIntervalSeconds = entity.GetInt32("PerformanceCollectorIntervalSeconds") ?? 30, + MaxAuthFailures = entity.GetInt32("MaxAuthFailures"), + AuthFailureTimeoutMinutes = entity.GetInt32("AuthFailureTimeoutMinutes"), + SelfDestructOnComplete = entity.GetBoolean("SelfDestructOnComplete"), + KeepLogFile = entity.GetBoolean("KeepLogFile"), + RebootOnComplete = entity.GetBoolean("RebootOnComplete"), + RebootDelaySeconds = entity.GetInt32("RebootDelaySeconds"), + EnableGeoLocation = entity.GetBoolean("EnableGeoLocation"), + EnableImeMatchLog = entity.GetBoolean("EnableImeMatchLog"), + LogLevel = entity.GetString("LogLevel"), + MaxBatchSize = entity.GetInt32("MaxBatchSize"), + DiagnosticsBlobSasUrl = entity.GetString("DiagnosticsBlobSasUrl"), + DiagnosticsUploadMode = entity.GetString("DiagnosticsUploadMode") ?? "Off", + DiagnosticsLogPathsJson = entity.GetString("DiagnosticsLogPathsJson"), + TeamsWebhookUrl = entity.GetString("TeamsWebhookUrl"), + TeamsNotifyOnSuccess = entity.GetBoolean("TeamsNotifyOnSuccess") ?? true, + TeamsNotifyOnFailure = entity.GetBoolean("TeamsNotifyOnFailure") ?? true, + WebhookProviderType = entity.GetInt32("WebhookProviderType") ?? 0, + WebhookUrl = entity.GetString("WebhookUrl"), + WebhookNotifyOnSuccess = entity.GetBoolean("WebhookNotifyOnSuccess") ?? true, + WebhookNotifyOnFailure = entity.GetBoolean("WebhookNotifyOnFailure") ?? true, + ShowScriptOutput = entity.GetBoolean("ShowScriptOutput") ?? true, + ShowEnrollmentSummary = entity.GetBoolean("ShowEnrollmentSummary"), + EnrollmentSummaryTimeoutSeconds = entity.GetInt32("EnrollmentSummaryTimeoutSeconds"), + EnrollmentSummaryBrandingImageUrl = entity.GetString("EnrollmentSummaryBrandingImageUrl"), + EnrollmentSummaryLaunchRetrySeconds = entity.GetInt32("EnrollmentSummaryLaunchRetrySeconds"), + HelloWaitTimeoutSeconds = entity.GetInt32("HelloWaitTimeoutSeconds") ?? 30, + AgentMaxLifetimeMinutes = entity.GetInt32("AgentMaxLifetimeMinutes"), + SendTraceEvents = entity.GetBoolean("SendTraceEvents") ?? true, + EnableLocalAdminAnalyzer = entity.GetBoolean("EnableLocalAdminAnalyzer"), + EnableSoftwareInventoryAnalyzer = entity.GetBoolean("EnableSoftwareInventoryAnalyzer"), + LocalAdminAllowedAccountsJson = entity.GetString("LocalAdminAllowedAccountsJson"), + BootstrapTokenEnabled = entity.GetBoolean("BootstrapTokenEnabled") ?? false, + UnrestrictedModeEnabled = entity.GetBoolean("UnrestrictedModeEnabled") ?? false, + UnrestrictedMode = entity.GetBoolean("UnrestrictedMode") ?? false, + OnboardedAt = entity.GetDateTime("OnboardedAt") + }; + } + + // --- Admin Configuration Entity Mapping --- + + private TableEntity ConvertToAdminTableEntity(AdminConfiguration config) + { + var entity = new TableEntity("GlobalConfig", "config") + { + { "LastUpdated", config.LastUpdated }, + { "UpdatedBy", config.UpdatedBy }, + { "GlobalRateLimitRequestsPerMinute", config.GlobalRateLimitRequestsPerMinute }, + { "PlatformStatsBlobSasUrl", config.PlatformStatsBlobSasUrl ?? string.Empty }, + { "CollectorIdleTimeoutMinutes", config.CollectorIdleTimeoutMinutes }, + { "MaxSessionWindowHours", config.MaxSessionWindowHours }, + { "MaintenanceBlockDurationHours", config.MaintenanceBlockDurationHours }, + { "DiagnosticsGlobalLogPathsJson", config.DiagnosticsGlobalLogPathsJson }, + { "NvdApiKey", config.NvdApiKey }, + { "VulnerabilityCorrelationEnabled", config.VulnerabilityCorrelationEnabled }, + { "VulnerabilityDataLastSyncUtc", config.VulnerabilityDataLastSyncUtc } + }; + + return entity; + } + + private AdminConfiguration ConvertFromAdminTableEntity(TableEntity entity) + { + return new AdminConfiguration + { + PartitionKey = entity.PartitionKey, + RowKey = entity.RowKey, + LastUpdated = entity.GetDateTime("LastUpdated") ?? DateTime.UtcNow, + UpdatedBy = entity.GetString("UpdatedBy") ?? "Unknown", + GlobalRateLimitRequestsPerMinute = entity.GetInt32("GlobalRateLimitRequestsPerMinute") ?? 100, + PlatformStatsBlobSasUrl = entity.GetString("PlatformStatsBlobSasUrl") ?? string.Empty, + CollectorIdleTimeoutMinutes = entity.GetInt32("CollectorIdleTimeoutMinutes") ?? 15, + MaxSessionWindowHours = entity.GetInt32("MaxSessionWindowHours") ?? 24, + MaintenanceBlockDurationHours = entity.GetInt32("MaintenanceBlockDurationHours") ?? 12, + DiagnosticsGlobalLogPathsJson = entity.GetString("DiagnosticsGlobalLogPathsJson"), + NvdApiKey = entity.GetString("NvdApiKey"), + VulnerabilityCorrelationEnabled = entity.GetBoolean("VulnerabilityCorrelationEnabled") ?? true, + VulnerabilityDataLastSyncUtc = entity.GetString("VulnerabilityDataLastSyncUtc") + }; + } + + // --- Feedback Entity Mapping --- + + private FeedbackEntry ConvertFromFeedbackTableEntity(TableEntity entity) + { + return new FeedbackEntry + { + Upn = entity.RowKey, + TenantId = entity.GetString("TenantId") ?? string.Empty, + DisplayName = entity.GetString("DisplayName") ?? string.Empty, + Rating = entity.GetInt32("Rating"), + Comment = entity.GetString("Comment"), + Dismissed = entity.GetBoolean("Dismissed") ?? false, + Submitted = entity.GetBoolean("Submitted") ?? false, + InteractedAt = entity.GetDateTime("InteractedAt") + }; + } + } + + /// + /// Entity representing an approved tenant in the PreviewWhitelist table. + /// + public class PreviewWhitelistEntity : ITableEntity + { + public string PartitionKey { get; set; } = string.Empty; // TenantId + public string RowKey { get; set; } = "approved"; + public DateTimeOffset? Timestamp { get; set; } + public ETag ETag { get; set; } + + public DateTime ApprovedAt { get; set; } + public string ApprovedBy { get; set; } = string.Empty; + } + + /// + /// Entity storing the notification email for a tenant in the PreviewWhitelist table. + /// PartitionKey = TenantId, RowKey = "notification-email". + /// Temporary — remove after GA. + /// + public class PreviewNotificationEntity : ITableEntity + { + public string PartitionKey { get; set; } = string.Empty; // TenantId + public string RowKey { get; set; } = "notification-email"; + public DateTimeOffset? Timestamp { get; set; } + public ETag ETag { get; set; } + + public string Email { get; set; } = string.Empty; + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDeviceSecurityRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDeviceSecurityRepository.cs new file mode 100644 index 00000000..4f0587e8 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableDeviceSecurityRepository.cs @@ -0,0 +1,356 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Azure; +using Azure.Data.Tables; +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.DataAccess.TableStorage +{ + /// + /// Table Storage implementation of IDeviceSecurityRepository. + /// Pure storage operations for blocked devices and blocked versions. + /// Caching is handled by the consuming services (BlockedDeviceService, BlockedVersionService). + /// + public class TableDeviceSecurityRepository : IDeviceSecurityRepository + { + private readonly TableClient _blockedDevicesTable; + private readonly TableClient _blockedVersionsTable; + private readonly IDataEventPublisher _publisher; + private readonly ILogger _logger; + + public TableDeviceSecurityRepository( + TableStorageService storage, + IDataEventPublisher publisher, + ILogger logger) + { + _publisher = publisher; + _logger = logger; + _blockedDevicesTable = storage.GetTableClient(Constants.TableNames.BlockedDevices); + _blockedVersionsTable = storage.GetTableClient(Constants.TableNames.BlockedVersions); + } + + // ----------------------------------------------------------------------- + // Blocked Devices + // ----------------------------------------------------------------------- + + public async Task<(bool isBlocked, DateTime? unblockAt, string action)> IsDeviceBlockedAsync(string tenantId, string serialNumber) + { + if (string.IsNullOrEmpty(tenantId) || string.IsNullOrEmpty(serialNumber)) + return (false, null, "Block"); + + try + { + var response = await _blockedDevicesTable.GetEntityAsync(tenantId, EncodeRowKey(serialNumber)); + var entity = response?.Value; + if (entity == null) + return (false, null, "Block"); + + var unblockAt = entity.GetDateTimeOffset("UnblockAt")?.UtcDateTime ?? DateTime.MinValue; + if (DateTime.UtcNow >= unblockAt) + return (false, null, "Block"); + + var action = entity.GetString("Action") ?? "Block"; + return (true, unblockAt, action); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return (false, null, "Block"); + } + } + + public async Task> GetBlockedDevicesAsync(string tenantId) + { + var result = new List(); + var expiredRowKeys = new List(); + var now = DateTime.UtcNow; + + await foreach (var entity in _blockedDevicesTable.QueryAsync(e => e.PartitionKey == tenantId)) + { + var unblockAt = entity.GetDateTimeOffset("UnblockAt")?.UtcDateTime ?? DateTime.MinValue; + + if (now >= unblockAt) + { + expiredRowKeys.Add(entity.RowKey); + continue; + } + + result.Add(MapToBlockedDeviceEntry(entity, tenantId, now)); + } + + // Clean up expired entries (fire-and-forget, best effort) + _ = CleanupExpiredDeviceEntriesAsync(tenantId, expiredRowKeys); + + return result; + } + + public async Task> GetAllBlockedDevicesAsync() + { + var result = new List(); + var expiredKeys = new List<(string partitionKey, string rowKey)>(); + var now = DateTime.UtcNow; + + await foreach (var entity in _blockedDevicesTable.QueryAsync()) + { + var unblockAt = entity.GetDateTimeOffset("UnblockAt")?.UtcDateTime ?? DateTime.MinValue; + + if (now >= unblockAt) + { + expiredKeys.Add((entity.PartitionKey, entity.RowKey)); + continue; + } + + result.Add(MapToBlockedDeviceEntry(entity, entity.PartitionKey, now)); + } + + // Clean up expired entries (fire-and-forget, best effort) + foreach (var (pk, rk) in expiredKeys) + { + try { await _blockedDevicesTable.DeleteEntityAsync(pk, rk); } + catch { /* best effort */ } + } + + return result; + } + + public async Task BlockDeviceAsync(string tenantId, string serialNumber, int durationHours, + string blockedByEmail, string? reason = null, string action = "Block") + { + var now = DateTime.UtcNow; + var unblockAt = now.AddHours(durationHours); + + var entity = new TableEntity(tenantId, EncodeRowKey(serialNumber)) + { + ["SerialNumber"] = serialNumber, + ["BlockedAt"] = now, + ["UnblockAt"] = unblockAt, + ["BlockedByEmail"] = blockedByEmail ?? string.Empty, + ["DurationHours"] = durationHours, + ["Reason"] = reason ?? string.Empty, + ["Action"] = action ?? "Block" + }; + + await _blockedDevicesTable.UpsertEntityAsync(entity); + await _publisher.PublishAsync("device.blocked", new { tenantId, serialNumber, action, durationHours }, tenantId); + } + + public async Task UnblockDeviceAsync(string tenantId, string serialNumber) + { + try + { + await _blockedDevicesTable.DeleteEntityAsync(tenantId, EncodeRowKey(serialNumber)); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + // Already removed + } + + await _publisher.PublishAsync("device.unblocked", new { tenantId, serialNumber }, tenantId); + } + + // ----------------------------------------------------------------------- + // Blocked Versions + // ----------------------------------------------------------------------- + + public async Task<(bool isBlocked, string action, string? matchedPattern)> IsVersionBlockedAsync(string agentVersion) + { + if (string.IsNullOrEmpty(agentVersion)) + return (false, "Block", null); + + // Load all rules and do version matching + string? matchedAction = null; + string? matchedPattern = null; + + await foreach (var entity in _blockedVersionsTable.QueryAsync(e => e.PartitionKey == "global")) + { + var pattern = entity.GetString("VersionPattern") ?? DecodeRowKey(entity.RowKey); + var action = entity.GetString("Action") ?? "Block"; + + if (VersionMatchesPattern(agentVersion, pattern)) + { + // Kill takes priority over Block + if (matchedAction == null || string.Equals(action, "Kill", StringComparison.OrdinalIgnoreCase)) + { + matchedAction = action; + matchedPattern = pattern; + } + + // If we already found a Kill, no need to check more + if (string.Equals(matchedAction, "Kill", StringComparison.OrdinalIgnoreCase)) + break; + } + } + + return matchedAction != null + ? (true, matchedAction, matchedPattern) + : (false, "Block", null); + } + + public async Task> GetBlockedVersionsAsync() + { + var result = new List(); + + await foreach (var entity in _blockedVersionsTable.QueryAsync(e => e.PartitionKey == "global")) + { + result.Add(new BlockedVersionEntry + { + VersionPattern = entity.GetString("VersionPattern") ?? DecodeRowKey(entity.RowKey), + Action = entity.GetString("Action") ?? "Block", + CreatedByEmail = entity.GetString("CreatedByEmail") ?? string.Empty, + CreatedAt = entity.GetDateTimeOffset("CreatedAt")?.UtcDateTime ?? DateTime.MinValue, + Reason = entity.GetString("Reason") + }); + } + + return result; + } + + public async Task BlockVersionAsync(string versionPattern, string action, string createdByEmail, string? reason = null) + { + var entity = new TableEntity("global", EncodeRowKey(versionPattern)) + { + ["VersionPattern"] = versionPattern, + ["Action"] = action, + ["CreatedByEmail"] = createdByEmail ?? string.Empty, + ["CreatedAt"] = DateTime.UtcNow, + ["Reason"] = reason ?? string.Empty + }; + + await _blockedVersionsTable.UpsertEntityAsync(entity); + await _publisher.PublishAsync("version.blocked", new { versionPattern, action }, null); + } + + public async Task UnblockVersionAsync(string versionPattern) + { + try + { + await _blockedVersionsTable.DeleteEntityAsync("global", EncodeRowKey(versionPattern)); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + // Already removed + } + + await _publisher.PublishAsync("version.unblocked", new { versionPattern }, null); + } + + // ----------------------------------------------------------------------- + // Version Matching Logic (copied from BlockedVersionService) + // ----------------------------------------------------------------------- + + /// + /// Checks if an agent version matches a block pattern. + /// - "1.*" -> agent version starts with "1." (major match) + /// - "1.0.*" -> agent version starts with "1.0." (major.minor match) + /// - "1.0.30" -> agent version parsed as semver, matches if agentVersion <= pattern + /// - "=1.0.30" -> exact match, only version 1.0.30 + /// + internal static bool VersionMatchesPattern(string agentVersion, string pattern) + { + if (string.IsNullOrEmpty(agentVersion) || string.IsNullOrEmpty(pattern)) + return false; + + // Exact match: "=1.0.30" matches only that specific version + if (pattern.StartsWith("=")) + { + var exactVersion = pattern.Substring(1); + if (TryParseVersion(agentVersion, out var agentExact) && TryParseVersion(exactVersion, out var patternExact)) + return CompareVersionParts(agentExact!, patternExact!) == 0; + return string.Equals(agentVersion, exactVersion, StringComparison.OrdinalIgnoreCase); + } + + // Wildcard patterns: prefix match + if (pattern.EndsWith(".*")) + { + var prefix = pattern.Substring(0, pattern.Length - 1); // "1." or "1.0." + return agentVersion.StartsWith(prefix, StringComparison.OrdinalIgnoreCase); + } + + // Version ceiling: <= comparison + if (TryParseVersion(agentVersion, out var agentParts) && TryParseVersion(pattern, out var patternParts)) + { + return CompareVersionParts(agentParts!, patternParts!) <= 0; + } + + // Fallback: exact string match + return string.Equals(agentVersion, pattern, StringComparison.OrdinalIgnoreCase); + } + + private static bool TryParseVersion(string version, out int[]? parts) + { + parts = null; + if (string.IsNullOrEmpty(version)) return false; + + var dashIndex = version.IndexOf('-'); + if (dashIndex >= 0) version = version.Substring(0, dashIndex); + var plusIndex = version.IndexOf('+'); + if (plusIndex >= 0) version = version.Substring(0, plusIndex); + + var segments = version.Split('.'); + var parsed = new List(); + + foreach (var seg in segments) + { + if (int.TryParse(seg, out var n)) + parsed.Add(n); + else + return false; + } + + if (parsed.Count == 0) return false; + parts = parsed.ToArray(); + return true; + } + + private static int CompareVersionParts(int[] a, int[] b) + { + var maxLen = Math.Max(a.Length, b.Length); + for (int i = 0; i < maxLen; i++) + { + var av = i < a.Length ? a[i] : 0; + var bv = i < b.Length ? b[i] : 0; + if (av != bv) return av.CompareTo(bv); + } + return 0; + } + + // ----------------------------------------------------------------------- + // Private helpers + // ----------------------------------------------------------------------- + + private static BlockedDeviceEntry MapToBlockedDeviceEntry(TableEntity entity, string tenantId, DateTime now) + { + return new BlockedDeviceEntry + { + TenantId = tenantId, + SerialNumber = entity.GetString("SerialNumber") ?? DecodeRowKey(entity.RowKey), + BlockedAt = entity.GetDateTimeOffset("BlockedAt")?.UtcDateTime ?? now, + UnblockAt = entity.GetDateTimeOffset("UnblockAt")?.UtcDateTime ?? DateTime.MinValue, + BlockedByEmail = entity.GetString("BlockedByEmail"), + DurationHours = entity.GetInt32("DurationHours") ?? 12, + Reason = entity.GetString("Reason"), + Action = entity.GetString("Action") ?? "Block" + }; + } + + private async Task CleanupExpiredDeviceEntriesAsync(string tenantId, List rowKeys) + { + foreach (var rowKey in rowKeys) + { + try { await _blockedDevicesTable.DeleteEntityAsync(tenantId, rowKey); } + catch { /* best effort */ } + } + } + + /// Azure Table RowKey must not contain /\#? and must be <= 1KB. URL-encode to be safe. + internal static string EncodeRowKey(string value) + => Uri.EscapeDataString(value); + + internal static string DecodeRowKey(string encoded) + => Uri.UnescapeDataString(encoded); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs new file mode 100644 index 00000000..3bd54a8f --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMaintenanceRepository.cs @@ -0,0 +1,245 @@ +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Functions.Services; +using Azure.Data.Tables; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.DataAccess.TableStorage +{ + /// + /// Table Storage implementation of IMaintenanceRepository. + /// Delegates to existing TableStorageService for backwards compatibility. + /// + public class TableMaintenanceRepository : IMaintenanceRepository + { + private readonly TableStorageService _storage; + private readonly ILogger _logger; + + public TableMaintenanceRepository(TableStorageService storage, ILogger logger) + { + _storage = storage; + _logger = logger; + } + + public Task LogAuditEntryAsync(string tenantId, string action, string entityType, + string entityId, string performedBy, Dictionary? details = null) + => _storage.LogAuditEntryAsync(tenantId, action, entityType, entityId, performedBy, details); + + public Task> GetAuditLogsAsync(string tenantId, int maxResults = 100) + => _storage.GetAuditLogsAsync(tenantId, maxResults); + + public Task> GetAllAuditLogsAsync(int maxResults = 100) + => _storage.GetAllAuditLogsAsync(maxResults); + + public Task> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate) + => _storage.GetSessionsOlderThanAsync(tenantId, cutoffDate); + + public Task> GetSessionsByDateRangeAsync(DateTime startDate, DateTime endDate, string? tenantId = null) + => _storage.GetSessionsByDateRangeAsync(startDate, endDate, tenantId); + + public Task> GetStalledSessionsAsync(string tenantId, DateTime cutoffTime) + => _storage.GetStalledSessionsAsync(tenantId, cutoffTime); + + public Task> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff, int maxSessionWindowHours) + => _storage.GetExcessiveDataSendersAsync(tenantId, windowCutoff, maxSessionWindowHours); + + public Task> GetAllTenantIdsAsync() + => _storage.GetAllTenantIdsAsync(); + + public Task DeleteSessionEventsAsync(string tenantId, string sessionId) + => _storage.DeleteSessionEventsAsync(tenantId, sessionId); + + public Task DeleteSessionRuleResultsAsync(string tenantId, string sessionId) + => _storage.DeleteSessionRuleResultsAsync(tenantId, sessionId); + + public Task DeleteSessionAppInstallSummariesAsync(string tenantId, string sessionId) + => _storage.DeleteSessionAppInstallSummariesAsync(tenantId, sessionId); + + public Task BackfillSessionIndexAsync() + => _storage.BackfillSessionIndexAsync(); + + public Task CleanupGhostSessionIndexEntriesAsync() + => _storage.CleanupGhostSessionIndexEntriesAsync(); + + public Task IsSessionIndexEmptyAsync() + => _storage.IsSessionIndexEmptyAsync(); + + // --- Tenant Offboarding --- + + public async Task> DeleteAllTenantDataAsync(string tenantId) + { + var deletedCounts = new Dictionary(); + var normalizedTenantId = tenantId.ToLowerInvariant(); + + // Tables where PartitionKey = tenantId (normalized lowercase) + var tenantPartitionTables = new[] + { + Constants.TableNames.Sessions, + Constants.TableNames.SessionsIndex, + Constants.TableNames.AuditLogs, + Constants.TableNames.UsageMetrics, + Constants.TableNames.UserActivity, + Constants.TableNames.GatherRules, + Constants.TableNames.AnalyzeRules, + Constants.TableNames.AppInstallSummaries, + Constants.TableNames.TenantConfiguration, + Constants.TableNames.TenantAdmins, + Constants.TableNames.BootstrapSessions, + Constants.TableNames.BlockedDevices, + Constants.TableNames.ImeLogPatterns, + Constants.TableNames.RuleStates, + }; + + foreach (var tableName in tenantPartitionTables) + { + var deleted = await DeleteAllRowsByPartitionKeyAsync(tableName, normalizedTenantId); + deletedCounts[tableName] = deleted; + _logger.LogInformation("Offboard [{TenantId}] {TableName}: deleted {Deleted} rows", tenantId, tableName, deleted); + } + + // Tables with composite PartitionKey = "{tenantId}_{sessionId}" – query by prefix + var eventsDeleted = await DeleteByTenantPrefixAsync(Constants.TableNames.Events, normalizedTenantId); + deletedCounts[Constants.TableNames.Events] = eventsDeleted; + _logger.LogInformation("Offboard [{TenantId}] Events: deleted {Deleted} rows", tenantId, eventsDeleted); + + var ruleResultsDeleted = await DeleteByTenantPrefixAsync(Constants.TableNames.RuleResults, normalizedTenantId); + deletedCounts[Constants.TableNames.RuleResults] = ruleResultsDeleted; + _logger.LogInformation("Offboard [{TenantId}] RuleResults: deleted {Deleted} rows", tenantId, ruleResultsDeleted); + + // BootstrapSessions CodeLookup entries (PartitionKey = "CodeLookup", TenantId property = tenantId) + var codeLookupDeleted = await DeleteCodeLookupEntriesAsync(normalizedTenantId); + deletedCounts["BootstrapSessions_CodeLookup"] = codeLookupDeleted; + _logger.LogInformation("Offboard [{TenantId}] BootstrapSessions CodeLookup: deleted {Deleted} rows", tenantId, codeLookupDeleted); + + return deletedCounts; + } + + /// + /// Deletes all rows in a table where PartitionKey equals the given value. + /// Uses batched deletes (max 100 per transaction, same PartitionKey required). + /// + private async Task DeleteAllRowsByPartitionKeyAsync(string tableName, string partitionKey) + { + try + { + var tableClient = _storage.GetTableClient(tableName); + var filter = $"PartitionKey eq '{partitionKey}'"; + var entities = tableClient.QueryAsync(filter, select: new[] { "PartitionKey", "RowKey" }); + + int deleted = 0; + var batch = new List(); + + await foreach (var entity in entities) + { + batch.Add(new TableTransactionAction(TableTransactionActionType.Delete, entity)); + deleted++; + + if (batch.Count >= 100) + { + await tableClient.SubmitTransactionAsync(batch); + batch.Clear(); + } + } + + if (batch.Count > 0) + { + await tableClient.SubmitTransactionAsync(batch); + } + + return deleted; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete rows in {TableName} for partition {PartitionKey}", tableName, partitionKey); + return 0; + } + } + + /// + /// Deletes all rows for a tenant from a table that uses composite PartitionKey = "{tenantId}_{sessionId}". + /// Used for Events and RuleResults tables. + /// + private async Task DeleteByTenantPrefixAsync(string tableName, string normalizedTenantId) + { + try + { + var tableClient = _storage.GetTableClient(tableName); + // OData startsWith: PartitionKey ge 'prefix' and PartitionKey lt 'prefix~' + var prefix = normalizedTenantId + "_"; + var filter = $"PartitionKey ge '{prefix}' and PartitionKey lt '{prefix}~'"; + var entities = tableClient.QueryAsync(filter, select: new[] { "PartitionKey", "RowKey" }); + + int deleted = 0; + // Group by PartitionKey because Azure Table batch transactions require same PartitionKey + var groups = new Dictionary>(); + + await foreach (var entity in entities) + { + if (!groups.ContainsKey(entity.PartitionKey)) + groups[entity.PartitionKey] = new List(); + groups[entity.PartitionKey].Add(entity); + deleted++; + } + + foreach (var group in groups.Values) + { + for (int i = 0; i < group.Count; i += 100) + { + var chunk = group.Skip(i).Take(100).ToList(); + var batch = chunk.Select(e => new TableTransactionAction(TableTransactionActionType.Delete, e)).ToList(); + await tableClient.SubmitTransactionAsync(batch); + } + } + + return deleted; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete {TableName} for tenant {TenantId}", tableName, normalizedTenantId); + return 0; + } + } + + /// + /// Deletes BootstrapSessions CodeLookup entries for a tenant. + /// These use PartitionKey = "CodeLookup" with a TenantId property matching the tenant. + /// + private async Task DeleteCodeLookupEntriesAsync(string normalizedTenantId) + { + try + { + var tableClient = _storage.GetTableClient(Constants.TableNames.BootstrapSessions); + var filter = $"PartitionKey eq 'CodeLookup' and TenantId eq '{normalizedTenantId}'"; + var entities = tableClient.QueryAsync(filter, select: new[] { "PartitionKey", "RowKey" }); + + int deleted = 0; + var batch = new List(); + + await foreach (var entity in entities) + { + batch.Add(new TableTransactionAction(TableTransactionActionType.Delete, entity)); + deleted++; + + if (batch.Count >= 100) + { + await tableClient.SubmitTransactionAsync(batch); + batch.Clear(); + } + } + + if (batch.Count > 0) + { + await tableClient.SubmitTransactionAsync(batch); + } + + return deleted; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to delete CodeLookup entries for tenant {TenantId}", normalizedTenantId); + return 0; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs new file mode 100644 index 00000000..63d2943f --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableMetricsRepository.cs @@ -0,0 +1,65 @@ +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Functions.Services; + +namespace AutopilotMonitor.Functions.DataAccess.TableStorage +{ + /// + /// Table Storage implementation of IMetricsRepository. + /// Delegates to existing TableStorageService for backwards compatibility. + /// + public class TableMetricsRepository : IMetricsRepository + { + private readonly TableStorageService _storage; + private readonly IDataEventPublisher _publisher; + + public TableMetricsRepository(TableStorageService storage, IDataEventPublisher publisher) + { + _storage = storage; + _publisher = publisher; + } + + public Task SaveUsageMetricsSnapshotAsync(UsageMetricsSnapshot metrics) + => _storage.SaveUsageMetricsSnapshotAsync(metrics); + + public Task> GetUsageMetricsSnapshotAsync( + string? tenantId = null, string? startDate = null, string? endDate = null, int maxResults = 100) + => _storage.GetUsageMetricsSnapshotAsync(tenantId, startDate, endDate, maxResults); + + public Task HasUsageMetricsSnapshotAsync(string date) + => _storage.HasUsageMetricsSnapshotAsync(date); + + public Task StoreAppInstallSummaryAsync(AppInstallSummary summary) + => _storage.StoreAppInstallSummaryAsync(summary); + + public Task> GetAppInstallSummariesByTenantAsync(string tenantId) + => _storage.GetAppInstallSummariesByTenantAsync(tenantId); + + public Task> GetAllAppInstallSummariesAsync() + => _storage.GetAllAppInstallSummariesAsync(); + + public Task GetPlatformStatsAsync() + => _storage.GetPlatformStatsAsync(); + + public Task SavePlatformStatsAsync(PlatformStats stats) + => _storage.SavePlatformStatsAsync(stats); + + public Task IncrementPlatformStatAsync(string field, long amount = 1) + => _storage.IncrementPlatformStatAsync(field, amount); + + public Task RecordUserLoginAsync(string tenantId, string upn, string? displayName, string? objectId) + => _storage.RecordUserLoginAsync(tenantId, upn, displayName, objectId); + + public Task GetUserActivityMetricsAsync(string tenantId) + => _storage.GetUserActivityMetricsAsync(tenantId); + + public Task GetAllUserActivityMetricsAsync() + => _storage.GetAllUserActivityMetricsAsync(); + + public Task<(int uniqueUsers, int loginCount)> GetUserActivityForDateAsync(string? tenantId, DateTime date) + => _storage.GetUserActivityForDateAsync(tenantId, date); + + public Task> GetMetricsSummaryAsync(string? tenantId) + => _storage.GetMetricsSummaryAsync(tenantId); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableNotificationRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableNotificationRepository.cs new file mode 100644 index 00000000..42156953 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableNotificationRepository.cs @@ -0,0 +1,292 @@ +using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using Azure; +using Azure.Data.Tables; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.DataAccess.TableStorage +{ + /// + /// Table Storage implementation of INotificationRepository. + /// Manages GlobalNotifications and SessionReports tables. + /// Uses inverted-tick RowKeys to sort newest-first. + /// + public class TableNotificationRepository : INotificationRepository + { + private readonly TableClient _notificationsTableClient; + private readonly TableClient _reportsTableClient; + private readonly ILogger _logger; + + public TableNotificationRepository( + TableStorageService storage, + ILogger logger) + { + _logger = logger; + _notificationsTableClient = storage.GetTableClient(Constants.TableNames.GlobalNotifications); + _reportsTableClient = storage.GetTableClient(Constants.TableNames.SessionReports); + } + + // --- Global Notifications --- + + public async Task AddNotificationAsync(GlobalNotification notification) + { + try + { + var invertedTicks = (DateTime.MaxValue.Ticks - notification.CreatedAt.Ticks).ToString("D19"); + var notificationId = notification.NotificationId; + if (string.IsNullOrEmpty(notificationId)) + notificationId = Guid.NewGuid().ToString("N")[..12]; + + var entity = new TableEntity("notifications", $"{invertedTicks}_{notificationId}") + { + ["NotificationId"] = notificationId, + ["Type"] = notification.Type ?? "info", + ["Title"] = notification.Title ?? string.Empty, + ["Message"] = notification.Message ?? string.Empty, + ["Href"] = notification.Href, + ["CreatedAt"] = notification.CreatedAt, + ["CreatedBy"] = notification.CreatedBy ?? string.Empty, + ["Dismissed"] = notification.IsDismissed + }; + + await _notificationsTableClient.UpsertEntityAsync(entity); + _logger.LogInformation("Global notification stored: {NotificationId} ({Type})", notificationId, notification.Type); + return true; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to store global notification ({Type}: {Title})", notification.Type, notification.Title); + return false; + } + } + + public async Task> GetNotificationsAsync(int maxResults = 50) + { + try + { + var notifications = new List(); + var count = 0; + + await foreach (var entity in _notificationsTableClient.QueryAsync( + filter: $"PartitionKey eq 'notifications'")) + { + var dismissed = entity.GetBoolean("Dismissed") ?? false; + if (dismissed) continue; + + notifications.Add(MapToGlobalNotification(entity)); + count++; + if (count >= maxResults) break; + } + + return notifications; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + _logger.LogWarning("GlobalNotifications table not found — returning empty list"); + return new List(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to retrieve global notifications"); + return new List(); + } + } + + public async Task DismissNotificationAsync(string notificationId, string dismissedBy) + { + try + { + await foreach (var entity in _notificationsTableClient.QueryAsync( + filter: $"PartitionKey eq 'notifications'")) + { + if (entity.GetString("NotificationId") == notificationId) + { + entity["Dismissed"] = true; + entity["DismissedBy"] = dismissedBy; + entity["DismissedAt"] = DateTime.UtcNow; + await _notificationsTableClient.UpdateEntityAsync(entity, entity.ETag, TableUpdateMode.Replace); + _logger.LogInformation("Global notification dismissed: {NotificationId}", notificationId); + return true; + } + } + + return false; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to dismiss global notification {NotificationId}", notificationId); + return false; + } + } + + public async Task DismissAllNotificationsAsync() + { + try + { + var count = 0; + + await foreach (var entity in _notificationsTableClient.QueryAsync( + filter: $"PartitionKey eq 'notifications'")) + { + var dismissed = entity.GetBoolean("Dismissed") ?? false; + if (dismissed) continue; + + entity["Dismissed"] = true; + entity["DismissedAt"] = DateTime.UtcNow; + await _notificationsTableClient.UpdateEntityAsync(entity, entity.ETag, TableUpdateMode.Replace); + count++; + } + + _logger.LogInformation("Dismissed {Count} global notifications", count); + return count; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to dismiss all global notifications"); + return 0; + } + } + + // --- Session Reports --- + + public async Task StoreSessionReportMetadataAsync(SessionReportMetadata metadata) + { + try + { + var invertedTicks = (DateTime.MaxValue.Ticks - metadata.SubmittedAt.Ticks).ToString("D19"); + var entity = new TableEntity("reports", $"{invertedTicks}_{metadata.ReportId}") + { + ["ReportId"] = metadata.ReportId ?? string.Empty, + ["TenantId"] = metadata.TenantId ?? string.Empty, + ["SessionId"] = metadata.SessionId ?? string.Empty, + ["Comment"] = metadata.Comment ?? string.Empty, + ["Email"] = metadata.Email ?? string.Empty, + ["BlobName"] = metadata.BlobName ?? string.Empty, + ["SubmittedBy"] = metadata.SubmittedBy ?? string.Empty, + ["SubmittedAt"] = metadata.SubmittedAt + }; + + await _reportsTableClient.UpsertEntityAsync(entity); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to store session report metadata {ReportId}", metadata.ReportId); + return false; + } + } + + public async Task> GetSessionReportsAsync(string? tenantId = null, int maxResults = 50) + { + var results = new List(); + + try + { + var count = 0; + await foreach (var entity in _reportsTableClient.QueryAsync( + filter: $"PartitionKey eq 'reports'")) + { + var report = MapToSessionReportMetadata(entity); + + // Filter by tenantId if specified + if (tenantId != null && report.TenantId != tenantId) + continue; + + results.Add(report); + count++; + if (count >= maxResults) break; + } + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + _logger.LogDebug("SessionReports table does not exist yet, returning empty list"); + } + + return results; + } + + public async Task GetSessionReportAsync(string reportId) + { + try + { + await foreach (var entity in _reportsTableClient.QueryAsync( + filter: $"PartitionKey eq 'reports'")) + { + if (entity.GetString("ReportId") == reportId) + { + return MapToSessionReportMetadata(entity); + } + } + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + _logger.LogDebug("SessionReports table does not exist yet"); + } + + return null; + } + + public async Task UpdateSessionReportAdminNoteAsync(string reportId, string adminNote) + { + TableEntity? found = null; + await foreach (var entity in _reportsTableClient.QueryAsync( + filter: $"PartitionKey eq 'reports'")) + { + if (entity.GetString("ReportId") == reportId) + { + found = entity; + break; + } + } + + if (found == null) + { + _logger.LogWarning("UpdateAdminNote: report {ReportId} not found", reportId); + return false; + } + + found["AdminNote"] = adminNote ?? string.Empty; + await _reportsTableClient.UpsertEntityAsync(found, TableUpdateMode.Merge); + + _logger.LogInformation("Updated AdminNote for report {ReportId}", reportId); + return true; + } + + // --- Helpers --- + + private static GlobalNotification MapToGlobalNotification(TableEntity entity) + { + return new GlobalNotification + { + NotificationId = entity.GetString("NotificationId") ?? string.Empty, + Type = entity.GetString("Type") ?? "info", + Title = entity.GetString("Title") ?? string.Empty, + Message = entity.GetString("Message") ?? string.Empty, + Href = entity.GetString("Href"), + CreatedAt = entity.GetDateTimeOffset("CreatedAt")?.UtcDateTime ?? DateTime.MinValue, + CreatedBy = entity.GetString("CreatedBy") ?? string.Empty, + IsDismissed = entity.GetBoolean("Dismissed") ?? false, + DismissedBy = entity.GetString("DismissedBy"), + DismissedAt = entity.GetDateTimeOffset("DismissedAt")?.UtcDateTime + }; + } + + private static SessionReportMetadata MapToSessionReportMetadata(TableEntity entity) + { + return new SessionReportMetadata + { + ReportId = entity.GetString("ReportId") ?? string.Empty, + TenantId = entity.GetString("TenantId") ?? string.Empty, + SessionId = entity.GetString("SessionId") ?? string.Empty, + Comment = entity.GetString("Comment") ?? string.Empty, + Email = entity.GetString("Email") ?? string.Empty, + BlobName = entity.GetString("BlobName") ?? string.Empty, + SubmittedBy = entity.GetString("SubmittedBy") ?? string.Empty, + SubmittedAt = entity.GetDateTimeOffset("SubmittedAt")?.UtcDateTime ?? DateTime.MinValue, + AdminNote = entity.GetString("AdminNote") ?? string.Empty + }; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableRuleRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableRuleRepository.cs new file mode 100644 index 00000000..1e885547 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableRuleRepository.cs @@ -0,0 +1,69 @@ +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Functions.Services; + +namespace AutopilotMonitor.Functions.DataAccess.TableStorage +{ + /// + /// Table Storage implementation of IRuleRepository. + /// Delegates to existing TableStorageService for backwards compatibility. + /// + public class TableRuleRepository : IRuleRepository + { + private readonly TableStorageService _storage; + private readonly IDataEventPublisher _publisher; + + public TableRuleRepository(TableStorageService storage, IDataEventPublisher publisher) + { + _storage = storage; + _publisher = publisher; + } + + public async Task StoreRuleResultAsync(RuleResult result) + { + var success = await _storage.StoreRuleResultAsync(result); + if (success) + await _publisher.PublishAsync("rule.evaluated", result, result.TenantId); + return success; + } + + public Task> GetRuleResultsAsync(string tenantId, string sessionId) + => _storage.GetRuleResultsAsync(tenantId, sessionId); + + public Task StoreGatherRuleAsync(GatherRule rule, string tenantId = "global") + => _storage.StoreGatherRuleAsync(rule, tenantId); + + public Task> GetGatherRulesAsync(string partitionKey) + => _storage.GetGatherRulesAsync(partitionKey); + + public Task DeleteGatherRuleAsync(string tenantId, string ruleId) + => _storage.DeleteGatherRuleAsync(tenantId, ruleId); + + public Task StoreRuleStateAsync(string tenantId, string ruleId, bool enabled) + => _storage.StoreRuleStateAsync(tenantId, ruleId, enabled); + + public Task> GetRuleStatesAsync(string tenantId) + => _storage.GetRuleStatesAsync(tenantId); + + public Task DeleteRuleStateAsync(string tenantId, string ruleId) + => _storage.DeleteRuleStateAsync(tenantId, ruleId); + + public Task StoreAnalyzeRuleAsync(AnalyzeRule rule, string tenantId = "global") + => _storage.StoreAnalyzeRuleAsync(rule, tenantId); + + public Task> GetAnalyzeRulesAsync(string partitionKey) + => _storage.GetAnalyzeRulesAsync(partitionKey); + + public Task DeleteAnalyzeRuleAsync(string tenantId, string ruleId) + => _storage.DeleteAnalyzeRuleAsync(tenantId, ruleId); + + public Task StoreImeLogPatternAsync(ImeLogPattern pattern, string tenantId = "global") + => _storage.StoreImeLogPatternAsync(pattern, tenantId); + + public Task> GetImeLogPatternsAsync(string partitionKey) + => _storage.GetImeLogPatternsAsync(partitionKey); + + public Task DeleteImeLogPatternAsync(string tenantId, string patternId) + => _storage.DeleteImeLogPatternAsync(tenantId, patternId); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs new file mode 100644 index 00000000..4c53a597 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionRepository.cs @@ -0,0 +1,113 @@ +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Shared.Models; +using AutopilotMonitor.Functions.Services; + +namespace AutopilotMonitor.Functions.DataAccess.TableStorage +{ + /// + /// Table Storage implementation of ISessionRepository. + /// Delegates to existing TableStorageService for backwards compatibility. + /// + public class TableSessionRepository : ISessionRepository + { + private readonly TableStorageService _storage; + private readonly IDataEventPublisher _publisher; + + public TableSessionRepository(TableStorageService storage, IDataEventPublisher publisher) + { + _storage = storage; + _publisher = publisher; + } + + public async Task StoreSessionAsync(SessionRegistration registration) + { + var result = await _storage.StoreSessionAsync(registration); + if (result) + await _publisher.PublishAsync("session.created", registration, registration.TenantId); + return result; + } + + public Task GetSessionAsync(string tenantId, string sessionId) + => _storage.GetSessionAsync(tenantId, sessionId); + + public Task GetSessionsAsync(string tenantId, int maxResults = 100, string? cursor = null) + => _storage.GetSessionsAsync(tenantId, maxResults, cursor); + + public Task GetAllSessionsAsync(int maxResults = 100, string? cursor = null) + => _storage.GetAllSessionsAsync(maxResults, cursor); + + public Task DeleteSessionAsync(string tenantId, string sessionId) + => _storage.DeleteSessionAsync(tenantId, sessionId); + + public Task 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) + => _storage.UpdateSessionStatusAsync(tenantId, sessionId, status, + currentPhase, failureReason, completedAt, earliestEventTimestamp, + latestEventTimestamp, isPreProvisioned, isUserDriven, resumedAt); + + public Task IncrementSessionEventCountAsync( + string tenantId, string sessionId, int increment, + DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null, + EnrollmentPhase? currentPhase = null) + => _storage.IncrementSessionEventCountAsync(tenantId, sessionId, increment, + earliestEventTimestamp, latestEventTimestamp, currentPhase); + + public Task UpdateSessionDiagnosticsBlobAsync(string tenantId, string sessionId, string blobName) + => _storage.UpdateSessionDiagnosticsBlobAsync(tenantId, sessionId, blobName); + + public Task SetSessionPreProvisionedAsync(string tenantId, string sessionId, bool isPreProvisioned, + SessionStatus? status = null, bool? isUserDriven = null) + => _storage.SetSessionPreProvisionedAsync(tenantId, sessionId, isPreProvisioned, status, isUserDriven); + + public Task UpdateSessionGeoAsync(string tenantId, string sessionId, + string? country, string? region, string? city, string? loc) + => _storage.UpdateSessionGeoAsync(tenantId, sessionId, country, region, city, loc); + + public Task UpdateSessionImeAgentVersionAsync(string tenantId, string sessionId, string version) + => _storage.UpdateSessionImeAgentVersionAsync(tenantId, sessionId, version); + + public async Task StoreEventAsync(EnrollmentEvent evt) + { + var result = await _storage.StoreEventAsync(evt); + if (result) + await _publisher.PublishAsync("event.ingested", evt, evt.TenantId); + return result; + } + + public async Task> StoreEventsBatchAsync(List events) + { + var result = await _storage.StoreEventsBatchAsync(events); + if (result.Count > 0 && events.Count > 0) + await _publisher.PublishAsync("events.ingested", new { count = result.Count }, events[0].TenantId); + return result; + } + + public Task> GetSessionEventsAsync(string tenantId, string sessionId, int maxResults = 1000) + => _storage.GetSessionEventsAsync(tenantId, sessionId, maxResults); + + public Task> SearchSessionsAsync(string? tenantId, SessionSearchFilter filter) + => _storage.SearchSessionsAsync(tenantId, filter); + + public Task> SearchSessionsByEventAsync( + string? tenantId, string eventType, string? source, string? severity, + string? phase, int limit = 50) + => _storage.SearchSessionsByEventAsync(tenantId, eventType, source, severity, phase, limit); + + public Task> SearchSessionsByCveAsync( + string? tenantId, string cveId, double? minCvssScore, string? overallRisk, int limit = 50) + => _storage.SearchSessionsByCveAsync(tenantId, cveId, minCvssScore, overallRisk, limit); + + public Task UpsertEventTypeIndexBatchAsync(string tenantId, string sessionId, IEnumerable eventTypes) + => _storage.UpsertEventTypeIndexBatchAsync(tenantId, sessionId, eventTypes); + + public Task UpsertDeviceSnapshotAsync(string tenantId, string sessionId, IEnumerable events) + => _storage.UpsertDeviceSnapshotAsync(tenantId, sessionId, events); + + public Task UpsertCveIndexEntriesAsync(string tenantId, string sessionId, List> findings) + => _storage.UpsertCveIndexEntriesAsync(tenantId, sessionId, findings); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableStorageInitializer.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableStorageInitializer.cs new file mode 100644 index 00000000..107e8e59 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableStorageInitializer.cs @@ -0,0 +1,22 @@ +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Functions.Services; + +namespace AutopilotMonitor.Functions.DataAccess.TableStorage +{ + /// + /// Table Storage implementation of IStorageInitializer. + /// Delegates to existing TableStorageService for backwards compatibility. + /// + public class TableStorageInitializer : IStorageInitializer + { + private readonly TableStorageService _storage; + + public TableStorageInitializer(TableStorageService storage) + { + _storage = storage; + } + + public Task InitializeAsync() + => _storage.InitializeTablesAsync(); + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableVulnerabilityRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableVulnerabilityRepository.cs new file mode 100644 index 00000000..da092dc8 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableVulnerabilityRepository.cs @@ -0,0 +1,211 @@ +using Azure; +using Azure.Data.Tables; +using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; +using AutopilotMonitor.Functions.Services; +using Microsoft.Extensions.Logging; + +namespace AutopilotMonitor.Functions.DataAccess.TableStorage +{ + /// + /// Table Storage implementation of IVulnerabilityRepository. + /// Delegates to existing TableStorageService where possible, + /// implements direct table access for CPE/CVE cache operations. + /// + public class TableVulnerabilityRepository : IVulnerabilityRepository + { + private readonly TableStorageService _storage; + private readonly IDataEventPublisher _publisher; + private readonly ILogger _logger; + + public TableVulnerabilityRepository(TableStorageService storage, IDataEventPublisher publisher, ILogger logger) + { + _storage = storage; + _publisher = publisher; + _logger = logger; + } + + // --- Vulnerability Reports (delegate to existing service) --- + + public async Task StoreVulnerabilityReportAsync(string tenantId, string sessionId, Dictionary reportData) + { + await _storage.StoreVulnerabilityReportAsync(tenantId, sessionId, reportData); + await _publisher.PublishAsync("vulnerability.reported", new { tenantId, sessionId, reportData }, tenantId); + } + + public Task?> GetVulnerabilityReportAsync(string tenantId, string sessionId) + => _storage.GetVulnerabilityReportAsync(tenantId, sessionId); + + // --- Software Inventory --- + + public Task UpsertSoftwareInventoryAsync(string tenantId, List> inventoryItems, + string sessionId, Dictionary? cpeMappings = null) + => _storage.UpsertSoftwareInventoryAsync(tenantId, inventoryItems, sessionId, cpeMappings); + + public Task>> GetSoftwareInventoryAsync(string tenantId) + => _storage.GetSoftwareInventoryAsync(tenantId); + + public async Task>> GetAllSoftwareInventoryAsync() + { + var tableClient = _storage.GetTableClient(Constants.TableNames.SoftwareInventory); + var results = new List>(); + await foreach (var entity in tableClient.QueryAsync(maxPerPage: 1000)) + { + var item = new Dictionary(); + foreach (var prop in entity) + item[prop.Key] = prop.Value; + item["PartitionKey"] = entity.PartitionKey; + item["RowKey"] = entity.RowKey; + results.Add(item); + } + return results; + } + + public async Task UpdateSoftwareInventoryCpeAsync(string normalizedName, string cpeUri) + { + var tableClient = _storage.GetTableClient(Constants.TableNames.SoftwareInventory); + await foreach (var entity in tableClient.QueryAsync(maxPerPage: 1000)) + { + var name = entity.GetString("NormalizedName") ?? string.Empty; + if (string.Equals(name, normalizedName, StringComparison.OrdinalIgnoreCase)) + { + entity["CpeUri"] = cpeUri; + await tableClient.UpsertEntityAsync(entity); + } + } + } + + // --- CPE Mappings --- + + public Task ImportCpeMappingSeedFromJsonAsync(string json) + => _storage.ImportCpeMappingSeedFromJsonAsync(json); + + public Task ImportCpeCommunityMappingsFromJsonAsync(string json) + => _storage.ImportCpeCommunityMappingsFromJsonAsync(json); + + public async Task>> GetAllCpeMappingsAsync() + { + var tableClient = _storage.GetTableClient(Constants.TableNames.VulnerabilityCache); + var results = new List>(); + await foreach (var entity in tableClient.QueryAsync( + filter: "PartitionKey ge 'cpe_map' and PartitionKey lt 'cpe_mar'", maxPerPage: 1000)) + { + var item = new Dictionary(); + foreach (var prop in entity) + item[prop.Key] = prop.Value; + item["PartitionKey"] = entity.PartitionKey; + item["RowKey"] = entity.RowKey; + results.Add(item); + } + return results; + } + + public async Task>> GetCpeMappingsByPartitionAsync(string partitionKey) + { + var tableClient = _storage.GetTableClient(Constants.TableNames.VulnerabilityCache); + var results = new List>(); + await foreach (var entity in tableClient.QueryAsync( + filter: $"PartitionKey eq '{partitionKey}'")) + { + var item = new Dictionary(); + foreach (var prop in entity) + item[prop.Key] = prop.Value; + item["PartitionKey"] = entity.PartitionKey; + item["RowKey"] = entity.RowKey; + results.Add(item); + } + return results; + } + + public async Task UpsertCpeMappingAsync(string partitionKey, string rowKey, Dictionary properties) + { + var tableClient = _storage.GetTableClient(Constants.TableNames.VulnerabilityCache); + var entity = new TableEntity(partitionKey, rowKey); + foreach (var kv in properties) + entity[kv.Key] = kv.Value; + await tableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace); + } + + public async Task DeleteCpeMappingAsync(string partitionKey, string rowKey) + { + var tableClient = _storage.GetTableClient(Constants.TableNames.VulnerabilityCache); + try + { + await tableClient.DeleteEntityAsync(partitionKey, rowKey); + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + // Already deleted + } + } + + public async Task DeleteCpeMappingsByPartitionAsync(string partitionKey) + { + var tableClient = _storage.GetTableClient(Constants.TableNames.VulnerabilityCache); + var count = 0; + await foreach (var entity in tableClient.QueryAsync( + filter: $"PartitionKey eq '{partitionKey}'")) + { + try + { + await tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey); + count++; + } + catch (RequestFailedException ex) when (ex.Status == 404) { } + } + return count; + } + + // --- CVE Cache --- + + public async Task?> GetCveCacheEntryAsync(string partitionKey, string rowKey) + { + var tableClient = _storage.GetTableClient(Constants.TableNames.VulnerabilityCache); + try + { + var response = await tableClient.GetEntityAsync(partitionKey, rowKey); + var entity = response?.Value; + if (entity == null) return null; + + var item = new Dictionary(); + foreach (var prop in entity) + item[prop.Key] = prop.Value; + item["PartitionKey"] = entity.PartitionKey; + item["RowKey"] = entity.RowKey; + return item; + } + catch (RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + } + + public async Task>> GetStaleCveCacheEntriesAsync() + { + var tableClient = _storage.GetTableClient(Constants.TableNames.VulnerabilityCache); + var results = new List>(); + await foreach (var entity in tableClient.QueryAsync( + filter: "RowKey eq 'cache_result'", + select: new[] { "PartitionKey", "RowKey", "CachedAt", "CveId", "CpeUri" })) + { + if (!entity.PartitionKey.StartsWith("cve_")) continue; + var item = new Dictionary(); + foreach (var prop in entity) + item[prop.Key] = prop.Value; + item["PartitionKey"] = entity.PartitionKey; + item["RowKey"] = entity.RowKey; + results.Add(item); + } + return results; + } + + public async Task UpsertCveCacheEntryAsync(string partitionKey, string rowKey, Dictionary data) + { + var tableClient = _storage.GetTableClient(Constants.TableNames.VulnerabilityCache); + var entity = new TableEntity(partitionKey, rowKey); + foreach (var kv in data) + entity[kv.Key] = kv.Value; + await tableClient.UpsertEntityAsync(entity); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DeviceBlockFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DeviceBlockFunction.cs index 831e1eae..657cb9c9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DeviceBlockFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/DeviceBlockFunction.cs @@ -3,6 +3,7 @@ using System.Net; using System.Threading.Tasks; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -15,16 +16,16 @@ namespace AutopilotMonitor.Functions.Functions.Admin { private readonly ILogger _logger; private readonly BlockedDeviceService _blockedDeviceService; - private readonly TableStorageService _storageService; + private readonly IMaintenanceRepository _maintenanceRepo; public DeviceBlockFunction( ILogger logger, BlockedDeviceService blockedDeviceService, - TableStorageService storageService) + IMaintenanceRepository maintenanceRepo) { _logger = logger; _blockedDeviceService = blockedDeviceService; - _storageService = storageService; + _maintenanceRepo = maintenanceRepo; } /// GET /api/devices/blocked?tenantId={tenantId} — list all active blocks for a tenant @@ -94,7 +95,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin await _blockedDeviceService.BlockDeviceAsync(tenantId, serialNumber, durationHours, userIdentifier, reason, action); - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( tenantId, "CREATE", "DeviceBlock", @@ -155,7 +156,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin await _blockedDeviceService.UnblockDeviceAsync(tenantId, serialNumber); - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( tenantId, "DELETE", "DeviceBlock", diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs index 4f5e6823..e1d0c585 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetAuditLogsFunction.cs @@ -1,6 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; -using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -10,12 +10,12 @@ namespace AutopilotMonitor.Functions.Functions.Admin public class GetAuditLogsFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly IMaintenanceRepository _maintenanceRepo; - public GetAuditLogsFunction(ILogger logger, TableStorageService storageService) + public GetAuditLogsFunction(ILogger logger, IMaintenanceRepository maintenanceRepo) { _logger = logger; - _storageService = storageService; + _maintenanceRepo = maintenanceRepo; } [Function("GetAuditLogs")] @@ -33,7 +33,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin _logger.LogInformation($"Fetching audit logs for tenant {tenantId} by user {userIdentifier}"); // Get audit logs from storage - var logs = await _storageService.GetAuditLogsAsync(tenantId, maxResults: 100); + var logs = await _maintenanceRepo.GetAuditLogsAsync(tenantId, maxResults: 100); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs index bd2b4455..c1180548 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/GetGlobalAuditLogsFunction.cs @@ -1,6 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; -using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -10,14 +10,14 @@ namespace AutopilotMonitor.Functions.Functions.Admin public class GetGlobalAuditLogsFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly IMaintenanceRepository _maintenanceRepo; public GetGlobalAuditLogsFunction( ILogger logger, - TableStorageService storageService) + IMaintenanceRepository maintenanceRepo) { _logger = logger; - _storageService = storageService; + _maintenanceRepo = maintenanceRepo; } [Function("GetGlobalAuditLogs")] @@ -33,7 +33,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin _logger.LogInformation($"Fetching all audit logs across all tenants (User: {userEmail})"); - var logs = await _storageService.GetAllAuditLogsAsync(maxResults: 100); + var logs = await _maintenanceRepo.GetAllAuditLogsAsync(maxResults: 100); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReseedFromGitHubFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReseedFromGitHubFunction.cs index c539013f..0f201c5d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReseedFromGitHubFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ReseedFromGitHubFunction.cs @@ -3,7 +3,7 @@ using System.Text.Json; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Functions.Services.Vulnerability; -using Azure.Data.Tables; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -21,7 +21,8 @@ namespace AutopilotMonitor.Functions.Functions.Admin private readonly GatherRuleService _gatherRuleService; private readonly AnalyzeRuleService _analyzeRuleService; private readonly ImeLogPatternService _imeLogPatternService; - private readonly TableStorageService _storageService; + private readonly IVulnerabilityRepository _vulnRepo; + private readonly IRuleRepository _ruleRepo; public ReseedFromGitHubFunction( ILogger logger, @@ -29,14 +30,16 @@ namespace AutopilotMonitor.Functions.Functions.Admin GatherRuleService gatherRuleService, AnalyzeRuleService analyzeRuleService, ImeLogPatternService imeLogPatternService, - TableStorageService storageService) + IVulnerabilityRepository vulnRepo, + IRuleRepository ruleRepo) { _logger = logger; _gitHubRepo = gitHubRepo; _gatherRuleService = gatherRuleService; _analyzeRuleService = analyzeRuleService; _imeLogPatternService = imeLogPatternService; - _storageService = storageService; + _vulnRepo = vulnRepo; + _ruleRepo = ruleRepo; } [Function("ReseedFromGitHub")] @@ -134,11 +137,11 @@ namespace AutopilotMonitor.Functions.Functions.Admin private async Task<(int deleted, int written)> ReseedGatherAsync(List rules) { - var existing = await _storageService.GetGatherRulesAsync("global"); + var existing = await _ruleRepo.GetGatherRulesAsync("global"); var deleted = 0; foreach (var rule in existing.Where(r => r.IsBuiltIn || r.IsCommunity)) { - await _storageService.DeleteGatherRuleAsync("global", rule.RuleId); + await _ruleRepo.DeleteGatherRuleAsync("global", rule.RuleId); deleted++; } @@ -149,7 +152,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin rule.IsBuiltIn = true; rule.CreatedAt = DateTime.UtcNow; rule.UpdatedAt = DateTime.UtcNow; - await _storageService.StoreGatherRuleAsync(rule, "global"); + await _ruleRepo.StoreGatherRuleAsync(rule, "global"); } _logger.LogInformation($"GitHub reseed gather: {deleted} deleted, {rules.Count} written"); @@ -158,11 +161,11 @@ namespace AutopilotMonitor.Functions.Functions.Admin private async Task<(int deleted, int written)> ReseedAnalyzeAsync(List rules) { - var existing = await _storageService.GetAnalyzeRulesAsync("global"); + var existing = await _ruleRepo.GetAnalyzeRulesAsync("global"); var deleted = 0; foreach (var rule in existing.Where(r => r.IsBuiltIn || r.IsCommunity)) { - await _storageService.DeleteAnalyzeRuleAsync("global", rule.RuleId); + await _ruleRepo.DeleteAnalyzeRuleAsync("global", rule.RuleId); deleted++; } @@ -173,7 +176,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin rule.IsBuiltIn = true; rule.CreatedAt = DateTime.UtcNow; rule.UpdatedAt = DateTime.UtcNow; - await _storageService.StoreAnalyzeRuleAsync(rule, "global"); + await _ruleRepo.StoreAnalyzeRuleAsync(rule, "global"); } _logger.LogInformation($"GitHub reseed analyze: {deleted} deleted, {rules.Count} written"); @@ -182,18 +185,18 @@ namespace AutopilotMonitor.Functions.Functions.Admin private async Task<(int deleted, int written)> ReseedImeAsync(List patterns) { - var existing = await _storageService.GetImeLogPatternsAsync("global"); + var existing = await _ruleRepo.GetImeLogPatternsAsync("global"); var deleted = 0; foreach (var pattern in existing.Where(p => p.IsBuiltIn)) { - await _storageService.DeleteImeLogPatternAsync("global", pattern.PatternId); + await _ruleRepo.DeleteImeLogPatternAsync("global", pattern.PatternId); deleted++; } foreach (var pattern in patterns) { pattern.IsBuiltIn = true; - await _storageService.StoreImeLogPatternAsync(pattern, "global"); + await _ruleRepo.StoreImeLogPatternAsync(pattern, "global"); } _logger.LogInformation($"GitHub reseed IME: {deleted} deleted, {patterns.Count} written"); @@ -202,45 +205,12 @@ namespace AutopilotMonitor.Functions.Functions.Admin private async Task<(int deleted, int written)> ReseedCpeCommunityMappingsAsync() { - var tableClient = _storageService.GetTableClient(AutopilotMonitor.Shared.Constants.TableNames.VulnerabilityCache); - // Delete existing community CPE mapping entries - var deleted = 0; - var existingEntities = tableClient.QueryAsync( - filter: $"PartitionKey eq 'cpe_map_community'"); - await foreach (var entity in existingEntities) - { - await tableClient.DeleteEntityAsync(entity.PartitionKey, entity.RowKey); - deleted++; - } + var deleted = await _vulnRepo.DeleteCpeMappingsByPartitionAsync("cpe_map_community"); - // Fetch community CPE mappings from GitHub + // Fetch community CPE mappings from GitHub and import var json = await _gitHubRepo.FetchCpeCommunityMappingsAsync(); - var seed = JsonSerializer.Deserialize(json, new JsonSerializerOptions - { - PropertyNameCaseInsensitive = true - }); - - var written = 0; - if (seed?.Mappings != null) - { - foreach (var mapping in seed.Mappings) - { - var entity = new TableEntity("cpe_map_community", $"{mapping.CpeVendor}_{mapping.CpeProduct}") - { - { "NormalizedVendor", mapping.NormalizedVendor ?? "" }, - { "NormalizedProduct", mapping.NormalizedProduct ?? "" }, - { "CpeVendor", mapping.CpeVendor ?? "" }, - { "CpeProduct", mapping.CpeProduct ?? "" }, - { "CpeUri", mapping.CpeUri ?? "" }, - { "Category", mapping.Category ?? "community" }, - { "DisplayNamePatternsJson", JsonSerializer.Serialize(mapping.DisplayNamePatterns ?? new List()) }, - { "PublisherPatternsJson", JsonSerializer.Serialize(mapping.PublisherPatterns ?? new List()) } - }; - await tableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace); - written++; - } - } + var written = await _vulnRepo.ImportCpeCommunityMappingsFromJsonAsync(json); // Reset the static seed-loaded flag so the next correlation picks up fresh data VulnerabilityCorrelationService.ResetMappingsCache(); @@ -252,19 +222,12 @@ namespace AutopilotMonitor.Functions.Functions.Admin private async Task<(int deleted, int written)> ReseedCpeSeedMappingsAsync() { // Count existing seed entries before deletion (for reporting) - var tableClient = _storageService.GetTableClient(AutopilotMonitor.Shared.Constants.TableNames.VulnerabilityCache); - var deleted = 0; - var existingEntities = tableClient.QueryAsync( - filter: $"PartitionKey eq 'cpe_map_seed'", - select: new[] { "PartitionKey" }); - await foreach (var _ in existingEntities) - { - deleted++; - } + var existingEntries = await _vulnRepo.GetCpeMappingsByPartitionAsync("cpe_map_seed"); + var deleted = existingEntries.Count; // Fetch CPE seed mappings from GitHub and import (deletes + writes in one call) var json = await _gitHubRepo.FetchCpeSeedMappingsAsync(); - var written = await _storageService.ImportCpeMappingSeedFromJsonAsync(json); + var written = await _vulnRepo.ImportCpeMappingSeedFromJsonAsync(json); // Reset the static seed-loaded flag so the next correlation picks up fresh data VulnerabilityCorrelationService.ResetMappingsCache(); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantAdminManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantAdminManagementFunction.cs index de97a1f8..f4e47920 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantAdminManagementFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantAdminManagementFunction.cs @@ -2,6 +2,7 @@ using System.Net; using AutopilotMonitor.Functions.Extensions; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.AspNetCore.Authorization; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -18,18 +19,18 @@ public class TenantAdminManagementFunction private readonly ILogger _logger; private readonly TenantAdminsService _tenantAdminsService; private readonly GlobalAdminService _globalAdminService; - private readonly TableStorageService _storageService; + private readonly IMaintenanceRepository _maintenanceRepo; public TenantAdminManagementFunction( ILogger logger, TenantAdminsService tenantAdminsService, GlobalAdminService globalAdminService, - TableStorageService storageService) + IMaintenanceRepository maintenanceRepo) { _logger = logger; _tenantAdminsService = tenantAdminsService; _globalAdminService = globalAdminService; - _storageService = storageService; + _maintenanceRepo = maintenanceRepo; } /// @@ -116,7 +117,7 @@ public class TenantAdminManagementFunction var role = !string.IsNullOrWhiteSpace(body.Role) ? body.Role : AutopilotMonitor.Shared.Constants.TenantRoles.Admin; var newAdmin = await _tenantAdminsService.AddTenantMemberAsync(tenantId, body.Upn, upn!, role, body.CanManageBootstrapTokens); - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( tenantId, "CREATE", "TenantAdmin", @@ -189,7 +190,7 @@ public class TenantAdminManagementFunction // Remove the admin await _tenantAdminsService.RemoveTenantAdminAsync(tenantId, adminUpn); - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( tenantId, "DELETE", "TenantAdmin", @@ -239,7 +240,7 @@ public class TenantAdminManagementFunction // Disable the admin await _tenantAdminsService.DisableTenantAdminAsync(tenantId, adminUpn); - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( tenantId, "UPDATE", "TenantAdmin", @@ -290,7 +291,7 @@ public class TenantAdminManagementFunction // Enable the admin await _tenantAdminsService.EnableTenantAdminAsync(tenantId, adminUpn); - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( tenantId, "UPDATE", "TenantAdmin", @@ -369,7 +370,7 @@ public class TenantAdminManagementFunction return notFoundResponse; } - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( tenantId, "UPDATE", "TenantAdmin", diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs index 85a3445e..c26bf65e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/TenantOffboardFunction.cs @@ -3,12 +3,11 @@ using AutopilotMonitor.Functions.Extensions; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared; -using Azure.Data.Tables; using Microsoft.AspNetCore.Authorization; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Functions.Admin; @@ -23,22 +22,18 @@ public class TenantOffboardFunction private readonly ILogger _logger; private readonly TenantAdminsService _tenantAdminsService; private readonly GlobalAdminService _globalAdminService; - private readonly TableStorageService _storageService; - private readonly TableServiceClient _tableServiceClient; + private readonly IMaintenanceRepository _maintenanceRepo; public TenantOffboardFunction( ILogger logger, TenantAdminsService tenantAdminsService, GlobalAdminService globalAdminService, - TableStorageService storageService, - IConfiguration configuration) + IMaintenanceRepository maintenanceRepo) { _logger = logger; _tenantAdminsService = tenantAdminsService; _globalAdminService = globalAdminService; - _storageService = storageService; - var connectionString = configuration["AzureTableStorageConnectionString"]; - _tableServiceClient = new TableServiceClient(connectionString); + _maintenanceRepo = maintenanceRepo; } /// @@ -82,7 +77,7 @@ public class TenantOffboardFunction _logger.LogWarning($"TENANT OFFBOARD initiated for tenant {tenantId} by {upn}"); // Audit under Global TenantId so the entry survives tenant data deletion - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( Constants.AuditGlobalTenantId, "DELETE", "Tenant", @@ -98,47 +93,8 @@ public class TenantOffboardFunction try { - // Tables where PartitionKey = tenantId (normalized lowercase) - var tenantPartitionTables = new[] - { - Constants.TableNames.Sessions, - Constants.TableNames.SessionsIndex, - Constants.TableNames.AuditLogs, - Constants.TableNames.UsageMetrics, - Constants.TableNames.UserActivity, - Constants.TableNames.GatherRules, - Constants.TableNames.AnalyzeRules, - Constants.TableNames.AppInstallSummaries, - Constants.TableNames.TenantConfiguration, - Constants.TableNames.TenantAdmins, - Constants.TableNames.BootstrapSessions, - Constants.TableNames.BlockedDevices, - Constants.TableNames.ImeLogPatterns, - Constants.TableNames.RuleStates, - }; - - var normalizedTenantId = tenantId.ToLowerInvariant(); - - foreach (var tableName in tenantPartitionTables) - { - var deleted = await DeleteAllRowsByPartitionKeyAsync(tableName, normalizedTenantId); - result.DeletedCounts[tableName] = deleted; - _logger.LogInformation($"Offboard [{tenantId}] {tableName}: deleted {deleted} rows"); - } - - // Tables with composite PartitionKey = "{tenantId}_{sessionId}" – query by prefix - var eventsDeleted = await DeleteByTenantPrefixAsync(Constants.TableNames.Events, normalizedTenantId); - result.DeletedCounts[Constants.TableNames.Events] = eventsDeleted; - _logger.LogInformation($"Offboard [{tenantId}] Events: deleted {eventsDeleted} rows"); - - var ruleResultsDeleted = await DeleteByTenantPrefixAsync(Constants.TableNames.RuleResults, normalizedTenantId); - result.DeletedCounts[Constants.TableNames.RuleResults] = ruleResultsDeleted; - _logger.LogInformation($"Offboard [{tenantId}] RuleResults: deleted {ruleResultsDeleted} rows"); - - // BootstrapSessions CodeLookup entries (PartitionKey = "CodeLookup", TenantId property = tenantId) - var codeLookupDeleted = await DeleteCodeLookupEntriesAsync(normalizedTenantId); - result.DeletedCounts["BootstrapSessions_CodeLookup"] = codeLookupDeleted; - _logger.LogInformation($"Offboard [{tenantId}] BootstrapSessions CodeLookup: deleted {codeLookupDeleted} rows"); + var deletedCounts = await _maintenanceRepo.DeleteAllTenantDataAsync(tenantId); + result.DeletedCounts = deletedCounts; result.Success = true; _logger.LogWarning($"TENANT OFFBOARD completed for tenant {tenantId} by {upn}. " + @@ -149,7 +105,7 @@ public class TenantOffboardFunction details["Action"] = "Offboard"; details["Phase"] = "Completed"; details["TotalRowsDeleted"] = result.DeletedCounts.Values.Sum().ToString(); - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( Constants.AuditGlobalTenantId, "DELETE", "Tenant", @@ -173,132 +129,6 @@ public class TenantOffboardFunction } } - /// - /// Deletes all rows in a table where PartitionKey equals the given value. - /// Uses batched deletes (max 100 per transaction, same PartitionKey required). - /// - private async Task DeleteAllRowsByPartitionKeyAsync(string tableName, string partitionKey) - { - try - { - var tableClient = _tableServiceClient.GetTableClient(tableName); - var filter = $"PartitionKey eq '{partitionKey}'"; - var entities = tableClient.QueryAsync(filter, select: new[] { "PartitionKey", "RowKey" }); - - int deleted = 0; - var batch = new List(); - - await foreach (var entity in entities) - { - batch.Add(new TableTransactionAction(TableTransactionActionType.Delete, entity)); - deleted++; - - if (batch.Count >= 100) - { - await tableClient.SubmitTransactionAsync(batch); - batch.Clear(); - } - } - - if (batch.Count > 0) - { - await tableClient.SubmitTransactionAsync(batch); - } - - return deleted; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to delete rows in {tableName} for partition {partitionKey}"); - return 0; - } - } - - /// - /// Deletes all rows for a tenant from a table that uses composite PartitionKey = "{tenantId}_{sessionId}". - /// Used for Events and RuleResults tables. - /// - private async Task DeleteByTenantPrefixAsync(string tableName, string normalizedTenantId) - { - try - { - var tableClient = _tableServiceClient.GetTableClient(tableName); - // OData startsWith: PartitionKey ge 'prefix' and PartitionKey lt 'prefix~' - var prefix = normalizedTenantId + "_"; - var filter = $"PartitionKey ge '{prefix}' and PartitionKey lt '{prefix}~'"; - var entities = tableClient.QueryAsync(filter, select: new[] { "PartitionKey", "RowKey" }); - - int deleted = 0; - // Group by PartitionKey because Azure Table batch transactions require same PartitionKey - var groups = new Dictionary>(); - - await foreach (var entity in entities) - { - if (!groups.ContainsKey(entity.PartitionKey)) - groups[entity.PartitionKey] = new List(); - groups[entity.PartitionKey].Add(entity); - deleted++; - } - - foreach (var group in groups.Values) - { - for (int i = 0; i < group.Count; i += 100) - { - var chunk = group.Skip(i).Take(100).ToList(); - var batch = chunk.Select(e => new TableTransactionAction(TableTransactionActionType.Delete, e)).ToList(); - await tableClient.SubmitTransactionAsync(batch); - } - } - - return deleted; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to delete {tableName} for tenant {normalizedTenantId}"); - return 0; - } - } - - /// - /// Deletes BootstrapSessions CodeLookup entries for a tenant. - /// These use PartitionKey = "CodeLookup" with a TenantId property matching the tenant. - /// - private async Task DeleteCodeLookupEntriesAsync(string normalizedTenantId) - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.BootstrapSessions); - var filter = $"PartitionKey eq 'CodeLookup' and TenantId eq '{normalizedTenantId}'"; - var entities = tableClient.QueryAsync(filter, select: new[] { "PartitionKey", "RowKey" }); - - int deleted = 0; - var batch = new List(); - - await foreach (var entity in entities) - { - batch.Add(new TableTransactionAction(TableTransactionActionType.Delete, entity)); - deleted++; - - if (batch.Count >= 100) - { - await tableClient.SubmitTransactionAsync(batch); - batch.Clear(); - } - } - - if (batch.Count > 0) - { - await tableClient.SubmitTransactionAsync(batch); - } - - return deleted; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Failed to delete CodeLookup entries for tenant {normalizedTenantId}"); - return 0; - } - } } public class OffboardResult diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/VersionBlockFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/VersionBlockFunction.cs index 396a55ed..f209d6b6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/VersionBlockFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/VersionBlockFunction.cs @@ -3,6 +3,7 @@ using System.Net; using System.Threading.Tasks; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -15,16 +16,16 @@ namespace AutopilotMonitor.Functions.Functions.Admin { private readonly ILogger _logger; private readonly BlockedVersionService _blockedVersionService; - private readonly TableStorageService _storageService; + private readonly IMaintenanceRepository _maintenanceRepo; public VersionBlockFunction( ILogger logger, BlockedVersionService blockedVersionService, - TableStorageService storageService) + IMaintenanceRepository maintenanceRepo) { _logger = logger; _blockedVersionService = blockedVersionService; - _storageService = storageService; + _maintenanceRepo = maintenanceRepo; } /// GET /api/versions/blocked — list all active version block rules @@ -91,7 +92,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin var normalizedAction = string.Equals(action, "Kill", StringComparison.OrdinalIgnoreCase) ? "Kill" : "Block"; - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( AutopilotMonitor.Shared.Constants.AuditGlobalTenantId, "CREATE", "VersionBlock", @@ -144,7 +145,7 @@ namespace AutopilotMonitor.Functions.Functions.Admin await _blockedVersionService.UnblockVersionAsync(versionPattern); - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( AutopilotMonitor.Shared.Constants.AuditGlobalTenantId, "DELETE", "VersionBlock", diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Auth/ApiKeyManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Auth/ApiKeyManagementFunction.cs index a9221c3f..4b3565c1 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Auth/ApiKeyManagementFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Auth/ApiKeyManagementFunction.cs @@ -3,8 +3,7 @@ using System.Security.Cryptography; using System.Text; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; -using AutopilotMonitor.Shared; -using Azure.Data.Tables; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -15,16 +14,16 @@ namespace AutopilotMonitor.Functions.Functions.Auth; public class ApiKeyManagementFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly IAdminRepository _adminRepo; private readonly GlobalAdminService _globalAdminService; public ApiKeyManagementFunction( ILogger logger, - TableStorageService storageService, + IAdminRepository adminRepo, GlobalAdminService globalAdminService) { _logger = logger; - _storageService = storageService; + _adminRepo = adminRepo; _globalAdminService = globalAdminService; } @@ -48,7 +47,8 @@ public class ApiKeyManagementFunction targetTenantId = overrideTenant; } - var keys = await GetApiKeysForTenantAsync(targetTenantId); + var entries = await _adminRepo.GetApiKeysAsync(targetTenantId); + var keys = entries.Select(MapKeyEntry).ToList(); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new { success = true, count = keys.Count, keys }); @@ -70,13 +70,8 @@ public class ApiKeyManagementFunction { try { - var tableClient = _storageService.GetTableClient(Constants.TableNames.ApiKeys); - var keys = new List(); - - await foreach (var entity in tableClient.QueryAsync()) - { - keys.Add(MapKeyEntity(entity)); - } + var entries = await _adminRepo.GetAllApiKeysAsync(); + var keys = entries.Select(MapKeyEntry).ToList(); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new { success = true, count = keys.Count, keys }); @@ -139,26 +134,26 @@ public class ApiKeyManagementFunction var keyId = Guid.NewGuid().ToString(); var partitionKey = scope == "global" ? "GLOBAL" : tenantId; var createdAt = DateTime.UtcNow; - DateTimeOffset? expiresAt = expiresInDays.HasValue - ? DateTimeOffset.UtcNow.AddDays(expiresInDays.Value) + DateTime? expiresAt = expiresInDays.HasValue + ? DateTime.UtcNow.AddDays(expiresInDays.Value) : null; - var entity = new TableEntity(partitionKey, keyId) + var entry = new ApiKeyEntry { - ["KeyHash"] = keyHash, - ["Label"] = label, - ["Scope"] = scope, - ["TenantId"] = tenantId, - ["CreatedBy"] = upn, - ["CreatedAt"] = createdAt, - ["IsActive"] = true, - ["RequestCount"] = 0L, + KeyId = keyId, + TenantId = tenantId, + KeyHash = keyHash, + Name = label, + Scope = scope, + Upn = upn, + CreatedBy = upn, + CreatedAt = createdAt, + ExpiresAt = expiresAt, + IsActive = true, + RequestCount = 0L, }; - if (expiresAt.HasValue) - entity["ExpiresAt"] = expiresAt.Value; - var tableClient = _storageService.GetTableClient(Constants.TableNames.ApiKeys); - await tableClient.UpsertEntityAsync(entity); + await _adminRepo.StoreApiKeyAsync(partitionKey, entry); _logger.LogInformation("API key created: scope={Scope}, tenantId={TenantId}, createdBy={CreatedBy}", scope, tenantId, upn); @@ -197,25 +192,21 @@ public class ApiKeyManagementFunction var upn = TenantHelper.GetUserIdentifier(req); var isGA = await _globalAdminService.IsGlobalAdminAsync(upn); - var tableClient = _storageService.GetTableClient(Constants.TableNames.ApiKeys); - - // Find the key by keyId (RowKey). Try tenant PK first, then GLOBAL. - TableEntity? foundEntity = null; + // Find the key by keyId. Try tenant PK first, then GLOBAL. + ApiKeyEntry? foundEntry = null; + string? foundPartitionKey = null; foreach (var partitionKey in new[] { tenantId, "GLOBAL" }) { - try + var entry = await _adminRepo.GetApiKeyAsync(partitionKey, keyId); + if (entry != null) { - var result = await tableClient.GetEntityAsync(partitionKey, keyId); - foundEntity = result.Value; + foundEntry = entry; + foundPartitionKey = partitionKey; break; } - catch (Azure.RequestFailedException ex) when (ex.Status == 404) - { - // try next - } } - if (foundEntity == null) + if (foundEntry == null) { var notFound = req.CreateResponse(HttpStatusCode.NotFound); await notFound.WriteAsJsonAsync(new { success = false, message = "API key not found" }); @@ -223,15 +214,14 @@ public class ApiKeyManagementFunction } // Ownership check: non-GA users can only delete their own tenant's keys - var keyTenantId = foundEntity.GetString("TenantId"); - if (!isGA && !string.Equals(keyTenantId, tenantId, StringComparison.OrdinalIgnoreCase)) + if (!isGA && !string.Equals(foundEntry.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) { var forbidden = req.CreateResponse(HttpStatusCode.Forbidden); await forbidden.WriteAsJsonAsync(new { success = false, message = "Access denied" }); return forbidden; } - await tableClient.DeleteEntityAsync(foundEntity.PartitionKey, keyId); + await _adminRepo.RevokeApiKeyAsync(foundPartitionKey!, keyId); _logger.LogInformation("API key deleted: keyId={KeyId}, deletedBy={Upn}", keyId, upn); @@ -248,33 +238,19 @@ public class ApiKeyManagementFunction } } - private async Task> GetApiKeysForTenantAsync(string tenantId) - { - var tableClient = _storageService.GetTableClient(Constants.TableNames.ApiKeys); - var keys = new List(); - - await foreach (var entity in tableClient.QueryAsync( - filter: $"PartitionKey eq '{tenantId}'")) - { - keys.Add(MapKeyEntity(entity)); - } - - return keys; - } - - private static object MapKeyEntity(TableEntity entity) + private static object MapKeyEntry(ApiKeyEntry entry) { return new { - keyId = entity.RowKey, - scope = entity.GetString("Scope") ?? "tenant", - tenantId = entity.GetString("TenantId") ?? entity.PartitionKey, - label = entity.GetString("Label") ?? "", - createdBy = entity.GetString("CreatedBy") ?? "", - createdAt = entity.GetDateTimeOffset("CreatedAt")?.UtcDateTime, - expiresAt = entity.GetDateTimeOffset("ExpiresAt")?.UtcDateTime, - isActive = entity.GetBoolean("IsActive") ?? true, - requestCount = entity.TryGetValue("RequestCount", out var rc) ? Convert.ToInt64(rc) : 0L, + keyId = entry.KeyId, + scope = entry.Scope ?? "tenant", + tenantId = entry.TenantId, + label = entry.Name ?? "", + createdBy = entry.CreatedBy ?? "", + createdAt = entry.CreatedAt, + expiresAt = entry.ExpiresAt, + isActive = entry.IsActive, + requestCount = entry.RequestCount, }; } } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/CreateBootstrapSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/CreateBootstrapSessionFunction.cs index 51967420..e2a96b21 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/CreateBootstrapSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/CreateBootstrapSessionFunction.cs @@ -4,6 +4,7 @@ using System.Net; using System.Threading.Tasks; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -22,20 +23,20 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap private readonly BootstrapSessionService _bootstrapService; private readonly GlobalAdminService _globalAdminService; private readonly TenantConfigurationService _configService; - private readonly TableStorageService _storageService; + private readonly IMaintenanceRepository _maintenanceRepo; public CreateBootstrapSessionFunction( ILogger logger, BootstrapSessionService bootstrapService, GlobalAdminService globalAdminService, TenantConfigurationService configService, - TableStorageService storageService) + IMaintenanceRepository maintenanceRepo) { _logger = logger; _bootstrapService = bootstrapService; _globalAdminService = globalAdminService; _configService = configService; - _storageService = storageService; + _maintenanceRepo = maintenanceRepo; } [Function("CreateBootstrapSession")] @@ -90,7 +91,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap var session = await _bootstrapService.CreateAsync(tenantId, validityHours, userIdentifier, request.Label); - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( tenantId, "CREATE", "BootstrapSession", diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/RevokeBootstrapSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/RevokeBootstrapSessionFunction.cs index f7ffb179..a47a8b52 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/RevokeBootstrapSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Bootstrap/RevokeBootstrapSessionFunction.cs @@ -3,6 +3,7 @@ using System.Net; using System.Threading.Tasks; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -19,20 +20,20 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap private readonly BootstrapSessionService _bootstrapService; private readonly GlobalAdminService _globalAdminService; private readonly TenantConfigurationService _configService; - private readonly TableStorageService _storageService; + private readonly IMaintenanceRepository _maintenanceRepo; public RevokeBootstrapSessionFunction( ILogger logger, BootstrapSessionService bootstrapService, GlobalAdminService globalAdminService, TenantConfigurationService configService, - TableStorageService storageService) + IMaintenanceRepository maintenanceRepo) { _logger = logger; _bootstrapService = bootstrapService; _globalAdminService = globalAdminService; _configService = configService; - _storageService = storageService; + _maintenanceRepo = maintenanceRepo; } [Function("RevokeBootstrapSession")] @@ -78,7 +79,7 @@ namespace AutopilotMonitor.Functions.Functions.Bootstrap return notFound; } - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( tenantId, "DELETE", "BootstrapSession", diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateAdminConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateAdminConfigurationFunction.cs index 671d6ecc..2fd0ccf7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateAdminConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateAdminConfigurationFunction.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using System.Collections.Generic; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -17,16 +18,16 @@ namespace AutopilotMonitor.Functions.Functions.Config { private readonly ILogger _logger; private readonly AdminConfigurationService _adminConfigService; - private readonly TableStorageService _storageService; + private readonly IMaintenanceRepository _maintenanceRepo; public UpdateAdminConfigurationFunction( ILogger logger, AdminConfigurationService adminConfigService, - TableStorageService storageService) + IMaintenanceRepository maintenanceRepo) { _logger = logger; _adminConfigService = adminConfigService; - _storageService = storageService; + _maintenanceRepo = maintenanceRepo; } [Function("UpdateAdminConfiguration")] @@ -73,7 +74,7 @@ namespace AutopilotMonitor.Functions.Functions.Config // Save configuration await _adminConfigService.SaveConfigurationAsync(config); - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( AutopilotMonitor.Shared.Constants.AuditGlobalTenantId, "UPDATE", "AdminConfiguration", diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs index 7e084265..41b4e934 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/UpdateTenantConfigurationFunction.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using System.Collections.Generic; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -18,18 +19,18 @@ namespace AutopilotMonitor.Functions.Functions.Config private readonly ILogger _logger; private readonly TenantConfigurationService _configService; private readonly GlobalAdminService _globalAdminService; - private readonly TableStorageService _storageService; + private readonly IMaintenanceRepository _maintenanceRepo; public UpdateTenantConfigurationFunction( ILogger logger, TenantConfigurationService configService, GlobalAdminService globalAdminService, - TableStorageService storageService) + IMaintenanceRepository maintenanceRepo) { _logger = logger; _configService = configService; _globalAdminService = globalAdminService; - _storageService = storageService; + _maintenanceRepo = maintenanceRepo; } [Function("UpdateTenantConfiguration")] @@ -126,7 +127,7 @@ namespace AutopilotMonitor.Functions.Functions.Config await _configService.SaveConfigurationAsync(config); var changes = ConfigDiffHelper.GetChanges(existingConfig, config); - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( tenantId, "UPDATE", "TenantConfiguration", diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs index 4903d4e5..13ed4553 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Feedback/FeedbackFunction.cs @@ -6,12 +6,10 @@ using AutopilotMonitor.Functions.Extensions; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; -using Azure; -using Azure.Data.Tables; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Functions.Feedback @@ -23,8 +21,8 @@ namespace AutopilotMonitor.Functions.Functions.Feedback private readonly AdminConfigurationService _adminConfigService; private readonly TenantAdminsService _tenantAdminsService; private readonly TelegramNotificationService _telegramNotificationService; - private readonly TableStorageService _storageService; - private readonly TableClient _feedbackTableClient; + private readonly ISessionRepository _sessionRepo; + private readonly IConfigRepository _configRepo; public FeedbackFunction( ILogger logger, @@ -32,19 +30,16 @@ namespace AutopilotMonitor.Functions.Functions.Feedback AdminConfigurationService adminConfigService, TenantAdminsService tenantAdminsService, TelegramNotificationService telegramNotificationService, - TableStorageService storageService, - IConfiguration configuration) + ISessionRepository sessionRepo, + IConfigRepository configRepo) { _logger = logger; _tenantConfigService = tenantConfigService; _adminConfigService = adminConfigService; _tenantAdminsService = tenantAdminsService; _telegramNotificationService = telegramNotificationService; - _storageService = storageService; - - var connectionString = configuration["AzureTableStorageConnectionString"]; - var serviceClient = new TableServiceClient(connectionString); - _feedbackTableClient = serviceClient.GetTableClient(Constants.TableNames.PreviewConfig); + _sessionRepo = sessionRepo; + _configRepo = configRepo; } /// @@ -78,30 +73,21 @@ namespace AutopilotMonitor.Functions.Functions.Feedback return await WriteJson(req, new { eligible = false }); // 5. Sessions check — at least 1 session exists - var sessionPage = await _storageService.GetSessionsAsync(tenantId, maxResults: 1); + var sessionPage = await _sessionRepo.GetSessionsAsync(tenantId, maxResults: 1); if (sessionPage.Sessions.Count == 0) return await WriteJson(req, new { eligible = false }); // 6. Cooldown check - try + var feedbackEntry = await _configRepo.GetFeedbackEntryAsync(upn); + if (feedbackEntry?.InteractedAt != null) { - var entity = await _feedbackTableClient.GetEntityAsync("Feedback", upn.ToLowerInvariant()); - var interactedAt = entity.Value.GetDateTime("InteractedAt"); + // Cooldown = 0 means single wave only + if (adminConfig.FeedbackCooldownDays == 0) + return await WriteJson(req, new { eligible = false }); - if (interactedAt.HasValue) - { - // Cooldown = 0 means single wave only - if (adminConfig.FeedbackCooldownDays == 0) - return await WriteJson(req, new { eligible = false }); - - var daysSinceInteraction = (DateTime.UtcNow - interactedAt.Value).TotalDays; - if (daysSinceInteraction < adminConfig.FeedbackCooldownDays) - return await WriteJson(req, new { eligible = false }); - } - } - catch (RequestFailedException ex) when (ex.Status == 404) - { - // No feedback record yet — user is eligible + var daysSinceInteraction = (DateTime.UtcNow - feedbackEntry.InteractedAt.Value).TotalDays; + if (daysSinceInteraction < adminConfig.FeedbackCooldownDays) + return await WriteJson(req, new { eligible = false }); } return await WriteJson(req, new { eligible = true }); @@ -167,18 +153,17 @@ namespace AutopilotMonitor.Functions.Functions.Feedback comment = comment.Substring(0, 500); // Upsert feedback record - var entity = new TableEntity("Feedback", upn.ToLowerInvariant()) + await _configRepo.SaveFeedbackEntryAsync(new FeedbackEntry { - { "TenantId", tenantId }, - { "DisplayName", displayName }, - { "Rating", body.Dismissed ? null : (int?)body.Rating }, - { "Comment", body.Dismissed ? null : comment }, - { "Dismissed", body.Dismissed }, - { "Submitted", !body.Dismissed }, - { "InteractedAt", DateTime.UtcNow } - }; - - await _feedbackTableClient.UpsertEntityAsync(entity, TableUpdateMode.Replace); + Upn = upn, + TenantId = tenantId, + DisplayName = displayName, + Rating = body.Dismissed ? null : (int?)body.Rating, + Comment = body.Dismissed ? null : comment, + Dismissed = body.Dismissed, + Submitted = !body.Dismissed, + InteractedAt = DateTime.UtcNow + }); // Telegram notification — only for actual submissions, fire-and-forget if (!body.Dismissed && body.Rating > 0) @@ -213,23 +198,18 @@ namespace AutopilotMonitor.Functions.Functions.Feedback { try { - var entries = new System.Collections.Generic.List(); - - await foreach (var entity in _feedbackTableClient.QueryAsync( - filter: "PartitionKey eq 'Feedback'")) + var allEntries = await _configRepo.GetAllFeedbackEntriesAsync(); + var entries = allEntries.Select(e => new { - entries.Add(new - { - upn = entity.RowKey, - tenantId = entity.GetString("TenantId"), - displayName = entity.GetString("DisplayName"), - rating = entity.GetInt32("Rating"), - comment = entity.GetString("Comment"), - dismissed = entity.GetBoolean("Dismissed") ?? false, - submitted = entity.GetBoolean("Submitted") ?? false, - interactedAt = entity.GetDateTime("InteractedAt")?.ToString("o") - }); - } + upn = e.Upn, + tenantId = e.TenantId, + displayName = e.DisplayName, + rating = e.Rating, + comment = e.Comment, + dismissed = e.Dismissed, + submitted = e.Submitted, + interactedAt = e.InteractedAt?.ToString("o") + }).ToList(); return await WriteJson(req, new { feedback = entries }); } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs index 42dcd176..5e64780e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Infrastructure/AuthFunction.cs @@ -1,6 +1,7 @@ using System.Net; using AutopilotMonitor.Functions.Extensions; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.Functions.Worker; @@ -18,7 +19,7 @@ public class AuthFunction private readonly GlobalAdminService _globalAdminService; private readonly TenantConfigurationService _tenantConfigService; private readonly TenantAdminsService _tenantAdminsService; - private readonly TableStorageService _storageService; + private readonly IMetricsRepository _metricsRepo; private readonly PreviewWhitelistService _previewWhitelistService; private readonly TelegramNotificationService _telegramNotificationService; private readonly GlobalNotificationService _globalNotificationService; @@ -28,7 +29,7 @@ public class AuthFunction GlobalAdminService globalAdminService, TenantConfigurationService tenantConfigService, TenantAdminsService tenantAdminsService, - TableStorageService storageService, + IMetricsRepository metricsRepo, PreviewWhitelistService previewWhitelistService, TelegramNotificationService telegramNotificationService, GlobalNotificationService globalNotificationService) @@ -37,7 +38,7 @@ public class AuthFunction _globalAdminService = globalAdminService; _tenantConfigService = tenantConfigService; _tenantAdminsService = tenantAdminsService; - _storageService = storageService; + _metricsRepo = metricsRepo; _previewWhitelistService = previewWhitelistService; _telegramNotificationService = telegramNotificationService; _globalNotificationService = globalNotificationService; @@ -172,7 +173,7 @@ public class AuthFunction bool canManageBootstrapTokens = memberRole?.CanManageBootstrapTokens ?? false; // Record user login activity for metrics tracking - _ = _storageService.RecordUserLoginAsync(tenantId, upn, displayName, objectId) + _ = _metricsRepo.RecordUserLoginAsync(tenantId, upn, displayName, objectId) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget RecordUserLoginAsync failed"), TaskContinuationOptions.OnlyOnFaulted); var userInfo = new diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs index 961c7ad6..fdab016a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs @@ -4,6 +4,7 @@ using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Functions.Services.Notifications; using AutopilotMonitor.Functions.Services.Vulnerability; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Models.Notifications; using Microsoft.Azure.Functions.Worker.Http; @@ -128,7 +129,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest StampServerFields(request.Events, request.TenantId, request.SessionId, receivedAt); // Store events in Azure Table Storage (batch write for efficiency) - var storedEvents = await _storageService.StoreEventsBatchAsync(request.Events); + var storedEvents = await _sessionRepo.StoreEventsBatchAsync(request.Events); int processedCount = storedEvents.Count; // Update EventTypeIndex and DeviceSnapshot indexes (fire-and-forget, non-blocking) @@ -136,10 +137,10 @@ namespace AutopilotMonitor.Functions.Functions.Ingest var indexSessionId = request.SessionId; var indexEvents = storedEvents.ToList(); _ = Task.WhenAll( - _storageService.UpsertEventTypeIndexBatchAsync( + _sessionRepo.UpsertEventTypeIndexBatchAsync( indexTenantId, indexSessionId, indexEvents.Select(e => e.EventType).Distinct()), - _storageService.UpsertDeviceSnapshotAsync( + _sessionRepo.UpsertDeviceSnapshotAsync( indexTenantId, indexSessionId, indexEvents) ).ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Index update failed (non-fatal)"), TaskContinuationOptions.OnlyOnFaulted); @@ -152,7 +153,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest var imeVersion = imeVersionEvent.Data["agentVersion"]?.ToString(); if (!string.IsNullOrEmpty(imeVersion)) { - _ = _storageService.UpdateSessionImeAgentVersionAsync(request.TenantId, request.SessionId, imeVersion) + _ = _sessionRepo.UpdateSessionImeAgentVersionAsync(request.TenantId, request.SessionId, imeVersion) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "ImeAgentVersion update failed (non-fatal)"), TaskContinuationOptions.OnlyOnFaulted); } @@ -164,7 +165,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest // Store app install summaries foreach (var summary in classification.AppInstallUpdates.Values) { - await _storageService.StoreAppInstallSummaryAsync(summary.Summary); + await _metricsRepo.StoreAppInstallSummaryAsync(summary.Summary); } // Extract geo-location data and merge into session row (fire-and-forget — @@ -174,7 +175,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest var geoData = classification.DeviceLocationEvent.Data; var geoTenantId = request.TenantId; var geoSessionId = request.SessionId; - _ = _storageService.UpdateSessionGeoAsync( + _ = _sessionRepo.UpdateSessionGeoAsync( geoTenantId, geoSessionId, geoData.ContainsKey("country") ? geoData["country"]?.ToString() : null, @@ -192,7 +193,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest // Always increment event count when events were stored if (processedCount > 0) { - await _storageService.IncrementSessionEventCountAsync( + await _sessionRepo.IncrementSessionEventCountAsync( request.TenantId, request.SessionId, processedCount, @@ -216,12 +217,12 @@ namespace AutopilotMonitor.Functions.Functions.Ingest { try { - var ruleEngine = new RuleEngine(_analyzeRuleService, _storageService, _logger); + var ruleEngine = new RuleEngine(_analyzeRuleService, _ruleRepo, _sessionRepo, _logger); var results = await ruleEngine.AnalyzeSessionAsync(ruleTenantId, ruleSessionId); foreach (var result in results) { - await _storageService.StoreRuleResultAsync(result); + await _ruleRepo.StoreRuleResultAsync(result); } if (results.Count > 0) @@ -237,7 +238,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest // Increment platform stats for detected issues if (results.Count > 0) { - _ = _storageService.IncrementPlatformStatAsync("IssuesDetected", results.Count) + _ = _metricsRepo.IncrementPlatformStatAsync("IssuesDetected", results.Count) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); } @@ -307,7 +308,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest if (reportData != null) { - await _storageService.StoreVulnerabilityReportAsync( + await _vulnRepo.StoreVulnerabilityReportAsync( capturedTenantId, capturedSessionId, reportData); _logger.LogInformation("{Prefix} Vulnerability correlation complete (async)", capturedPrefix); @@ -317,7 +318,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest : null; if (findings != null && findings.Count > 0) { - _ = _storageService.UpsertCveIndexEntriesAsync(capturedTenantId, capturedSessionId, findings) + _ = _sessionRepo.UpsertCveIndexEntriesAsync(capturedTenantId, capturedSessionId, findings) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "CveIndex update failed (non-fatal)"), TaskContinuationOptions.OnlyOnFaulted); } @@ -342,10 +343,10 @@ namespace AutopilotMonitor.Functions.Functions.Ingest // Increment platform stats (fire-and-forget, non-blocking) // Note: IssuesDetected is now incremented inside the async rule engine task above. - _ = _storageService.IncrementPlatformStatAsync("TotalEventsProcessed", processedCount) + _ = _metricsRepo.IncrementPlatformStatAsync("TotalEventsProcessed", processedCount) .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); if (classification.CompletionEvent != null) - _ = _storageService.IncrementPlatformStatAsync("SuccessfulEnrollments") + _ = _metricsRepo.IncrementPlatformStatAsync("SuccessfulEnrollments") .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); // Store diagnostics blob name on session (if agent uploaded a diagnostics package) @@ -356,13 +357,13 @@ namespace AutopilotMonitor.Functions.Functions.Ingest : null; if (!string.IsNullOrEmpty(blobName)) { - await _storageService.UpdateSessionDiagnosticsBlobAsync( + await _sessionRepo.UpdateSessionDiagnosticsBlobAsync( request.TenantId, request.SessionId, blobName); } } // Retrieve updated session data to include in SignalR messages - var updatedSession = await _storageService.GetSessionAsync(request.TenantId, request.SessionId); + var updatedSession = await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId); // Session age warning: log if session >4h old and still InProgress (observability only) if (updatedSession != null && updatedSession.Status == SessionStatus.InProgress) @@ -489,7 +490,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest if (c.CompletionEvent != null) { - statusTransitioned = await _storageService.UpdateSessionStatusAsync( + statusTransitioned = await _sessionRepo.UpdateSessionStatusAsync( request.TenantId, request.SessionId, SessionStatus.Succeeded, c.CompletionEvent.Phase, completedAt: c.CompletionEvent.Timestamp, earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp); @@ -501,7 +502,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest ? $"{c.FailureEvent.Message} ({c.FailureEvent.Data["errorCode"]})" : c.FailureEvent.Message; - statusTransitioned = await _storageService.UpdateSessionStatusAsync( + statusTransitioned = await _sessionRepo.UpdateSessionStatusAsync( request.TenantId, request.SessionId, SessionStatus.Failed, c.FailureEvent.Phase, failureReason, completedAt: c.FailureEvent.Timestamp, earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp); @@ -510,7 +511,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest else if (c.EspFailureEvent != null) { failureReason = c.EspFailureEvent.Message ?? "ESP failure (backend fallback)"; - statusTransitioned = await _storageService.UpdateSessionStatusAsync( + statusTransitioned = await _sessionRepo.UpdateSessionStatusAsync( request.TenantId, request.SessionId, SessionStatus.Failed, c.EspFailureEvent.Phase, failureReason, completedAt: c.EspFailureEvent.Timestamp, earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp); @@ -519,7 +520,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest } else if (c.GatherCompletionEvent != null) { - await _storageService.UpdateSessionStatusAsync( + await _sessionRepo.UpdateSessionStatusAsync( request.TenantId, request.SessionId, SessionStatus.Succeeded, c.GatherCompletionEvent.Phase, completedAt: c.GatherCompletionEvent.Timestamp, earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp); @@ -527,7 +528,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest } else if (c.WhiteGloveEvent != null) { - whiteGloveStatusTransitioned = await _storageService.UpdateSessionStatusAsync( + whiteGloveStatusTransitioned = await _sessionRepo.UpdateSessionStatusAsync( request.TenantId, request.SessionId, SessionStatus.Pending, c.WhiteGloveEvent.Phase, earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp, isPreProvisioned: true, isUserDriven: false); @@ -537,7 +538,7 @@ namespace AutopilotMonitor.Functions.Functions.Ingest _logger.LogWarning("{SessionPrefix} WhiteGlove UpdateSessionStatusAsync failed, attempting unconditional fallback for IsPreProvisioned + Status", sessionPrefix); try { - await _storageService.SetSessionPreProvisionedAsync(request.TenantId, request.SessionId, true, SessionStatus.Pending, isUserDriven: false); + await _sessionRepo.SetSessionPreProvisionedAsync(request.TenantId, request.SessionId, true, SessionStatus.Pending, isUserDriven: false); whiteGloveStatusTransitioned = true; _logger.LogInformation("{SessionPrefix} WhiteGlove fallback succeeded: IsPreProvisioned + Status=Pending set via unconditional merge", sessionPrefix); } @@ -551,10 +552,10 @@ namespace AutopilotMonitor.Functions.Functions.Ingest } else if (c.WhiteGloveResumedEvent != null) { - var currentSession = await _storageService.GetSessionAsync(request.TenantId, request.SessionId); + var currentSession = await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId); if (currentSession?.Status == SessionStatus.Pending) { - await _storageService.UpdateSessionStatusAsync( + await _sessionRepo.UpdateSessionStatusAsync( request.TenantId, request.SessionId, SessionStatus.InProgress, c.WhiteGloveResumedEvent.Phase, earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp, isUserDriven: true, resumedAt: c.WhiteGloveResumedEvent.Timestamp); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.cs index c1575685..286720c3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.cs @@ -3,6 +3,7 @@ using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Functions.Services.Notifications; using AutopilotMonitor.Functions.Services.Vulnerability; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -14,7 +15,11 @@ namespace AutopilotMonitor.Functions.Functions.Ingest public partial class IngestEventsFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly ISessionRepository _sessionRepo; + private readonly IMetricsRepository _metricsRepo; + private readonly IMaintenanceRepository _maintenanceRepo; + private readonly IRuleRepository _ruleRepo; + private readonly IVulnerabilityRepository _vulnRepo; private readonly TenantConfigurationService _configService; private readonly RateLimitService _rateLimitService; private readonly AutopilotDeviceValidator _autopilotDeviceValidator; @@ -30,7 +35,11 @@ namespace AutopilotMonitor.Functions.Functions.Ingest public IngestEventsFunction( ILogger logger, - TableStorageService storageService, + ISessionRepository sessionRepo, + IMetricsRepository metricsRepo, + IMaintenanceRepository maintenanceRepo, + IRuleRepository ruleRepo, + IVulnerabilityRepository vulnRepo, TenantConfigurationService configService, RateLimitService rateLimitService, AutopilotDeviceValidator autopilotDeviceValidator, @@ -45,7 +54,11 @@ namespace AutopilotMonitor.Functions.Functions.Ingest SignalRNotificationService signalRNotification) { _logger = logger; - _storageService = storageService; + _sessionRepo = sessionRepo; + _metricsRepo = metricsRepo; + _maintenanceRepo = maintenanceRepo; + _ruleRepo = ruleRepo; + _vulnRepo = vulnRepo; _configService = configService; _rateLimitService = rateLimitService; _autopilotDeviceValidator = autopilotDeviceValidator; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs index 0f27bc37..bd15970c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetAppMetricsFunction.cs @@ -1,6 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; -using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -10,12 +10,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics public class GetAppMetricsFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly IMetricsRepository _metricsRepo; - public GetAppMetricsFunction(ILogger logger, TableStorageService storageService) + public GetAppMetricsFunction(ILogger logger, IMetricsRepository metricsRepo) { _logger = logger; - _storageService = storageService; + _metricsRepo = metricsRepo; } [Function("GetAppMetrics")] @@ -37,7 +37,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var cutoff = DateTime.UtcNow.AddDays(-days); - var allSummaries = await _storageService.GetAppInstallSummariesByTenantAsync(tenantId); + var allSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId); var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList(); // Aggregate: slowest apps (avg duration) and top failing apps diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs index 3f4e6039..f1593274 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicLocationSessionsFunction.cs @@ -1,6 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; -using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -11,12 +11,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics public class GetGeographicLocationSessionsFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly IMaintenanceRepository _maintenanceRepo; - public GetGeographicLocationSessionsFunction(ILogger logger, TableStorageService storageService) + public GetGeographicLocationSessionsFunction(ILogger logger, IMaintenanceRepository maintenanceRepo) { _logger = logger; - _storageService = storageService; + _maintenanceRepo = maintenanceRepo; } [Function("GetGeographicLocationSessions")] @@ -48,7 +48,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics locationKey, tenantId, days, groupBy); var cutoff = DateTime.UtcNow.AddDays(-days); - var sessions = await _storageService.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), tenantId); + var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), tenantId); var filtered = FilterSessionsByLocation(sessions, locationKey, groupBy); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs index 4e60e8ab..166098a8 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGeographicMetricsFunction.cs @@ -1,6 +1,7 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -11,13 +12,15 @@ namespace AutopilotMonitor.Functions.Functions.Metrics public class GetGeographicMetricsFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly IMaintenanceRepository _maintenanceRepo; + private readonly IMetricsRepository _metricsRepo; private readonly TenantConfigurationService _configService; - public GetGeographicMetricsFunction(ILogger logger, TableStorageService storageService, TenantConfigurationService configService) + public GetGeographicMetricsFunction(ILogger logger, IMaintenanceRepository maintenanceRepo, IMetricsRepository metricsRepo, TenantConfigurationService configService) { _logger = logger; - _storageService = storageService; + _maintenanceRepo = maintenanceRepo; + _metricsRepo = metricsRepo; _configService = configService; } @@ -44,8 +47,8 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var geoEnabled = tenantConfig?.EnableGeoLocation ?? true; var cutoff = DateTime.UtcNow.AddDays(-days); - var sessions = await _storageService.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), tenantId); - var allSummaries = await _storageService.GetAppInstallSummariesByTenantAsync(tenantId); + var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1), tenantId); + var allSummaries = await _metricsRepo.GetAppInstallSummariesByTenantAsync(tenantId); var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList(); var result = ComputeGeographicMetrics(sessions, summaries, groupBy); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAppMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAppMetricsFunction.cs index 3b977a0c..7c6b386d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAppMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalAppMetricsFunction.cs @@ -1,6 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; -using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -10,14 +10,14 @@ namespace AutopilotMonitor.Functions.Functions.Metrics public class GetGlobalAppMetricsFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly IMetricsRepository _metricsRepo; public GetGlobalAppMetricsFunction( ILogger logger, - TableStorageService storageService) + IMetricsRepository metricsRepo) { _logger = logger; - _storageService = storageService; + _metricsRepo = metricsRepo; } [Function("GetGlobalAppMetrics")] @@ -39,7 +39,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var cutoff = DateTime.UtcNow.AddDays(-days); - var allSummaries = await _storageService.GetAllAppInstallSummariesAsync(); + var allSummaries = await _metricsRepo.GetAllAppInstallSummariesAsync(); var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList(); var appGroups = summaries.GroupBy(s => s.AppName).Select(g => diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs index 18eee9da..dc164cd5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicLocationSessionsFunction.cs @@ -1,6 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; -using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -10,14 +10,14 @@ namespace AutopilotMonitor.Functions.Functions.Metrics public class GetGlobalGeographicLocationSessionsFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly IMaintenanceRepository _maintenanceRepo; public GetGlobalGeographicLocationSessionsFunction( ILogger logger, - TableStorageService storageService) + IMaintenanceRepository maintenanceRepo) { _logger = logger; - _storageService = storageService; + _maintenanceRepo = maintenanceRepo; } [Function("GetGlobalGeographicLocationSessions")] @@ -49,7 +49,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics locationKey, days, groupBy, userEmail); var cutoff = DateTime.UtcNow.AddDays(-days); - var sessions = await _storageService.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1)); + var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1)); var filtered = GetGeographicLocationSessionsFunction.FilterSessionsByLocation(sessions, locationKey, groupBy); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicMetricsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicMetricsFunction.cs index 2fb00b32..30516afd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicMetricsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetGlobalGeographicMetricsFunction.cs @@ -1,6 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; -using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -10,14 +10,17 @@ namespace AutopilotMonitor.Functions.Functions.Metrics public class GetGlobalGeographicMetricsFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly IMaintenanceRepository _maintenanceRepo; + private readonly IMetricsRepository _metricsRepo; public GetGlobalGeographicMetricsFunction( ILogger logger, - TableStorageService storageService) + IMaintenanceRepository maintenanceRepo, + IMetricsRepository metricsRepo) { _logger = logger; - _storageService = storageService; + _maintenanceRepo = maintenanceRepo; + _metricsRepo = metricsRepo; } [Function("GetGlobalGeographicMetrics")] @@ -40,8 +43,8 @@ namespace AutopilotMonitor.Functions.Functions.Metrics var groupBy = query["groupBy"] ?? "city"; var cutoff = DateTime.UtcNow.AddDays(-days); - var sessions = await _storageService.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1)); - var allSummaries = await _storageService.GetAllAppInstallSummariesAsync(); + var sessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(cutoff, DateTime.UtcNow.AddDays(1)); + var allSummaries = await _metricsRepo.GetAllAppInstallSummariesAsync(); var summaries = allSummaries.Where(s => s.StartedAt >= cutoff).ToList(); var result = GetGeographicMetricsFunction.ComputeGeographicMetrics(sessions, summaries, groupBy); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetPlatformStatsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetPlatformStatsFunction.cs index 97817870..8599f497 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetPlatformStatsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/GetPlatformStatsFunction.cs @@ -1,5 +1,5 @@ using System.Net; -using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -13,12 +13,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics public class GetPlatformStatsFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly IMetricsRepository _metricsRepo; - public GetPlatformStatsFunction(ILogger logger, TableStorageService storageService) + public GetPlatformStatsFunction(ILogger logger, IMetricsRepository metricsRepo) { _logger = logger; - _storageService = storageService; + _metricsRepo = metricsRepo; } [Function("GetPlatformStats")] @@ -27,7 +27,7 @@ namespace AutopilotMonitor.Functions.Functions.Metrics { try { - var stats = await _storageService.GetPlatformStatsAsync(); + var stats = await _metricsRepo.GetPlatformStatsAsync(); var response = req.CreateResponse(HttpStatusCode.OK); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/MetricsSummaryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/MetricsSummaryFunction.cs index 328a52b3..375d0c7d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/MetricsSummaryFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Metrics/MetricsSummaryFunction.cs @@ -1,6 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; -using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -10,12 +10,12 @@ namespace AutopilotMonitor.Functions.Functions.Metrics; public class MetricsSummaryFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly IMetricsRepository _metricsRepo; - public MetricsSummaryFunction(ILogger logger, TableStorageService storageService) + public MetricsSummaryFunction(ILogger logger, IMetricsRepository metricsRepo) { _logger = logger; - _storageService = storageService; + _metricsRepo = metricsRepo; } [Function("MetricsSummary")] @@ -25,7 +25,7 @@ public class MetricsSummaryFunction try { var tenantId = TenantHelper.GetTenantId(req); - var summary = await _storageService.GetMetricsSummaryAsync(tenantId); + var summary = await _metricsRepo.GetMetricsSummaryAsync(tenantId); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new { success = true, summary }); @@ -46,7 +46,7 @@ public class MetricsSummaryFunction { try { - var summary = await _storageService.GetMetricsSummaryAsync(null); + var summary = await _metricsRepo.GetMetricsSummaryAsync(null); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new { success = true, summary }); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Progress/ProgressPortalFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Progress/ProgressPortalFunction.cs index 39c53d7e..2f1abab6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Progress/ProgressPortalFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Progress/ProgressPortalFunction.cs @@ -2,6 +2,7 @@ using System.Net; using System.Web; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -16,16 +17,16 @@ namespace AutopilotMonitor.Functions.Functions.Progress; public class ProgressPortalFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly ISessionRepository _sessionRepo; private readonly GlobalAdminService _globalAdminService; public ProgressPortalFunction( ILogger logger, - TableStorageService storageService, + ISessionRepository sessionRepo, GlobalAdminService globalAdminService) { _logger = logger; - _storageService = storageService; + _sessionRepo = sessionRepo; _globalAdminService = globalAdminService; } @@ -46,7 +47,7 @@ public class ProgressPortalFunction _logger.LogInformation("ProgressGetSessions: Fetching sessions for tenant {TenantId}", tenantId); - var page = await _storageService.GetSessionsAsync(tenantId, maxResults: 100); + var page = await _sessionRepo.GetSessionsAsync(tenantId, maxResults: 100); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new @@ -137,7 +138,7 @@ public class ProgressPortalFunction sessionPrefix, userIdentifier, requestedTenantId); } - var events = await _storageService.GetSessionEventsAsync(requestedTenantId, sessionId); + var events = await _sessionRepo.GetSessionEventsAsync(requestedTenantId, sessionId); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/SubmitSessionReportFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/SubmitSessionReportFunction.cs index fa0cb6ae..2846a200 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Reports/SubmitSessionReportFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Reports/SubmitSessionReportFunction.cs @@ -1,6 +1,7 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -12,7 +13,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports { private readonly ILogger _logger; private readonly SessionReportService _sessionReportService; - private readonly TableStorageService _storageService; + private readonly IMaintenanceRepository _maintenanceRepo; private readonly GlobalAdminService _globalAdminService; private readonly TelegramNotificationService _telegramNotificationService; private readonly GlobalNotificationService _globalNotificationService; @@ -20,14 +21,14 @@ namespace AutopilotMonitor.Functions.Functions.Reports public SubmitSessionReportFunction( ILogger logger, SessionReportService sessionReportService, - TableStorageService storageService, + IMaintenanceRepository maintenanceRepo, GlobalAdminService globalAdminService, TelegramNotificationService telegramNotificationService, GlobalNotificationService globalNotificationService) { _logger = logger; _sessionReportService = sessionReportService; - _storageService = storageService; + _maintenanceRepo = maintenanceRepo; _globalAdminService = globalAdminService; _telegramNotificationService = telegramNotificationService; _globalNotificationService = globalNotificationService; @@ -85,7 +86,7 @@ namespace AutopilotMonitor.Functions.Functions.Reports // Log audit entry — skip for Global Admins if (!isGlobalAdmin) { - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( request.TenantId, "CREATE", "SessionReport", diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GetRuleResultsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GetRuleResultsFunction.cs index 4d2ee230..5c897d42 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GetRuleResultsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/GetRuleResultsFunction.cs @@ -2,6 +2,7 @@ using System.Net; using System.Web; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -16,18 +17,24 @@ namespace AutopilotMonitor.Functions.Functions.Rules public class GetRuleResultsFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly IRuleRepository _ruleRepo; + private readonly IMaintenanceRepository _maintenanceRepo; + private readonly ISessionRepository _sessionRepo; private readonly AnalyzeRuleService _analyzeRuleService; private readonly GlobalAdminService _globalAdminService; public GetRuleResultsFunction( ILogger logger, - TableStorageService storageService, + IRuleRepository ruleRepo, + IMaintenanceRepository maintenanceRepo, + ISessionRepository sessionRepo, AnalyzeRuleService analyzeRuleService, GlobalAdminService globalAdminService) { _logger = logger; - _storageService = storageService; + _ruleRepo = ruleRepo; + _maintenanceRepo = maintenanceRepo; + _sessionRepo = sessionRepo; _analyzeRuleService = analyzeRuleService; _globalAdminService = globalAdminService; } @@ -66,15 +73,15 @@ namespace AutopilotMonitor.Functions.Functions.Rules { try { - var ruleEngine = new RuleEngine(_analyzeRuleService, _storageService, _logger); + var ruleEngine = new RuleEngine(_analyzeRuleService, _ruleRepo, _sessionRepo, _logger); var freshResults = await ruleEngine.AnalyzeSessionAsync(effectiveTenantId, sessionId, reanalyze: true); // Delete existing results so stale entries don't persist after re-analysis - await _storageService.DeleteSessionRuleResultsAsync(effectiveTenantId, sessionId); + await _maintenanceRepo.DeleteSessionRuleResultsAsync(effectiveTenantId, sessionId); foreach (var result in freshResults) { - await _storageService.StoreRuleResultAsync(result); + await _ruleRepo.StoreRuleResultAsync(result); } _logger.LogInformation($"On-demand re-analysis for session {sessionId}: {freshResults.Count} issue(s) detected"); @@ -85,7 +92,7 @@ namespace AutopilotMonitor.Functions.Functions.Rules } } - var results = await _storageService.GetRuleResultsAsync(effectiveTenantId, sessionId); + var results = await _ruleRepo.GetRuleResultsAsync(effectiveTenantId, sessionId); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs index a5007afc..6170cc69 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Rules/PreviewWhitelistFunction.cs @@ -1,5 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Extensions; +using AutopilotMonitor.Functions.DataAccess.TableStorage; using AutopilotMonitor.Functions.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.Azure.Functions.Worker; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/DeleteSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/DeleteSessionFunction.cs index 9f239f4d..bd76848c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/DeleteSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/DeleteSessionFunction.cs @@ -1,6 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; -using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -10,14 +10,17 @@ namespace AutopilotMonitor.Functions.Functions.Sessions public class DeleteSessionFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly ISessionRepository _sessionRepo; + private readonly IMaintenanceRepository _maintenanceRepo; public DeleteSessionFunction( ILogger logger, - TableStorageService storageService) + ISessionRepository sessionRepo, + IMaintenanceRepository maintenanceRepo) { _logger = logger; - _storageService = storageService; + _sessionRepo = sessionRepo; + _maintenanceRepo = maintenanceRepo; } [Function("DeleteSession")] @@ -36,22 +39,22 @@ namespace AutopilotMonitor.Functions.Functions.Sessions _logger.LogInformation($"Deleting session {sessionId} for tenant {tenantId} by user {userIdentifier}"); // Delete all related data for this session - var eventsDeleted = await _storageService.DeleteSessionEventsAsync(tenantId, sessionId); + var eventsDeleted = await _maintenanceRepo.DeleteSessionEventsAsync(tenantId, sessionId); _logger.LogInformation($"Deleted {eventsDeleted} events for session {sessionId}"); - var ruleResultsDeleted = await _storageService.DeleteSessionRuleResultsAsync(tenantId, sessionId); + var ruleResultsDeleted = await _maintenanceRepo.DeleteSessionRuleResultsAsync(tenantId, sessionId); _logger.LogInformation($"Deleted {ruleResultsDeleted} rule results for session {sessionId}"); - var appSummariesDeleted = await _storageService.DeleteSessionAppInstallSummariesAsync(tenantId, sessionId); + var appSummariesDeleted = await _maintenanceRepo.DeleteSessionAppInstallSummariesAsync(tenantId, sessionId); _logger.LogInformation($"Deleted {appSummariesDeleted} app install summaries for session {sessionId}"); // Delete the session itself - var sessionDeleted = await _storageService.DeleteSessionAsync(tenantId, sessionId); + var sessionDeleted = await _sessionRepo.DeleteSessionAsync(tenantId, sessionId); if (sessionDeleted) { // Log audit entry with actual user identifier - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( tenantId, "DELETE", "Session", diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs index 22250af3..2bed50a2 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetAllSessionsFunction.cs @@ -1,6 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; -using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -11,14 +11,14 @@ namespace AutopilotMonitor.Functions.Functions.Sessions public class GetAllSessionsFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly ISessionRepository _sessionRepo; public GetAllSessionsFunction( ILogger logger, - TableStorageService storageService) + ISessionRepository sessionRepo) { _logger = logger; - _storageService = storageService; + _sessionRepo = sessionRepo; } [Function("GetAllSessions")] @@ -47,12 +47,12 @@ namespace AutopilotMonitor.Functions.Functions.Sessions } _logger.LogInformation("Fetching sessions for tenant {TenantId} (User: {UserEmail}, cursor: {Cursor})", tenantIdFilter, userEmail, cursor ?? "none"); - page = await _storageService.GetSessionsAsync(tenantIdFilter, maxResults: 100, cursor: cursor); + page = await _sessionRepo.GetSessionsAsync(tenantIdFilter, maxResults: 100, cursor: cursor); } else { _logger.LogInformation("Fetching all sessions across all tenants (User: {UserEmail}, cursor: {Cursor})", userEmail, cursor ?? "none"); - page = await _storageService.GetAllSessionsAsync(maxResults: 100, cursor: cursor); + page = await _sessionRepo.GetAllSessionsAsync(maxResults: 100, cursor: cursor); } var response = req.CreateResponse(HttpStatusCode.OK); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs index ea096211..d6d2262d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionEventsFunction.cs @@ -2,6 +2,7 @@ using System.Net; using System.Web; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -11,16 +12,16 @@ namespace AutopilotMonitor.Functions.Functions.Sessions public class GetSessionEventsFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly ISessionRepository _sessionRepo; private readonly GlobalAdminService _globalAdminService; public GetSessionEventsFunction( ILogger logger, - TableStorageService storageService, + ISessionRepository sessionRepo, GlobalAdminService globalAdminService) { _logger = logger; - _storageService = storageService; + _sessionRepo = sessionRepo; _globalAdminService = globalAdminService; } @@ -92,7 +93,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions } // Get events from storage using the requested tenant ID - var events = await _storageService.GetSessionEventsAsync(requestedTenantId, sessionId); + var events = await _sessionRepo.GetSessionEventsAsync(requestedTenantId, sessionId); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionFunction.cs index 4464d0d3..a3f823c8 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionFunction.cs @@ -2,6 +2,7 @@ using System.Net; using System.Web; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -11,16 +12,16 @@ namespace AutopilotMonitor.Functions.Functions.Sessions public class GetSessionFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly ISessionRepository _sessionRepo; private readonly GlobalAdminService _globalAdminService; public GetSessionFunction( ILogger logger, - TableStorageService storageService, + ISessionRepository sessionRepo, GlobalAdminService globalAdminService) { _logger = logger; - _storageService = storageService; + _sessionRepo = sessionRepo; _globalAdminService = globalAdminService; } @@ -78,7 +79,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions } } - var session = await _storageService.GetSessionAsync(effectiveTenantId, sessionId); + var session = await _sessionRepo.GetSessionAsync(effectiveTenantId, sessionId); if (session == null) { var notFound = req.CreateResponse(HttpStatusCode.NotFound); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionsFunction.cs index ad4afd54..979ca08d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/GetSessionsFunction.cs @@ -1,6 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; -using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -10,12 +10,12 @@ namespace AutopilotMonitor.Functions.Functions.Sessions public class GetSessionsFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly ISessionRepository _sessionRepo; - public GetSessionsFunction(ILogger logger, TableStorageService storageService) + public GetSessionsFunction(ILogger logger, ISessionRepository sessionRepo) { _logger = logger; - _storageService = storageService; + _sessionRepo = sessionRepo; } [Function("GetSessions")] @@ -33,7 +33,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions _logger.LogInformation("Fetching sessions for tenant {TenantId} (cursor: {Cursor})", tenantId, cursor ?? "none"); - var page = await _storageService.GetSessionsAsync(tenantId, maxResults: 100, cursor: cursor); + var page = await _sessionRepo.GetSessionsAsync(tenantId, maxResults: 100, cursor: cursor); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionFailedFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionFailedFunction.cs index c15ab824..b7ed0afc 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionFailedFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionFailedFunction.cs @@ -1,6 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; -using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -12,14 +12,17 @@ namespace AutopilotMonitor.Functions.Functions.Sessions public class MarkSessionFailedFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly ISessionRepository _sessionRepo; + private readonly IMaintenanceRepository _maintenanceRepo; public MarkSessionFailedFunction( ILogger logger, - TableStorageService storageService) + ISessionRepository sessionRepo, + IMaintenanceRepository maintenanceRepo) { _logger = logger; - _storageService = storageService; + _sessionRepo = sessionRepo; + _maintenanceRepo = maintenanceRepo; } [Function("MarkSessionFailed")] @@ -38,7 +41,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions _logger.LogInformation($"Marking session {sessionId} as failed for tenant {tenantId} by user {userIdentifier}"); // Update session status to Failed with manual failure reason - var success = await _storageService.UpdateSessionStatusAsync( + var success = await _sessionRepo.UpdateSessionStatusAsync( tenantId, sessionId, SessionStatus.Failed, @@ -49,7 +52,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions if (success) { // Log audit entry with actual user identifier - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( tenantId, "UPDATE", "Session", @@ -63,7 +66,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions ); // Retrieve updated session data to include in SignalR message - var updatedSession = await _storageService.GetSessionAsync(tenantId, sessionId); + var updatedSession = await _sessionRepo.GetSessionAsync(tenantId, sessionId); _logger.LogInformation($"Successfully marked session {sessionId} as failed"); var response = req.CreateResponse(HttpStatusCode.OK); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionSucceededFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionSucceededFunction.cs index b44a0990..150abc7b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionSucceededFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/MarkSessionSucceededFunction.cs @@ -1,6 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; -using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -12,14 +12,17 @@ namespace AutopilotMonitor.Functions.Functions.Sessions public class MarkSessionSucceededFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly ISessionRepository _sessionRepo; + private readonly IMaintenanceRepository _maintenanceRepo; public MarkSessionSucceededFunction( ILogger logger, - TableStorageService storageService) + ISessionRepository sessionRepo, + IMaintenanceRepository maintenanceRepo) { _logger = logger; - _storageService = storageService; + _sessionRepo = sessionRepo; + _maintenanceRepo = maintenanceRepo; } [Function("MarkSessionSucceeded")] @@ -38,7 +41,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions _logger.LogInformation($"Marking session {sessionId} as succeeded for tenant {tenantId} by user {userIdentifier}"); // Update session status to Succeeded with manual reason - var success = await _storageService.UpdateSessionStatusAsync( + var success = await _sessionRepo.UpdateSessionStatusAsync( tenantId, sessionId, SessionStatus.Succeeded, @@ -49,7 +52,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions if (success) { // Log audit entry with actual user identifier - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( tenantId, "UPDATE", "Session", @@ -63,7 +66,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions ); // Retrieve updated session data to include in SignalR message - var updatedSession = await _storageService.GetSessionAsync(tenantId, sessionId); + var updatedSession = await _sessionRepo.GetSessionAsync(tenantId, sessionId); _logger.LogInformation($"Successfully marked session {sessionId} as succeeded"); var response = req.CreateResponse(HttpStatusCode.OK); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs index 8a5a87e2..5d62893d 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/RegisterSessionFunction.cs @@ -1,6 +1,7 @@ using System.Net; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -13,7 +14,8 @@ namespace AutopilotMonitor.Functions.Functions.Sessions public class RegisterSessionFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly ISessionRepository _sessionRepo; + private readonly IMetricsRepository _metricsRepo; private readonly TenantConfigurationService _configService; private readonly RateLimitService _rateLimitService; private readonly AutopilotDeviceValidator _autopilotDeviceValidator; @@ -22,7 +24,8 @@ namespace AutopilotMonitor.Functions.Functions.Sessions public RegisterSessionFunction( ILogger logger, - TableStorageService storageService, + ISessionRepository sessionRepo, + IMetricsRepository metricsRepo, TenantConfigurationService configService, RateLimitService rateLimitService, AutopilotDeviceValidator autopilotDeviceValidator, @@ -30,7 +33,8 @@ namespace AutopilotMonitor.Functions.Functions.Sessions BootstrapSessionService bootstrapSessionService) { _logger = logger; - _storageService = storageService; + _sessionRepo = sessionRepo; + _metricsRepo = metricsRepo; _configService = configService; _rateLimitService = rateLimitService; _autopilotDeviceValidator = autopilotDeviceValidator; @@ -101,7 +105,7 @@ namespace AutopilotMonitor.Functions.Functions.Sessions _logger.LogInformation($"Registering session {registration.SessionId} for tenant {registration.TenantId} (Device: {validation.CertificateThumbprint})"); // Store session in Azure Table Storage - var stored = await _storageService.StoreSessionAsync(registration); + var stored = await _sessionRepo.StoreSessionAsync(registration); if (!stored) { @@ -110,11 +114,11 @@ namespace AutopilotMonitor.Functions.Functions.Sessions } // Increment platform stats (fire-and-forget, non-blocking) - _ = _storageService.IncrementPlatformStatAsync("TotalEnrollments") + _ = _metricsRepo.IncrementPlatformStatAsync("TotalEnrollments") .ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException, "Fire-and-forget IncrementPlatformStatAsync failed"), TaskContinuationOptions.OnlyOnFaulted); // Retrieve the stored session to include full data in SignalR message - var session = await _storageService.GetSessionAsync(registration.TenantId, registration.SessionId); + var session = await _sessionRepo.GetSessionAsync(registration.TenantId, registration.SessionId); // Check if admin has already marked this session as terminal (e.g., agent restarted after admin override) string? adminAction = null; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsByCveFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsByCveFunction.cs index 27cebd16..bb022558 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsByCveFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsByCveFunction.cs @@ -1,6 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; -using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -10,12 +10,12 @@ namespace AutopilotMonitor.Functions.Functions.Sessions; public class SearchSessionsByCveFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly ISessionRepository _sessionRepo; - public SearchSessionsByCveFunction(ILogger logger, TableStorageService storageService) + public SearchSessionsByCveFunction(ILogger logger, ISessionRepository sessionRepo) { _logger = logger; - _storageService = storageService; + _sessionRepo = sessionRepo; } [Function("SearchSessionsByCve")] @@ -56,7 +56,7 @@ public class SearchSessionsByCveFunction var overallRisk = query["overallRisk"]; var limit = int.TryParse(query["limit"], out var lim) ? Math.Min(lim, 100) : 50; - var sessions = await _storageService.SearchSessionsByCveAsync(tenantId, cveId, minCvssScore, overallRisk, limit); + var sessions = await _sessionRepo.SearchSessionsByCveAsync(tenantId, cveId, minCvssScore, overallRisk, limit); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsByEventFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsByEventFunction.cs index 92bc87db..a18acb12 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsByEventFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsByEventFunction.cs @@ -1,6 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; -using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -10,12 +10,12 @@ namespace AutopilotMonitor.Functions.Functions.Sessions; public class SearchSessionsByEventFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly ISessionRepository _sessionRepo; - public SearchSessionsByEventFunction(ILogger logger, TableStorageService storageService) + public SearchSessionsByEventFunction(ILogger logger, ISessionRepository sessionRepo) { _logger = logger; - _storageService = storageService; + _sessionRepo = sessionRepo; } [Function("SearchSessionsByEvent")] @@ -54,7 +54,7 @@ public class SearchSessionsByEventFunction var limit = int.TryParse(query["limit"], out var lim) ? Math.Min(lim, 100) : 50; - var sessions = await _storageService.SearchSessionsByEventAsync(tenantId, eventType, null, null, null, limit); + var sessions = await _sessionRepo.SearchSessionsByEventAsync(tenantId, eventType, null, null, null, limit); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs index d077290b..88153032 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Sessions/SearchSessionsFunction.cs @@ -1,6 +1,6 @@ using System.Net; using AutopilotMonitor.Functions.Helpers; -using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; @@ -11,12 +11,12 @@ namespace AutopilotMonitor.Functions.Functions.Sessions; public class SearchSessionsFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly ISessionRepository _sessionRepo; - public SearchSessionsFunction(ILogger logger, TableStorageService storageService) + public SearchSessionsFunction(ILogger logger, ISessionRepository sessionRepo) { _logger = logger; - _storageService = storageService; + _sessionRepo = sessionRepo; } [Function("SearchSessions")] @@ -82,7 +82,7 @@ public class SearchSessionsFunction if (deviceProperties.Count > 0) filter.DeviceProperties = deviceProperties; - var sessions = await _storageService.SearchSessionsAsync(tenantId, filter); + var sessions = await _sessionRepo.SearchSessionsAsync(tenantId, filter); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/DeleteCustomCpeMappingFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/DeleteCustomCpeMappingFunction.cs index 64c7a088..e6aa5b2c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/DeleteCustomCpeMappingFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/DeleteCustomCpeMappingFunction.cs @@ -3,10 +3,8 @@ using System.Linq; using System.Net; using System.Text.Json; using System.Threading.Tasks; -using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Functions.Services.Vulnerability; -using AutopilotMonitor.Shared; -using Azure; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -15,14 +13,14 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability { public class DeleteCustomCpeMappingFunction { - private readonly TableStorageService _storageService; + private readonly IVulnerabilityRepository _vulnRepo; private readonly ILogger _logger; public DeleteCustomCpeMappingFunction( - TableStorageService storageService, + IVulnerabilityRepository vulnRepo, ILogger logger) { - _storageService = storageService; + _vulnRepo = vulnRepo; _logger = logger; } @@ -54,19 +52,20 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability var rowKey = $"{(request.NormalizedVendor ?? "unknown")}:{request.NormalizedProduct}".ToLowerInvariant(); rowKey = rowKey.Replace("/", "_").Replace("\\", "_").Replace("#", "_").Replace("?", "_"); - var tableClient = _storageService.GetTableClient(Constants.TableNames.VulnerabilityCache); + // Check if mapping exists before deleting (repository silently ignores 404) + var existingMappings = await _vulnRepo.GetCpeMappingsByPartitionAsync("cpe_map_custom"); + var exists = existingMappings.Any(m => + m.ContainsKey("RowKey") && string.Equals(m["RowKey"]?.ToString(), rowKey, StringComparison.Ordinal)); - try - { - await tableClient.DeleteEntityAsync("cpe_map_custom", rowKey); - } - catch (RequestFailedException ex) when (ex.Status == 404) + if (!exists) { var notFound = req.CreateResponse(HttpStatusCode.NotFound); await notFound.WriteAsJsonAsync(new { success = false, message = "Mapping not found" }); return notFound; } + await _vulnRepo.DeleteCpeMappingAsync("cpe_map_custom", rowKey); + VulnerabilityCorrelationService.ResetMappingsCache(); _logger.LogInformation("Custom CPE mapping deleted: {RowKey}", rowKey); diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetCpeMappingsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetCpeMappingsFunction.cs index 9fd54418..c913f4a6 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetCpeMappingsFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetCpeMappingsFunction.cs @@ -4,9 +4,7 @@ using System.Linq; using System.Net; using System.Text.Json; using System.Threading.Tasks; -using AutopilotMonitor.Functions.Services; -using AutopilotMonitor.Shared; -using Azure.Data.Tables; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -20,14 +18,14 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability public class GetCpeMappingsFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly IVulnerabilityRepository _vulnRepo; public GetCpeMappingsFunction( ILogger logger, - TableStorageService storageService) + IVulnerabilityRepository vulnRepo) { _logger = logger; - _storageService = storageService; + _vulnRepo = vulnRepo; } [Function("GetCpeMappings")] @@ -37,23 +35,19 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability try { // Global Admin only — enforced by PolicyEnforcementMiddleware - var tableClient = _storageService.GetTableClient(Constants.TableNames.VulnerabilityCache); - - // Same range query pattern used in VulnerabilityCorrelationService.EnsureMappingsLoadedAsync - var allEntities = tableClient.QueryAsync( - filter: "PartitionKey ge 'cpe_map' and PartitionKey lt 'cpe_mar'", - maxPerPage: 1000); + var allEntities = await _vulnRepo.GetAllCpeMappingsAsync(); var mappings = new List(); - await foreach (var entity in allEntities) + foreach (var entity in allEntities) { - var source = entity.PartitionKey switch + var partitionKey = entity.ContainsKey("PartitionKey") ? entity["PartitionKey"]?.ToString() ?? "" : ""; + var source = partitionKey switch { "cpe_map_seed" => "seed", "cpe_map_custom" => "custom", "cpe_map_community" => "community", - _ => entity.GetString("Source") ?? "unknown" + _ => entity.ContainsKey("Source") ? entity["Source"]?.ToString() ?? "unknown" : "unknown" }; List displayNamePatterns; @@ -61,7 +55,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability try { displayNamePatterns = JsonSerializer.Deserialize>( - entity.GetString("DisplayNamePatternsJson") ?? "[]") ?? new List(); + entity.ContainsKey("DisplayNamePatternsJson") ? entity["DisplayNamePatternsJson"]?.ToString() ?? "[]" : "[]") ?? new List(); } catch { @@ -70,7 +64,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability try { publisherPatterns = JsonSerializer.Deserialize>( - entity.GetString("PublisherPatternsJson") ?? "[]") ?? new List(); + entity.ContainsKey("PublisherPatternsJson") ? entity["PublisherPatternsJson"]?.ToString() ?? "[]" : "[]") ?? new List(); } catch { @@ -79,16 +73,17 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability mappings.Add(new CpeMappingDto { - NormalizedVendor = entity.GetString("NormalizedVendor") ?? "", - NormalizedProduct = entity.GetString("NormalizedProduct") ?? "", - CpeVendor = entity.GetString("CpeVendor") ?? "", - CpeProduct = entity.GetString("CpeProduct") ?? "", - CpeUri = entity.GetString("CpeUri") ?? "", - Category = entity.GetString("Category") ?? "", + NormalizedVendor = entity.ContainsKey("NormalizedVendor") ? entity["NormalizedVendor"]?.ToString() ?? "" : "", + NormalizedProduct = entity.ContainsKey("NormalizedProduct") ? entity["NormalizedProduct"]?.ToString() ?? "" : "", + CpeVendor = entity.ContainsKey("CpeVendor") ? entity["CpeVendor"]?.ToString() ?? "" : "", + CpeProduct = entity.ContainsKey("CpeProduct") ? entity["CpeProduct"]?.ToString() ?? "" : "", + CpeUri = entity.ContainsKey("CpeUri") ? entity["CpeUri"]?.ToString() ?? "" : "", + Category = entity.ContainsKey("Category") ? entity["Category"]?.ToString() ?? "" : "", DisplayNamePatterns = displayNamePatterns, PublisherPatterns = publisherPatterns, Source = source, - CreatedAt = entity.GetString("CreatedAt") ?? entity.GetString("ImportedAt") ?? "" + CreatedAt = entity.ContainsKey("CreatedAt") ? entity["CreatedAt"]?.ToString() ?? "" : + entity.ContainsKey("ImportedAt") ? entity["ImportedAt"]?.ToString() ?? "" : "" }); } diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetSoftwareInventoryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetSoftwareInventoryFunction.cs index e28b9689..e43720bd 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetSoftwareInventoryFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetSoftwareInventoryFunction.cs @@ -5,6 +5,7 @@ using System.Web; using System.Threading.Tasks; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -18,16 +19,16 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability public class GetSoftwareInventoryFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly IVulnerabilityRepository _vulnRepo; private readonly GlobalAdminService _globalAdminService; public GetSoftwareInventoryFunction( ILogger logger, - TableStorageService storageService, + IVulnerabilityRepository vulnRepo, GlobalAdminService globalAdminService) { _logger = logger; - _storageService = storageService; + _vulnRepo = vulnRepo; _globalAdminService = globalAdminService; } @@ -50,7 +51,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability return badRequest; } - var inventory = await _storageService.GetSoftwareInventoryAsync(tenantId); + var inventory = await _vulnRepo.GetSoftwareInventoryAsync(tenantId); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetUnmatchedSoftwareFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetUnmatchedSoftwareFunction.cs index a0527d01..ea1e660a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetUnmatchedSoftwareFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetUnmatchedSoftwareFunction.cs @@ -6,8 +6,7 @@ using System.Threading.Tasks; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; -using AutopilotMonitor.Shared; -using Azure.Data.Tables; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -22,16 +21,16 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability public class GetUnmatchedSoftwareFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly IVulnerabilityRepository _vulnRepo; private readonly GlobalAdminService _globalAdminService; public GetUnmatchedSoftwareFunction( ILogger logger, - TableStorageService storageService, + IVulnerabilityRepository vulnRepo, GlobalAdminService globalAdminService) { _logger = logger; - _storageService = storageService; + _vulnRepo = vulnRepo; _globalAdminService = globalAdminService; } @@ -45,37 +44,36 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability var userIdentifier = TenantHelper.GetUserIdentifier(req); // Query all SoftwareInventory entries across tenants and filter for unmatched (no CpeUri) - var tableClient = _storageService.GetTableClient(Constants.TableNames.SoftwareInventory); - var allEntities = tableClient.QueryAsync(maxPerPage: 1000); + var allEntities = await _vulnRepo.GetAllSoftwareInventoryAsync(); var unmatchedAgg = new Dictionary(StringComparer.OrdinalIgnoreCase); - await foreach (var entity in allEntities) + foreach (var entity in allEntities) { - var cpeUri = entity.GetString("CpeUri") ?? ""; + var cpeUri = entity.ContainsKey("CpeUri") ? entity["CpeUri"]?.ToString() ?? "" : ""; if (!string.IsNullOrEmpty(cpeUri)) continue; // Has a CPE mapping — not unmatched - var normalizedName = entity.GetString("NormalizedName") ?? ""; + var normalizedName = entity.ContainsKey("NormalizedName") ? entity["NormalizedName"]?.ToString() ?? "" : ""; if (string.IsNullOrWhiteSpace(normalizedName)) continue; var key = normalizedName.ToLowerInvariant(); - var sessionCount = entity.GetInt32("SessionCount") ?? 1; - var lastSeenAt = entity.GetString("LastSeenAt") ?? ""; - var lastSessionId = entity.GetString("LastSessionId") ?? ""; + var sessionCount = entity.ContainsKey("SessionCount") && entity["SessionCount"] is int sc ? sc : 1; + var lastSeenAt = entity.ContainsKey("LastSeenAt") ? entity["LastSeenAt"]?.ToString() ?? "" : ""; + var lastSessionId = entity.ContainsKey("LastSessionId") ? entity["LastSessionId"]?.ToString() ?? "" : ""; if (!unmatchedAgg.TryGetValue(key, out var existing)) { unmatchedAgg[key] = new UnmatchedSoftwareAgg { - SoftwareName = entity.GetString("DisplayName") ?? normalizedName, - Publisher = entity.GetString("Publisher") ?? "", + SoftwareName = entity.ContainsKey("DisplayName") ? entity["DisplayName"]?.ToString() ?? normalizedName : normalizedName, + Publisher = entity.ContainsKey("Publisher") ? entity["Publisher"]?.ToString() ?? "" : "", Frequency = sessionCount, LastSeenAt = lastSeenAt, ExampleSessionId = lastSessionId, - NormalizedVendor = entity.GetString("NormalizedVendor") ?? "", - NormalizedVersion = entity.GetString("NormalizedVersion") ?? "" + NormalizedVendor = entity.ContainsKey("NormalizedVendor") ? entity["NormalizedVendor"]?.ToString() ?? "" : "", + NormalizedVersion = entity.ContainsKey("NormalizedVersion") ? entity["NormalizedVersion"]?.ToString() ?? "" : "" }; } else diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetVulnerabilityReportFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetVulnerabilityReportFunction.cs index 1eb00c83..57726098 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetVulnerabilityReportFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/GetVulnerabilityReportFunction.cs @@ -4,8 +4,8 @@ using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Functions.Services.Vulnerability; using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; -using Azure.Data.Tables; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -21,18 +21,21 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability public class GetVulnerabilityReportFunction { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly IVulnerabilityRepository _vulnRepo; + private readonly ISessionRepository _sessionRepo; private readonly GlobalAdminService _globalAdminService; private readonly VulnerabilityCorrelationService _correlationService; public GetVulnerabilityReportFunction( ILogger logger, - TableStorageService storageService, + IVulnerabilityRepository vulnRepo, + ISessionRepository sessionRepo, GlobalAdminService globalAdminService, VulnerabilityCorrelationService correlationService) { _logger = logger; - _storageService = storageService; + _vulnRepo = vulnRepo; + _sessionRepo = sessionRepo; _globalAdminService = globalAdminService; _correlationService = correlationService; } @@ -90,7 +93,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability if (freshReport != null) { - await _storageService.StoreVulnerabilityReportAsync(effectiveTenantId, sessionId, freshReport); + await _vulnRepo.StoreVulnerabilityReportAsync(effectiveTenantId, sessionId, freshReport); _logger.LogInformation("Vulnerability re-scan complete for session {SessionId}", sessionId); var rescanResponse = req.CreateResponse(HttpStatusCode.OK); @@ -107,7 +110,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability } } - var report = await _storageService.GetVulnerabilityReportAsync(effectiveTenantId, sessionId); + var report = await _vulnRepo.GetVulnerabilityReportAsync(effectiveTenantId, sessionId); if (report == null) { @@ -133,28 +136,25 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability private async Task>> LoadInventoryItemsFromEventsAsync(string tenantId, string sessionId) { var items = new List>(); - var tableClient = _storageService.GetTableClient(Constants.TableNames.Events); - var partitionKey = $"{tenantId}_{sessionId}"; - var inventoryEvents = tableClient.QueryAsync( - filter: $"PartitionKey eq '{partitionKey}' and EventType eq '{Constants.EventTypes.SoftwareInventoryAnalysis}'"); + // Fetch all session events and filter by EventType in code + var allEvents = await _sessionRepo.GetSessionEventsAsync(tenantId, sessionId); + var inventoryEvents = allEvents + .Where(e => e.EventType == Constants.EventTypes.SoftwareInventoryAnalysis); - await foreach (var entity in inventoryEvents) + foreach (var evt in inventoryEvents) { - var dataJson = entity.GetString("DataJson"); - if (string.IsNullOrEmpty(dataJson)) continue; + // The Data dictionary contains the event payload + if (evt.Data == null || evt.Data.Count == 0) continue; try { - var data = Newtonsoft.Json.JsonConvert.DeserializeObject>(dataJson); - if (data == null) continue; - // Only use shutdown events (final inventory, not baseline) - var triggeredAt = data.ContainsKey("triggered_at") ? data["triggered_at"]?.ToString() : null; + var triggeredAt = evt.Data.ContainsKey("triggered_at") ? evt.Data["triggered_at"]?.ToString() : null; if (triggeredAt != "shutdown") continue; // Extract inventory array - if (data.ContainsKey("inventory") && data["inventory"] is Newtonsoft.Json.Linq.JArray inventoryArray) + if (evt.Data.ContainsKey("inventory") && evt.Data["inventory"] is Newtonsoft.Json.Linq.JArray inventoryArray) { foreach (var item in inventoryArray) { diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/SaveCustomCpeMappingFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/SaveCustomCpeMappingFunction.cs index 250d8adb..0df2abde 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/SaveCustomCpeMappingFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/SaveCustomCpeMappingFunction.cs @@ -4,10 +4,8 @@ using System.Linq; using System.Net; using System.Text.Json; using System.Threading.Tasks; -using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Functions.Services.Vulnerability; -using AutopilotMonitor.Shared; -using Azure.Data.Tables; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -16,14 +14,14 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability { public class SaveCustomCpeMappingFunction { - private readonly TableStorageService _storageService; + private readonly IVulnerabilityRepository _vulnRepo; private readonly ILogger _logger; public SaveCustomCpeMappingFunction( - TableStorageService storageService, + IVulnerabilityRepository vulnRepo, ILogger logger) { - _storageService = storageService; + _vulnRepo = vulnRepo; _logger = logger; } @@ -54,13 +52,11 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability return badReq; } - var tableClient = _storageService.GetTableClient(Constants.TableNames.VulnerabilityCache); - var rowKey = $"{(mapping.NormalizedVendor ?? "unknown")}:{mapping.NormalizedProduct}".ToLowerInvariant(); // Sanitize RowKey (Table Storage doesn't allow /, \, #, ?) rowKey = rowKey.Replace("/", "_").Replace("\\", "_").Replace("#", "_").Replace("?", "_"); - var entity = new TableEntity("cpe_map_custom", rowKey) + var properties = new Dictionary { { "NormalizedVendor", mapping.NormalizedVendor ?? "" }, { "NormalizedProduct", mapping.NormalizedProduct }, @@ -74,18 +70,17 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability { "CreatedAt", DateTime.UtcNow.ToString("o") } }; - await tableClient.UpsertEntityAsync(entity); + await _vulnRepo.UpsertCpeMappingAsync("cpe_map_custom", rowKey, properties); // Reset the static mappings cache so future correlations use the new mapping VulnerabilityCorrelationService.ResetMappingsCache(); // Update SoftwareInventory entries that match this product so they no longer appear as "unmatched" - var updatedInventoryCount = await UpdateSoftwareInventoryCpeAsync( - mapping.NormalizedProduct, mapping.CpeUri); + await _vulnRepo.UpdateSoftwareInventoryCpeAsync(mapping.NormalizedProduct, mapping.CpeUri); _logger.LogInformation( - "Custom CPE mapping saved: {RowKey} -> {CpeUri}, updated {Count} inventory entries", - rowKey, mapping.CpeUri, updatedInventoryCount); + "Custom CPE mapping saved: {RowKey} -> {CpeUri}", + rowKey, mapping.CpeUri); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new { success = true, message = "CPE mapping saved", rowKey }); @@ -100,42 +95,6 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability } } - /// - /// Updates SoftwareInventory entries whose NormalizedName matches the mapped product, - /// setting their CpeUri so they no longer appear in the unmatched software list. - /// - private async Task UpdateSoftwareInventoryCpeAsync(string normalizedProduct, string cpeUri) - { - try - { - var inventoryClient = _storageService.GetTableClient(Constants.TableNames.SoftwareInventory); - var allEntities = inventoryClient.QueryAsync(maxPerPage: 1000); - var updatedCount = 0; - - await foreach (var entity in allEntities) - { - var existingCpe = entity.GetString("CpeUri") ?? ""; - if (!string.IsNullOrEmpty(existingCpe)) - continue; // Already has a CPE mapping - - var normalizedName = (entity.GetString("NormalizedName") ?? "").ToLowerInvariant(); - if (normalizedName == normalizedProduct.ToLowerInvariant()) - { - entity["CpeUri"] = cpeUri; - await inventoryClient.UpsertEntityAsync(entity); - updatedCount++; - } - } - - return updatedCount; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to update SoftwareInventory entries for {Product}", normalizedProduct); - return 0; - } - } - private class CpeMappingRequest { public string NormalizedVendor { get; set; } = ""; diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/TriggerVulnerabilityDataSyncFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/TriggerVulnerabilityDataSyncFunction.cs index a8e2db24..d8322e1a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/TriggerVulnerabilityDataSyncFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/TriggerVulnerabilityDataSyncFunction.cs @@ -4,6 +4,7 @@ using System.Web; using System.Threading.Tasks; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Functions.Services.Vulnerability; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; @@ -20,7 +21,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability private readonly KevDataService _kevService; private readonly AdminConfigurationService _adminConfigService; private readonly GlobalAdminService _globalAdminService; - private readonly TableStorageService _storageService; + private readonly IVulnerabilityRepository _vulnRepo; private readonly GitHubRuleRepository _gitHubRepo; private readonly ILogger _logger; @@ -28,14 +29,14 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability KevDataService kevService, AdminConfigurationService adminConfigService, GlobalAdminService globalAdminService, - TableStorageService storageService, + IVulnerabilityRepository vulnRepo, GitHubRuleRepository gitHubRepo, ILogger logger) { _kevService = kevService; _adminConfigService = adminConfigService; _globalAdminService = globalAdminService; - _storageService = storageService; + _vulnRepo = vulnRepo; _gitHubRepo = gitHubRepo; _logger = logger; } @@ -64,7 +65,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability try { var seedJson = await _gitHubRepo.FetchCpeSeedMappingsAsync(); - cpeSeedEntries = await _storageService.ImportCpeMappingSeedFromJsonAsync(seedJson); + cpeSeedEntries = await _vulnRepo.ImportCpeMappingSeedFromJsonAsync(seedJson); _logger.LogInformation("CPE seed mappings reseeded from GitHub: {Count} entries", cpeSeedEntries); } catch (Exception ex) @@ -79,7 +80,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability try { var communityJson = await _gitHubRepo.FetchCpeCommunityMappingsAsync(); - cpeCommunityEntries = await _storageService.ImportCpeCommunityMappingsFromJsonAsync(communityJson); + cpeCommunityEntries = await _vulnRepo.ImportCpeCommunityMappingsFromJsonAsync(communityJson); _logger.LogInformation("CPE community mappings reseeded from GitHub: {Count} entries", cpeCommunityEntries); } catch (Exception ex) diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilityDataSyncFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilityDataSyncFunction.cs index 8548dacc..5a77d011 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilityDataSyncFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Vulnerability/VulnerabilityDataSyncFunction.cs @@ -1,11 +1,11 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Functions.Services.Vulnerability; -using AutopilotMonitor.Shared; -using Azure.Data.Tables; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Azure.Functions.Worker; using Microsoft.Extensions.Logging; @@ -24,20 +24,20 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability { private readonly KevDataService _kevService; private readonly NvdApiClient _nvdApi; - private readonly TableStorageService _storageService; + private readonly IVulnerabilityRepository _vulnRepo; private readonly AdminConfigurationService _adminConfigService; private readonly ILogger _logger; public VulnerabilityDataSyncFunction( KevDataService kevService, NvdApiClient nvdApi, - TableStorageService storageService, + IVulnerabilityRepository vulnRepo, AdminConfigurationService adminConfigService, ILogger logger) { _kevService = kevService; _nvdApi = nvdApi; - _storageService = storageService; + _vulnRepo = vulnRepo; _adminConfigService = adminConfigService; _logger = logger; } @@ -92,29 +92,25 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability { try { - var tableClient = _storageService.GetTableClient(Constants.TableNames.VulnerabilityCache); var staleCutoff = DateTime.UtcNow.AddDays(-7).ToString("o"); - // Find all CVE cache entries (PK starts with "cve_") - var cacheEntries = tableClient.QueryAsync( - filter: "RowKey eq 'cache_result'", - select: new[] { "PartitionKey", "RowKey", "CachedAt", "CpeUri", "TotalCves" }); + var cacheEntries = await _vulnRepo.GetStaleCveCacheEntriesAsync(); - var staleEntries = new System.Collections.Generic.List<(string CacheKey, string CpeUri, string CachedAt, int TotalCves)>(); + var staleEntries = new List<(string CacheKey, string CpeUri, string CachedAt, int TotalCves)>(); - await foreach (var entity in cacheEntries) + foreach (var entity in cacheEntries) { - var pk = entity.PartitionKey; + var pk = entity.ContainsKey("PartitionKey") ? entity["PartitionKey"]?.ToString() : null; if (pk == null || !pk.StartsWith("cve_")) continue; - var cachedAt = entity.GetString("CachedAt") ?? ""; + var cachedAt = entity.ContainsKey("CachedAt") ? entity["CachedAt"]?.ToString() ?? "" : ""; if (string.Compare(cachedAt, staleCutoff, StringComparison.OrdinalIgnoreCase) < 0) { staleEntries.Add(( pk, - entity.GetString("CpeUri") ?? pk.Substring(4), // Fallback: strip "cve_" prefix + entity.ContainsKey("CpeUri") ? entity["CpeUri"]?.ToString() ?? pk.Substring(4) : pk.Substring(4), cachedAt, - entity.GetInt32("TotalCves") ?? 0 + entity.ContainsKey("TotalCves") && entity["TotalCves"] is int tc ? tc : 0 )); } } @@ -142,7 +138,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability var newCount = cveResponse?.Vulnerabilities?.Count ?? 0; // Re-serialize and store (reuse the same cache key) - var cves = new System.Collections.Generic.List(); + var cves = new List(); if (cveResponse?.Vulnerabilities != null) { foreach (var vulnItem in cveResponse.Vulnerabilities.Take(200)) @@ -165,7 +161,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability if (description.Length > 500) description = description.Substring(0, 497) + "..."; - var affectedVersions = new System.Collections.Generic.List(); + var affectedVersions = new List(); if (cve.Configurations != null) { foreach (var config in cve.Configurations) @@ -211,7 +207,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability cveDataJson = System.Text.Json.JsonSerializer.Serialize(reduced); } - var updatedEntity = new TableEntity(cacheKey, "cache_result") + var data = new Dictionary { { "CveDataJson", cveDataJson }, { "CachedAt", DateTime.UtcNow.ToString("o") }, @@ -219,7 +215,7 @@ namespace AutopilotMonitor.Functions.Functions.Vulnerability { "TotalCves", cves.Count } }; - await tableClient.UpsertEntityAsync(updatedEntity); + await _vulnRepo.UpsertCveCacheEntryAsync(cacheKey, "cache_result", data); refreshed++; _logger.LogInformation("[VulnSync] Refreshed {CpeUri}: {OldCount} → {NewCount} CVEs", diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/ApiKeyMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/ApiKeyMiddleware.cs index d6c2d2e2..d6df2e09 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/ApiKeyMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/ApiKeyMiddleware.cs @@ -3,9 +3,7 @@ using System.Security.Claims; using System.Security.Cryptography; using System.Text; using AutopilotMonitor.Functions.Services; -using AutopilotMonitor.Shared; -using Azure; -using Azure.Data.Tables; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.AspNetCore.Http; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Middleware; @@ -21,7 +19,7 @@ namespace AutopilotMonitor.Functions.Middleware; public class ApiKeyMiddleware : IFunctionsWorkerMiddleware { private readonly ILogger _logger; - private readonly TableStorageService _storageService; + private readonly IAdminRepository _adminRepo; private readonly RateLimitService _rateLimitService; // Routes that bypass ApiKey middleware (use device cert / bootstrap token auth) @@ -35,10 +33,10 @@ public class ApiKeyMiddleware : IFunctionsWorkerMiddleware "/api/progress/", }; - public ApiKeyMiddleware(ILogger logger, TableStorageService storageService, RateLimitService rateLimitService) + public ApiKeyMiddleware(ILogger logger, IAdminRepository adminRepo, RateLimitService rateLimitService) { _logger = logger; - _storageService = storageService; + _adminRepo = adminRepo; _rateLimitService = rateLimitService; } @@ -78,15 +76,7 @@ public class ApiKeyMiddleware : IFunctionsWorkerMiddleware try { - var tableClient = _storageService.GetTableClient(Constants.TableNames.ApiKeys); - - TableEntity? foundKey = null; - await foreach (var entity in tableClient.QueryAsync( - filter: $"KeyHash eq '{keyHash}'")) - { - foundKey = entity; - break; - } + var foundKey = await _adminRepo.ValidateApiKeyAsync(keyHash); if (foundKey == null) { @@ -97,7 +87,7 @@ public class ApiKeyMiddleware : IFunctionsWorkerMiddleware } // Check IsActive - if (foundKey.TryGetValue("IsActive", out var isActiveObj) && isActiveObj is bool isActive && !isActive) + if (!foundKey.IsActive) { httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized; await httpContext.Response.WriteAsJsonAsync(new { error = "API key is revoked" }); @@ -105,25 +95,22 @@ public class ApiKeyMiddleware : IFunctionsWorkerMiddleware } // Check expiry - if (foundKey.TryGetValue("ExpiresAt", out var expiresObj) && expiresObj is DateTimeOffset expiresAt) + if (foundKey.ExpiresAt.HasValue && foundKey.ExpiresAt.Value < DateTime.UtcNow) { - if (expiresAt < DateTimeOffset.UtcNow) - { - httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized; - await httpContext.Response.WriteAsJsonAsync(new { error = "API key has expired" }); - return; - } + httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized; + await httpContext.Response.WriteAsJsonAsync(new { error = "API key has expired" }); + return; } - var scope = foundKey.TryGetValue("Scope", out var scopeObj) ? scopeObj?.ToString() ?? "tenant" : "tenant"; - var keyTenantId = foundKey.TryGetValue("TenantId", out var tidObj) ? tidObj?.ToString() : null; + var scope = foundKey.Scope ?? "tenant"; + var keyTenantId = foundKey.TenantId; // Rate limit: 60 req/min for tenant-scoped, 120 req/min for global-scoped var maxRequests = scope == "global" ? 120 : 60; - var rateLimitResult = _rateLimitService.CheckRateLimit($"apikey_{foundKey.RowKey}", maxRequests); + var rateLimitResult = _rateLimitService.CheckRateLimit($"apikey_{foundKey.KeyId}", maxRequests); if (!rateLimitResult.IsAllowed) { - _logger.LogWarning("ApiKey rate limit exceeded: keyId={KeyId}", foundKey.RowKey); + _logger.LogWarning("ApiKey rate limit exceeded: keyId={KeyId}", foundKey.KeyId); httpContext.Response.StatusCode = 429; httpContext.Response.ContentType = "application/json"; if (rateLimitResult.RetryAfter.HasValue) @@ -136,15 +123,18 @@ public class ApiKeyMiddleware : IFunctionsWorkerMiddleware var claims = new List { new Claim("auth_method", "api_key"), - new Claim("key_id", foundKey.RowKey), + new Claim("key_id", foundKey.KeyId), new Claim("key_scope", scope), }; + // Determine partition key for increment (matches storage layout) + var partitionKey = scope == "global" ? "GLOBAL" : keyTenantId; + if (scope == "global") { // Global key: mark as GlobalAdmin for policy purposes claims.Add(new Claim("is_global_admin", "true")); - claims.Add(new Claim("upn", $"apikey-global@{foundKey.RowKey}")); + claims.Add(new Claim("upn", $"apikey-global@{foundKey.KeyId}")); context.Items["ApiKeyScope"] = "global"; } else @@ -161,10 +151,10 @@ public class ApiKeyMiddleware : IFunctionsWorkerMiddleware httpContext.User = principal; context.Items["ClaimsPrincipal"] = principal; - _logger.LogDebug("ApiKey auth: scope={Scope}, tenantId={TenantId}, keyId={KeyId}", scope, keyTenantId, foundKey.RowKey); + _logger.LogDebug("ApiKey auth: scope={Scope}, tenantId={TenantId}, keyId={KeyId}", scope, keyTenantId, foundKey.KeyId); // Increment request count (fire-and-forget) - _ = IncrementRequestCountAsync(tableClient, foundKey.PartitionKey, foundKey.RowKey); + _ = IncrementRequestCountAsync(partitionKey, foundKey.KeyId); await next(context); } @@ -176,15 +166,11 @@ public class ApiKeyMiddleware : IFunctionsWorkerMiddleware } } - private async Task IncrementRequestCountAsync(TableClient tableClient, string pk, string rk) + private async Task IncrementRequestCountAsync(string partitionKey, string keyId) { try { - var result = await tableClient.GetEntityAsync(pk, rk); - var entity = result.Value; - var count = entity.TryGetValue("RequestCount", out var c) ? Convert.ToInt64(c) : 0L; - entity["RequestCount"] = count + 1; - await tableClient.UpdateEntityAsync(entity, entity.ETag); + await _adminRepo.IncrementApiKeyRequestCountAsync(partitionKey, keyId); } catch { diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index 6afbb024..3c73116e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using AutopilotMonitor.Functions.DataAccess; using AutopilotMonitor.Functions.Functions.Config; using AutopilotMonitor.Functions.Functions.Ingest; using AutopilotMonitor.Functions.Functions.Sessions; @@ -100,6 +101,11 @@ builder.Services.AddMemoryCache(); builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); // Initialize all tables at startup + +// Data Access Layer — repository interfaces backed by Table Storage. +// To switch to Cosmos DB: replace AddTableStorageDataAccess() with AddCosmosDataAccess(). +// To add event streaming: chain .AddEventStreaming() after this call. +builder.Services.AddTableStorageDataAccess(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs index 8a88bb85..9994e904 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/AdminConfigurationService.cs @@ -2,39 +2,31 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; -using Azure; -using Azure.Data.Tables; using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Services { /// - /// Service for managing global admin configuration in Azure Table Storage + /// Service for managing global admin configuration. + /// Caching and business logic layer — delegates storage to IConfigRepository. /// public class AdminConfigurationService { - private readonly TableClient _adminTableClient; - private readonly TableClient _tenantConfigTableClient; + private readonly IConfigRepository _configRepo; private readonly ILogger _logger; private readonly IMemoryCache _cache; private const string CacheKey = "admin-config"; private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); - public AdminConfigurationService(IConfiguration configuration, ILogger logger, IMemoryCache cache) + public AdminConfigurationService(IConfigRepository configRepo, ILogger logger, IMemoryCache cache) { + _configRepo = configRepo; _logger = logger; _cache = cache; - - var connectionString = configuration["AzureTableStorageConnectionString"]; - var serviceClient = new TableServiceClient(connectionString); - _adminTableClient = serviceClient.GetTableClient(Constants.TableNames.AdminConfiguration); - _tenantConfigTableClient = serviceClient.GetTableClient(Constants.TableNames.TenantConfiguration); - // Tables are initialized centrally by TableInitializerService at startup } /// @@ -49,25 +41,23 @@ namespace AutopilotMonitor.Functions.Services try { - // Load from Table Storage - var entity = await _adminTableClient.GetEntityAsync("GlobalConfig", "config"); - var config = ConvertFromTableEntity(entity.Value); + // Load from repository + var config = await _configRepo.GetAdminConfigurationAsync(); - _cache.Set(CacheKey, config, CacheDuration); + if (config != null) + { + _cache.Set(CacheKey, config, CacheDuration); + return config; + } - return config; - } - catch (RequestFailedException ex) when (ex.Status == 404) - { // Configuration not found - create and save default immediately _logger.LogInformation("Admin configuration not found, creating and saving default configuration"); var defaultConfig = AdminConfiguration.CreateDefault(); try { - // Save the default configuration to Table Storage - var entity = ConvertToTableEntity(defaultConfig); - await _adminTableClient.UpsertEntityAsync(entity); + // Save the default configuration via repository + await _configRepo.SaveAdminConfigurationAsync(defaultConfig); _cache.Set(CacheKey, defaultConfig, CacheDuration); @@ -102,8 +92,7 @@ namespace AutopilotMonitor.Functions.Services { config.LastUpdated = DateTime.UtcNow; - var entity = ConvertToTableEntity(config); - await _adminTableClient.UpsertEntityAsync(entity); + await _configRepo.SaveAdminConfigurationAsync(config); // Invalidate cache _cache.Remove(CacheKey); @@ -132,28 +121,26 @@ namespace AutopilotMonitor.Functions.Services var tenantsUpdated = 0; var tenantsSkipped = 0; - // Query all tenant configurations - var query = _tenantConfigTableClient.QueryAsync(filter: $"RowKey eq 'config'"); + // Get all tenant configurations via repository + var allConfigs = await _configRepo.GetAllTenantConfigurationsAsync(); - await foreach (var entity in query) + foreach (var tenantConfig in allConfigs) { // Check if tenant has a custom rate limit override - var customRateLimit = entity.GetInt32("CustomRateLimitRequestsPerMinute"); - - if (customRateLimit.HasValue) + if (tenantConfig.CustomRateLimitRequestsPerMinute.HasValue) { // Skip tenants with custom rate limit tenantsSkipped++; - _logger.LogDebug($"Skipping tenant {entity.PartitionKey} - has custom rate limit: {customRateLimit}"); + _logger.LogDebug($"Skipping tenant {tenantConfig.TenantId} - has custom rate limit: {tenantConfig.CustomRateLimitRequestsPerMinute}"); continue; } // Update tenant's rate limit to match global default - entity["RateLimitRequestsPerMinute"] = globalRateLimit; - entity["LastUpdated"] = DateTime.UtcNow; - entity["UpdatedBy"] = "System (Global Rate Limit Sync)"; + tenantConfig.RateLimitRequestsPerMinute = globalRateLimit; + tenantConfig.LastUpdated = DateTime.UtcNow; + tenantConfig.UpdatedBy = "System (Global Rate Limit Sync)"; - await _tenantConfigTableClient.UpdateEntityAsync(entity, ETag.All, TableUpdateMode.Replace); + await _configRepo.SaveTenantConfigurationAsync(tenantConfig); tenantsUpdated++; } @@ -173,45 +160,5 @@ namespace AutopilotMonitor.Functions.Services { _cache.Remove(CacheKey); } - - private TableEntity ConvertToTableEntity(AdminConfiguration config) - { - var entity = new TableEntity("GlobalConfig", "config") - { - { "LastUpdated", config.LastUpdated }, - { "UpdatedBy", config.UpdatedBy }, - { "GlobalRateLimitRequestsPerMinute", config.GlobalRateLimitRequestsPerMinute }, - { "PlatformStatsBlobSasUrl", config.PlatformStatsBlobSasUrl ?? string.Empty }, - { "CollectorIdleTimeoutMinutes", config.CollectorIdleTimeoutMinutes }, - { "MaxSessionWindowHours", config.MaxSessionWindowHours }, - { "MaintenanceBlockDurationHours", config.MaintenanceBlockDurationHours }, - { "DiagnosticsGlobalLogPathsJson", config.DiagnosticsGlobalLogPathsJson }, - { "NvdApiKey", config.NvdApiKey }, - { "VulnerabilityCorrelationEnabled", config.VulnerabilityCorrelationEnabled }, - { "VulnerabilityDataLastSyncUtc", config.VulnerabilityDataLastSyncUtc } - }; - - return entity; - } - - private AdminConfiguration ConvertFromTableEntity(TableEntity entity) - { - return new AdminConfiguration - { - PartitionKey = entity.PartitionKey, - RowKey = entity.RowKey, - LastUpdated = entity.GetDateTime("LastUpdated") ?? DateTime.UtcNow, - UpdatedBy = entity.GetString("UpdatedBy") ?? "Unknown", - GlobalRateLimitRequestsPerMinute = entity.GetInt32("GlobalRateLimitRequestsPerMinute") ?? 100, - PlatformStatsBlobSasUrl = entity.GetString("PlatformStatsBlobSasUrl") ?? string.Empty, - CollectorIdleTimeoutMinutes = entity.GetInt32("CollectorIdleTimeoutMinutes") ?? 15, - MaxSessionWindowHours = entity.GetInt32("MaxSessionWindowHours") ?? 24, - MaintenanceBlockDurationHours = entity.GetInt32("MaintenanceBlockDurationHours") ?? 12, - DiagnosticsGlobalLogPathsJson = entity.GetString("DiagnosticsGlobalLogPathsJson"), - NvdApiKey = entity.GetString("NvdApiKey"), - VulnerabilityCorrelationEnabled = entity.GetBoolean("VulnerabilityCorrelationEnabled") ?? true, - VulnerabilityDataLastSyncUtc = entity.GetString("VulnerabilityDataLastSyncUtc") - }; - } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs b/src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs index e98e933d..fbc9118c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/AnalyzeRuleService.cs @@ -1,3 +1,4 @@ +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Extensions.Logging; @@ -12,13 +13,13 @@ namespace AutopilotMonitor.Functions.Services /// public class AnalyzeRuleService { - private readonly TableStorageService _storageService; + private readonly IRuleRepository _ruleRepo; private readonly ILogger _logger; private bool _seeded = false; - public AnalyzeRuleService(TableStorageService storageService, ILogger logger) + public AnalyzeRuleService(IRuleRepository ruleRepo, ILogger logger) { - _storageService = storageService; + _ruleRepo = ruleRepo; _logger = logger; } @@ -39,14 +40,14 @@ namespace AutopilotMonitor.Functions.Services await EnsureBuiltInRulesSeededAsync(); // Global rules: built-in + community (single source of truth for definitions) - var globalRules = await _storageService.GetAnalyzeRulesAsync("global"); + var globalRules = await _ruleRepo.GetAnalyzeRulesAsync("global"); // Tenant rules: only custom rules (IsBuiltIn=false, IsCommunity=false) - var tenantRules = await _storageService.GetAnalyzeRulesAsync(tenantId); + var tenantRules = await _ruleRepo.GetAnalyzeRulesAsync(tenantId); var customRules = tenantRules.Where(r => !r.IsBuiltIn && !r.IsCommunity).ToList(); // Per-tenant enabled/disabled states for global rules - var ruleStates = await _storageService.GetRuleStatesAsync(tenantId); + var ruleStates = await _ruleRepo.GetRuleStatesAsync(tenantId); var mergedRules = new List(); @@ -73,7 +74,7 @@ namespace AutopilotMonitor.Functions.Services rule.IsCommunity = false; rule.CreatedAt = DateTime.UtcNow; rule.UpdatedAt = DateTime.UtcNow; - return await _storageService.StoreAnalyzeRuleAsync(rule, tenantId); + return await _ruleRepo.StoreAnalyzeRuleAsync(rule, tenantId); } /// @@ -85,11 +86,11 @@ namespace AutopilotMonitor.Functions.Services { if (rule.IsBuiltIn || rule.IsCommunity) { - return await _storageService.StoreRuleStateAsync(tenantId, rule.RuleId, rule.Enabled); + return await _ruleRepo.StoreRuleStateAsync(tenantId, rule.RuleId, rule.Enabled); } rule.UpdatedAt = DateTime.UtcNow; - return await _storageService.StoreAnalyzeRuleAsync(rule, tenantId); + return await _ruleRepo.StoreAnalyzeRuleAsync(rule, tenantId); } /// @@ -101,10 +102,10 @@ namespace AutopilotMonitor.Functions.Services { if (rule.IsBuiltIn || rule.IsCommunity) { - return await _storageService.DeleteRuleStateAsync(tenantId, rule.RuleId); + return await _ruleRepo.DeleteRuleStateAsync(tenantId, rule.RuleId); } - return await _storageService.DeleteAnalyzeRuleAsync(tenantId, rule.RuleId); + return await _ruleRepo.DeleteAnalyzeRuleAsync(tenantId, rule.RuleId); } /// @@ -116,12 +117,12 @@ namespace AutopilotMonitor.Functions.Services { _logger.LogInformation("Reseeding built-in analyze rules (full re-import)..."); - var existingGlobalRules = await _storageService.GetAnalyzeRulesAsync("global"); + var existingGlobalRules = await _ruleRepo.GetAnalyzeRulesAsync("global"); var deleted = 0; foreach (var rule in existingGlobalRules.Where(r => r.IsBuiltIn)) { - await _storageService.DeleteAnalyzeRuleAsync("global", rule.RuleId); + await _ruleRepo.DeleteAnalyzeRuleAsync("global", rule.RuleId); deleted++; } _logger.LogInformation($"Deleted {deleted} old global built-in analyze rules"); @@ -129,7 +130,7 @@ namespace AutopilotMonitor.Functions.Services var builtInRules = BuiltInAnalyzeRules.GetAll(); foreach (var rule in builtInRules) { - await _storageService.StoreAnalyzeRuleAsync(rule, "global"); + await _ruleRepo.StoreAnalyzeRuleAsync(rule, "global"); } _logger.LogInformation($"Written {builtInRules.Count} built-in analyze rules from code"); @@ -146,7 +147,7 @@ namespace AutopilotMonitor.Functions.Services { if (_seeded) return; - var existingRules = await _storageService.GetAnalyzeRulesAsync("global"); + var existingRules = await _ruleRepo.GetAnalyzeRulesAsync("global"); var builtInRules = BuiltInAnalyzeRules.GetAll(); if (existingRules.Count == 0) @@ -154,7 +155,7 @@ namespace AutopilotMonitor.Functions.Services _logger.LogInformation("Seeding built-in analyze rules..."); foreach (var rule in builtInRules) { - await _storageService.StoreAnalyzeRuleAsync(rule, "global"); + await _ruleRepo.StoreAnalyzeRuleAsync(rule, "global"); } _logger.LogInformation($"Seeded {builtInRules.Count} built-in analyze rules"); } @@ -173,14 +174,14 @@ namespace AutopilotMonitor.Functions.Services || existing.Severity != rule.Severity || existing.Trigger != rule.Trigger) { - await _storageService.StoreAnalyzeRuleAsync(rule, "global"); + await _ruleRepo.StoreAnalyzeRuleAsync(rule, "global"); updated++; } } else { // New built-in rule added in code - await _storageService.StoreAnalyzeRuleAsync(rule, "global"); + await _ruleRepo.StoreAnalyzeRuleAsync(rule, "global"); updated++; } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/BlockedDeviceService.cs b/src/Backend/AutopilotMonitor.Functions/Services/BlockedDeviceService.cs index 5de28525..5a9d5b7a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/BlockedDeviceService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/BlockedDeviceService.cs @@ -2,21 +2,19 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Threading.Tasks; -using Azure; -using Azure.Data.Tables; -using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Services { /// /// Manages temporarily blocked devices (e.g. rogue devices sending excessive data). - /// Uses Azure Table Storage for persistence across backend restarts, with a + /// Uses IDeviceSecurityRepository for persistence, with a /// ConcurrentDictionary in-memory cache for fast lookups at ingest time. /// public class BlockedDeviceService { - private readonly TableStorageService _storageService; + private readonly IDeviceSecurityRepository _securityRepo; private readonly ILogger _logger; // Cache key: "tenantId|serialNumber" (lower-cased serial number for case-insensitive matching) @@ -27,15 +25,15 @@ namespace AutopilotMonitor.Functions.Services // Lazy loading: populated on first lookup per tenant. private readonly ConcurrentDictionary _loadedTenants = new(StringComparer.OrdinalIgnoreCase); - public BlockedDeviceService(TableStorageService storageService, ILogger logger) + public BlockedDeviceService(IDeviceSecurityRepository securityRepo, ILogger logger) { - _storageService = storageService; + _securityRepo = securityRepo; _logger = logger; } /// /// Checks whether a device is currently blocked. - /// Fast path: in-memory cache (loaded lazily per tenant from Table Storage). + /// Fast path: in-memory cache (loaded lazily per tenant from storage). /// Returns the action type ("Block" or "Kill") so callers can differentiate. /// public async Task<(bool isBlocked, DateTime? unblockAt, string action)> IsBlockedAsync(string tenantId, string serialNumber) @@ -63,29 +61,15 @@ namespace AutopilotMonitor.Functions.Services } /// - /// Blocks a device for the specified duration. Updates both Table Storage and the in-memory cache. + /// Blocks a device for the specified duration. Updates both storage and the in-memory cache. /// is "Block" (stop uploads) or "Kill" (remote self-destruct). /// public async Task BlockDeviceAsync(string tenantId, string serialNumber, int durationHours, string blockedByEmail, string? reason = null, string action = "Block") { - var now = DateTime.UtcNow; - var unblockAt = now.AddHours(durationHours); - - var entity = new TableEntity(tenantId, EncodeRowKey(serialNumber)) - { - ["SerialNumber"] = serialNumber, - ["BlockedAt"] = now, - ["UnblockAt"] = unblockAt, - ["BlockedByEmail"] = blockedByEmail ?? string.Empty, - ["DurationHours"] = durationHours, - ["Reason"] = reason ?? string.Empty, - ["Action"] = action ?? "Block" - }; - - var tableClient = _storageService.GetTableServiceClient().GetTableClient(Constants.TableNames.BlockedDevices); - await tableClient.UpsertEntityAsync(entity); + await _securityRepo.BlockDeviceAsync(tenantId, serialNumber, durationHours, blockedByEmail, reason, action); // Update cache immediately + var unblockAt = DateTime.UtcNow.AddHours(durationHours); var cacheKey = BuildCacheKey(tenantId, serialNumber); _cache[cacheKey] = new BlockCacheEntry { UnblockAt = unblockAt, Action = action ?? "Block" }; @@ -95,20 +79,11 @@ namespace AutopilotMonitor.Functions.Services } /// - /// Removes a device block immediately. Updates both Table Storage and the in-memory cache. + /// Removes a device block immediately. Updates both storage and the in-memory cache. /// public async Task UnblockDeviceAsync(string tenantId, string serialNumber) { - var tableClient = _storageService.GetTableServiceClient().GetTableClient(Constants.TableNames.BlockedDevices); - - try - { - await tableClient.DeleteEntityAsync(tenantId, EncodeRowKey(serialNumber)); - } - catch (RequestFailedException ex) when (ex.Status == 404) - { - // Already removed — that's fine - } + await _securityRepo.UnblockDeviceAsync(tenantId, serialNumber); // Remove from cache immediately var cacheKey = BuildCacheKey(tenantId, serialNumber); @@ -119,87 +94,17 @@ namespace AutopilotMonitor.Functions.Services /// /// Returns all currently active (non-expired) blocked devices for a tenant. - /// Also cleans up expired entries from Table Storage as a side-effect. + /// Delegates to repository which also cleans up expired entries. /// - public async Task> GetBlockedDevicesAsync(string tenantId) - { - var tableClient = _storageService.GetTableServiceClient().GetTableClient(Constants.TableNames.BlockedDevices); - var result = new List(); - var expiredRowKeys = new List(); - var now = DateTime.UtcNow; - - await foreach (var entity in tableClient.QueryAsync(e => e.PartitionKey == tenantId)) - { - var unblockAt = entity.GetDateTimeOffset("UnblockAt")?.UtcDateTime ?? DateTime.MinValue; - - if (now >= unblockAt) - { - expiredRowKeys.Add(entity.RowKey); - continue; - } - - result.Add(new BlockedDeviceEntry - { - TenantId = tenantId, - SerialNumber = entity.GetString("SerialNumber") ?? DecodeRowKey(entity.RowKey), - BlockedAt = entity.GetDateTimeOffset("BlockedAt")?.UtcDateTime ?? now, - UnblockAt = unblockAt, - BlockedByEmail = entity.GetString("BlockedByEmail"), - DurationHours = entity.GetInt32("DurationHours") ?? 12, - Reason = entity.GetString("Reason"), - Action = entity.GetString("Action") ?? "Block" - }); - } - - // Clean up expired entries from storage (fire-and-forget, best effort) - _ = CleanupExpiredEntriesAsync(tableClient, tenantId, expiredRowKeys); - - return result; - } + public Task> GetBlockedDevicesAsync(string tenantId) + => _securityRepo.GetBlockedDevicesAsync(tenantId); /// /// Returns all currently active (non-expired) blocked devices across ALL tenants. - /// Also cleans up expired entries from Table Storage as a side-effect. + /// Delegates to repository which also cleans up expired entries. /// - public async Task> GetAllBlockedDevicesAsync() - { - var tableClient = _storageService.GetTableServiceClient().GetTableClient(Constants.TableNames.BlockedDevices); - var result = new List(); - var expiredKeys = new List<(string partitionKey, string rowKey)>(); - var now = DateTime.UtcNow; - - await foreach (var entity in tableClient.QueryAsync()) - { - var unblockAt = entity.GetDateTimeOffset("UnblockAt")?.UtcDateTime ?? DateTime.MinValue; - - if (now >= unblockAt) - { - expiredKeys.Add((entity.PartitionKey, entity.RowKey)); - continue; - } - - result.Add(new BlockedDeviceEntry - { - TenantId = entity.PartitionKey, - SerialNumber = entity.GetString("SerialNumber") ?? DecodeRowKey(entity.RowKey), - BlockedAt = entity.GetDateTimeOffset("BlockedAt")?.UtcDateTime ?? now, - UnblockAt = unblockAt, - BlockedByEmail = entity.GetString("BlockedByEmail"), - DurationHours = entity.GetInt32("DurationHours") ?? 12, - Reason = entity.GetString("Reason"), - Action = entity.GetString("Action") ?? "Block" - }); - } - - // Clean up expired entries (fire-and-forget, best effort) - foreach (var (pk, rk) in expiredKeys) - { - try { await tableClient.DeleteEntityAsync(pk, rk); } - catch { /* best effort */ } - } - - return result; - } + public Task> GetAllBlockedDevicesAsync() + => _securityRepo.GetAllBlockedDevicesAsync(); // ----------------------------------------------------------------------- // Private helpers @@ -213,19 +118,17 @@ namespace AutopilotMonitor.Functions.Services try { - var tableClient = _storageService.GetTableServiceClient().GetTableClient(Constants.TableNames.BlockedDevices); + var entries = await _securityRepo.GetBlockedDevicesAsync(tenantId); var now = DateTime.UtcNow; - await foreach (var entity in tableClient.QueryAsync(e => e.PartitionKey == tenantId)) + foreach (var entry in entries) { - var unblockAt = entity.GetDateTimeOffset("UnblockAt")?.UtcDateTime ?? DateTime.MinValue; - if (unblockAt <= now) continue; // Skip expired + if (entry.UnblockAt == null || entry.UnblockAt <= now) continue; - var serialNumber = entity.GetString("SerialNumber") ?? DecodeRowKey(entity.RowKey); - _cache[BuildCacheKey(tenantId, serialNumber)] = new BlockCacheEntry + _cache[BuildCacheKey(tenantId, entry.SerialNumber)] = new BlockCacheEntry { - UnblockAt = unblockAt, - Action = entity.GetString("Action") ?? "Block" + UnblockAt = entry.UnblockAt.Value, + Action = entry.Action }; } @@ -239,15 +142,6 @@ namespace AutopilotMonitor.Functions.Services } } - private static async Task CleanupExpiredEntriesAsync(TableClient tableClient, string tenantId, List rowKeys) - { - foreach (var rowKey in rowKeys) - { - try { await tableClient.DeleteEntityAsync(tenantId, rowKey); } - catch { /* best effort */ } - } - } - private class BlockCacheEntry { public DateTime UnblockAt { get; set; } @@ -256,27 +150,7 @@ namespace AutopilotMonitor.Functions.Services private static string BuildCacheKey(string tenantId, string serialNumber) => $"{tenantId}|{serialNumber.ToUpperInvariant()}"; - - /// Azure Table RowKey must not contain /\#? and must be <= 1KB. URL-encode to be safe. - private static string EncodeRowKey(string serialNumber) - => Uri.EscapeDataString(serialNumber); - - private static string DecodeRowKey(string encodedRowKey) - => Uri.UnescapeDataString(encodedRowKey); } - /// - /// Represents a blocked device entry returned by the API - /// - public class BlockedDeviceEntry - { - public string TenantId { get; set; } = string.Empty; - public string SerialNumber { get; set; } = string.Empty; - public DateTime BlockedAt { get; set; } - public DateTime UnblockAt { get; set; } - public string BlockedByEmail { get; set; } = string.Empty; - public int DurationHours { get; set; } - public string? Reason { get; set; } - public string Action { get; set; } = "Block"; - } + // Note: BlockedDeviceEntry is now defined in AutopilotMonitor.Shared.DataAccess.IDeviceSecurityRepository } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/BlockedVersionService.cs b/src/Backend/AutopilotMonitor.Functions/Services/BlockedVersionService.cs index f3c966de..bd2da494 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/BlockedVersionService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/BlockedVersionService.cs @@ -3,9 +3,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using Azure; -using Azure.Data.Tables; -using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Services @@ -17,11 +15,11 @@ namespace AutopilotMonitor.Functions.Services /// "1.0.*" — matches all agents with major.minor 1.0 /// "1.0.30" — matches all agents with version <= 1.0.30 /// "=1.0.30" — matches exactly version 1.0.30 - /// Global rules (apply to all tenants). Uses Azure Table Storage + in-memory cache. + /// Global rules (apply to all tenants). Uses IDeviceSecurityRepository + in-memory cache. /// public class BlockedVersionService { - private readonly TableStorageService _storageService; + private readonly IDeviceSecurityRepository _securityRepo; private readonly ILogger _logger; private static readonly TimeSpan CacheRefreshInterval = TimeSpan.FromMinutes(5); @@ -31,9 +29,9 @@ namespace AutopilotMonitor.Functions.Services private volatile bool _loaded; private DateTime _lastLoadedUtc = DateTime.MinValue; - public BlockedVersionService(TableStorageService storageService, ILogger logger) + public BlockedVersionService(IDeviceSecurityRepository securityRepo, ILogger logger) { - _storageService = storageService; + _securityRepo = securityRepo; _logger = logger; } @@ -86,17 +84,7 @@ namespace AutopilotMonitor.Functions.Services if (!IsValidPattern(versionPattern)) throw new ArgumentException($"Invalid version pattern: '{versionPattern}'. Use formats like '1.*', '1.0.*', '1.0.30' (<=), or '=1.0.30' (exact)."); - var entity = new TableEntity("global", EncodeRowKey(versionPattern)) - { - ["VersionPattern"] = versionPattern, - ["Action"] = action, - ["CreatedByEmail"] = createdByEmail ?? string.Empty, - ["CreatedAt"] = DateTime.UtcNow, - ["Reason"] = reason ?? string.Empty - }; - - var tableClient = _storageService.GetTableServiceClient().GetTableClient(Constants.TableNames.BlockedVersions); - await tableClient.UpsertEntityAsync(entity); + await _securityRepo.BlockVersionAsync(versionPattern, action, createdByEmail, reason); // Update cache _cache[versionPattern] = new VersionBlockCacheEntry { Action = action }; @@ -112,16 +100,8 @@ namespace AutopilotMonitor.Functions.Services public async Task UnblockVersionAsync(string versionPattern) { versionPattern = versionPattern.Trim(); - var tableClient = _storageService.GetTableServiceClient().GetTableClient(Constants.TableNames.BlockedVersions); - try - { - await tableClient.DeleteEntityAsync("global", EncodeRowKey(versionPattern)); - } - catch (RequestFailedException ex) when (ex.Status == 404) - { - // Already removed - } + await _securityRepo.UnblockVersionAsync(versionPattern); _cache.TryRemove(versionPattern, out _); @@ -131,25 +111,8 @@ namespace AutopilotMonitor.Functions.Services /// /// Returns all active version block rules. /// - public async Task> GetBlockedVersionsAsync() - { - var tableClient = _storageService.GetTableServiceClient().GetTableClient(Constants.TableNames.BlockedVersions); - var result = new List(); - - await foreach (var entity in tableClient.QueryAsync(e => e.PartitionKey == "global")) - { - result.Add(new BlockedVersionEntry - { - VersionPattern = entity.GetString("VersionPattern") ?? DecodeRowKey(entity.RowKey), - Action = entity.GetString("Action") ?? "Block", - CreatedByEmail = entity.GetString("CreatedByEmail") ?? string.Empty, - CreatedAt = entity.GetDateTimeOffset("CreatedAt")?.UtcDateTime ?? DateTime.MinValue, - Reason = entity.GetString("Reason") - }); - } - - return result; - } + public Task> GetBlockedVersionsAsync() + => _securityRepo.GetBlockedVersionsAsync(); // ----------------------------------------------------------------------- // Version matching logic @@ -274,15 +237,14 @@ namespace AutopilotMonitor.Functions.Services try { - var tableClient = _storageService.GetTableServiceClient().GetTableClient(Constants.TableNames.BlockedVersions); - var freshCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + var entries = await _securityRepo.GetBlockedVersionsAsync(); - await foreach (var entity in tableClient.QueryAsync(e => e.PartitionKey == "global")) + var freshCache = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + foreach (var entry in entries) { - var pattern = entity.GetString("VersionPattern") ?? DecodeRowKey(entity.RowKey); - freshCache[pattern] = new VersionBlockCacheEntry + freshCache[entry.VersionPattern] = new VersionBlockCacheEntry { - Action = entity.GetString("Action") ?? "Block" + Action = entry.Action }; } @@ -316,23 +278,7 @@ namespace AutopilotMonitor.Functions.Services { public string Action { get; set; } = "Block"; } - - private static string EncodeRowKey(string pattern) - => Uri.EscapeDataString(pattern); - - private static string DecodeRowKey(string encoded) - => Uri.UnescapeDataString(encoded); } - /// - /// Represents a version block rule returned by the API. - /// - public class BlockedVersionEntry - { - public string VersionPattern { get; set; } = string.Empty; - public string Action { get; set; } = "Block"; - public string CreatedByEmail { get; set; } = string.Empty; - public DateTime CreatedAt { get; set; } - public string? Reason { get; set; } - } + // Note: BlockedVersionEntry is now defined in AutopilotMonitor.Shared.DataAccess.IDeviceSecurityRepository } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/BootstrapSessionService.cs b/src/Backend/AutopilotMonitor.Functions/Services/BootstrapSessionService.cs index 13b0bf89..c42cbb8c 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/BootstrapSessionService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/BootstrapSessionService.cs @@ -3,47 +3,39 @@ using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; using System.Threading.Tasks; -using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; -using Azure; -using Azure.Data.Tables; using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Services { /// /// Service for managing bootstrap sessions (OOBE pre-enrollment agent deployment). - /// Uses a single Azure Table with two partition schemes: - /// - Main entity: PartitionKey = TenantId, RowKey = ShortCode (for tenant-scoped listing) - /// - Lookup entity: PartitionKey = "CodeLookup", RowKey = ShortCode (for anonymous code validation) + /// Delegates storage operations to IBootstrapRepository while keeping + /// caching, short code generation, and business logic. /// public class BootstrapSessionService { - private readonly TableClient _tableClient; + private readonly IBootstrapRepository _bootstrapRepo; private readonly ILogger _logger; private readonly IMemoryCache _cache; // Charset for short codes: no ambiguous chars (0/O, 1/l/I) private const string ShortCodeCharset = "23456789abcdefghjkmnpqrstuvwxyz"; private const int ShortCodeLength = 6; - private const string CodeLookupPartition = "CodeLookup"; // Token validation cache TTL (short enough for revocation to propagate) private static readonly TimeSpan TokenCacheDuration = TimeSpan.FromSeconds(60); public BootstrapSessionService( - IConfiguration configuration, + IBootstrapRepository bootstrapRepo, ILogger logger, IMemoryCache cache) { + _bootstrapRepo = bootstrapRepo; _logger = logger; _cache = cache; - - var connectionString = configuration["AzureTableStorageConnectionString"]; - var serviceClient = new TableServiceClient(connectionString); - _tableClient = serviceClient.GetTableClient(Constants.TableNames.BootstrapSessions); } /// @@ -63,59 +55,32 @@ namespace AutopilotMonitor.Functions.Services { shortCode = GenerateShortCode(); - try + var session = new BootstrapSession { - // Try to insert the lookup entity first (acts as uniqueness guard) - var lookupEntity = new TableEntity(CodeLookupPartition, shortCode) - { - { "TenantId", tenantId }, - { "Token", token }, - { "ExpiresAt", now.AddHours(validityHours) }, - { "IsRevoked", false } - }; - await _tableClient.AddEntityAsync(lookupEntity); - break; // No collision - } - catch (RequestFailedException ex) when (ex.Status == 409) + TenantId = tenantId, + ShortCode = shortCode, + Token = token, + CreatedAt = now, + ExpiresAt = now.AddHours(validityHours), + CreatedByUpn = createdByUpn, + IsRevoked = false, + UsageCount = 0, + Label = label ?? "" + }; + + var created = await _bootstrapRepo.CreateBootstrapSessionAsync(session); + if (created) { - _logger.LogWarning("Bootstrap short code collision on attempt {Attempt}: {Code}", attempt + 1, shortCode!); - shortCode = null; + _logger.LogInformation("Created bootstrap session {ShortCode} for tenant {TenantId}, expires {ExpiresAt}", + shortCode, tenantId, session.ExpiresAt); + return session; } + + _logger.LogWarning("Bootstrap short code collision on attempt {Attempt}: {Code}", attempt + 1, shortCode); + shortCode = null; } - if (shortCode == null) - throw new InvalidOperationException("Failed to generate unique short code after 5 attempts"); - - // Insert the main entity - var session = new BootstrapSession - { - TenantId = tenantId, - ShortCode = shortCode, - Token = token, - CreatedAt = now, - ExpiresAt = now.AddHours(validityHours), - CreatedByUpn = createdByUpn, - IsRevoked = false, - UsageCount = 0, - Label = label ?? "" - }; - - var mainEntity = new TableEntity(tenantId, shortCode) - { - { "Token", session.Token }, - { "CreatedAt", session.CreatedAt }, - { "ExpiresAt", session.ExpiresAt }, - { "CreatedByUpn", session.CreatedByUpn }, - { "IsRevoked", false }, - { "UsageCount", 0 }, - { "Label", session.Label } - }; - await _tableClient.AddEntityAsync(mainEntity); - - _logger.LogInformation("Created bootstrap session {ShortCode} for tenant {TenantId}, expires {ExpiresAt}", - shortCode, tenantId, session.ExpiresAt); - - return session; + throw new InvalidOperationException("Failed to generate unique short code after 5 attempts"); } /// @@ -123,25 +88,7 @@ namespace AutopilotMonitor.Functions.Services /// public async Task> ListAsync(string tenantId) { - var cutoff = DateTime.UtcNow.AddHours(-24); - var sessions = new List(); - - var query = _tableClient.QueryAsync( - filter: $"PartitionKey eq '{tenantId}'"); - - await foreach (var entity in query) - { - var expiresAt = entity.GetDateTime("ExpiresAt") ?? DateTime.MinValue; - var isRevoked = entity.GetBoolean("IsRevoked") ?? false; - - // Skip sessions that expired more than 24h ago and are not active - if (expiresAt < cutoff && (isRevoked || expiresAt < DateTime.UtcNow)) - continue; - - sessions.Add(MapFromEntity(entity)); - } - - return sessions.OrderByDescending(s => s.CreatedAt).ToList(); + return await _bootstrapRepo.GetBootstrapSessionsAsync(tenantId); } /// @@ -149,36 +96,21 @@ namespace AutopilotMonitor.Functions.Services /// public async Task RevokeAsync(string tenantId, string shortCode) { - try + // We need to invalidate the token cache entry before revoking + // First get the session to find the token + var sessions = await _bootstrapRepo.GetBootstrapSessionsAsync(tenantId); + var session = sessions.FirstOrDefault(s => s.ShortCode == shortCode); + if (session?.Token != null) { - // Update main entity - var mainEntity = await _tableClient.GetEntityAsync(tenantId, shortCode); - mainEntity.Value["IsRevoked"] = true; - await _tableClient.UpdateEntityAsync(mainEntity.Value, mainEntity.Value.ETag, TableUpdateMode.Merge); - - // Update lookup entity - try - { - var lookupEntity = await _tableClient.GetEntityAsync(CodeLookupPartition, shortCode); - lookupEntity.Value["IsRevoked"] = true; - await _tableClient.UpdateEntityAsync(lookupEntity.Value, lookupEntity.Value.ETag, TableUpdateMode.Merge); - } - catch (RequestFailedException ex) when (ex.Status == 404) - { - _logger.LogWarning("Lookup entity not found for bootstrap code {ShortCode} during revoke", shortCode); - } - - // Invalidate cache - _cache.Remove($"bootstrap-token:{GetTokenFromEntity(mainEntity.Value)}"); + _cache.Remove($"bootstrap-token:{session.Token}"); + } + var result = await _bootstrapRepo.RevokeBootstrapSessionAsync(shortCode); + if (result) + { _logger.LogInformation("Revoked bootstrap session {ShortCode} for tenant {TenantId}", shortCode, tenantId); - return true; - } - catch (RequestFailedException ex) when (ex.Status == 404) - { - _logger.LogWarning("Bootstrap session {ShortCode} not found for tenant {TenantId}", shortCode, tenantId); - return false; } + return result; } /// @@ -187,44 +119,13 @@ namespace AutopilotMonitor.Functions.Services /// public async Task ValidateCodeAsync(string shortCode) { - try - { - // O(1) lookup via the CodeLookup partition - var lookupEntity = await _tableClient.GetEntityAsync(CodeLookupPartition, shortCode); - var lookup = lookupEntity.Value; + var session = await _bootstrapRepo.GetBootstrapSessionByCodeAsync(shortCode); + if (session == null) return null; - var isRevoked = lookup.GetBoolean("IsRevoked") ?? false; - var expiresAt = lookup.GetDateTime("ExpiresAt") ?? DateTime.MinValue; + // Increment usage count (fire-and-forget) + _ = _bootstrapRepo.IncrementBootstrapUsageAsync(shortCode); - if (isRevoked) - { - _logger.LogWarning("Bootstrap code {ShortCode} has been revoked", shortCode); - return null; - } - - if (expiresAt < DateTime.UtcNow) - { - _logger.LogWarning("Bootstrap code {ShortCode} has expired (expired {ExpiresAt})", shortCode, expiresAt); - return null; - } - - var tenantId = lookup.GetString("TenantId") ?? ""; - var token = lookup.GetString("Token") ?? ""; - - // Read main entity for full details - var mainEntity = await _tableClient.GetEntityAsync(tenantId, shortCode); - var session = MapFromEntity(mainEntity.Value); - - // Increment usage count (fire-and-forget) - _ = IncrementUsageCountAsync(tenantId, shortCode); - - return session; - } - catch (RequestFailedException ex) when (ex.Status == 404) - { - _logger.LogWarning("Bootstrap code {ShortCode} not found", shortCode); - return null; - } + return session; } /// @@ -245,22 +146,9 @@ namespace AutopilotMonitor.Functions.Services _cache.Remove(cacheKey); } - // Query for the token across all partitions (excluding CodeLookup) - // This is a table scan filtered by Token property — acceptable because: - // 1. Called rarely (once per 60s per token due to caching) - // 2. BootstrapSessions table is small (dozens of entries, not millions) - var query = _tableClient.QueryAsync( - filter: $"Token eq '{token}' and PartitionKey ne '{CodeLookupPartition}'", - maxPerPage: 1); + var session = await _bootstrapRepo.ValidateBootstrapTokenAsync(token); - BootstrapSession? session = null; - await foreach (var entity in query) - { - session = MapFromEntity(entity); - break; // We only need the first match - } - - if (session == null || session.IsRevoked || session.ExpiresAt < DateTime.UtcNow) + if (session == null) { // Cache negative result briefly to prevent repeated lookups _cache.Set(cacheKey, null, TimeSpan.FromSeconds(10)); @@ -277,31 +165,7 @@ namespace AutopilotMonitor.Functions.Services /// public async Task CleanupExpiredAsync() { - var cutoff = DateTime.UtcNow.AddDays(-7); - var toDelete = new List<(string partitionKey, string rowKey, ETag etag)>(); - - var query = _tableClient.QueryAsync( - filter: $"ExpiresAt lt datetime'{cutoff:yyyy-MM-ddTHH:mm:ssZ}'"); - - await foreach (var entity in query) - { - toDelete.Add((entity.PartitionKey, entity.RowKey, entity.ETag)); - } - - foreach (var (partitionKey, rowKey, etag) in toDelete) - { - try - { - await _tableClient.DeleteEntityAsync(partitionKey, rowKey, etag); - } - catch (RequestFailedException ex) when (ex.Status == 404) - { - // Already deleted — ignore - } - } - - if (toDelete.Count > 0) - _logger.LogInformation("Cleaned up {Count} expired bootstrap sessions", toDelete.Count); + await _bootstrapRepo.CleanupExpiredAsync(); } private static string GenerateShortCode() @@ -318,41 +182,5 @@ namespace AutopilotMonitor.Functions.Services } return new string(chars); } - - private static BootstrapSession MapFromEntity(TableEntity entity) - { - return new BootstrapSession - { - TenantId = entity.PartitionKey, - ShortCode = entity.RowKey, - Token = entity.GetString("Token"), - CreatedAt = entity.GetDateTime("CreatedAt") ?? DateTime.MinValue, - ExpiresAt = entity.GetDateTime("ExpiresAt") ?? DateTime.MinValue, - CreatedByUpn = entity.GetString("CreatedByUpn") ?? "", - IsRevoked = entity.GetBoolean("IsRevoked") ?? false, - UsageCount = entity.GetInt32("UsageCount") ?? 0, - Label = entity.GetString("Label") ?? "" - }; - } - - private static string GetTokenFromEntity(TableEntity entity) - { - return entity.GetString("Token") ?? ""; - } - - private async Task IncrementUsageCountAsync(string tenantId, string shortCode) - { - try - { - var entity = await _tableClient.GetEntityAsync(tenantId, shortCode); - var currentCount = entity.Value.GetInt32("UsageCount") ?? 0; - entity.Value["UsageCount"] = currentCount + 1; - await _tableClient.UpdateEntityAsync(entity.Value, entity.Value.ETag, TableUpdateMode.Merge); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to increment usage count for bootstrap code {ShortCode}", shortCode); - } - } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs b/src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs index 60d039c7..f7ab3c2e 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/GatherRuleService.cs @@ -1,3 +1,4 @@ +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Extensions.Logging; using System.Linq; @@ -13,13 +14,13 @@ namespace AutopilotMonitor.Functions.Services /// public class GatherRuleService { - private readonly TableStorageService _storageService; + private readonly IRuleRepository _ruleRepo; private readonly ILogger _logger; private bool _seeded = false; - public GatherRuleService(TableStorageService storageService, ILogger logger) + public GatherRuleService(IRuleRepository ruleRepo, ILogger logger) { - _storageService = storageService; + _ruleRepo = ruleRepo; _logger = logger; } @@ -40,14 +41,14 @@ namespace AutopilotMonitor.Functions.Services await EnsureBuiltInRulesSeededAsync(); // Global rules: built-in + community (single source of truth for definitions) - var globalRules = await _storageService.GetGatherRulesAsync("global"); + var globalRules = await _ruleRepo.GetGatherRulesAsync("global"); // Tenant rules: only custom rules (IsBuiltIn=false, IsCommunity=false) - var tenantRules = await _storageService.GetGatherRulesAsync(tenantId); + var tenantRules = await _ruleRepo.GetGatherRulesAsync(tenantId); var customRules = tenantRules.Where(r => !r.IsBuiltIn && !r.IsCommunity).ToList(); // Per-tenant enabled/disabled states for global rules - var ruleStates = await _storageService.GetRuleStatesAsync(tenantId); + var ruleStates = await _ruleRepo.GetRuleStatesAsync(tenantId); var mergedRules = new List(); @@ -74,7 +75,7 @@ namespace AutopilotMonitor.Functions.Services rule.IsCommunity = false; rule.CreatedAt = DateTime.UtcNow; rule.UpdatedAt = DateTime.UtcNow; - return await _storageService.StoreGatherRuleAsync(rule, tenantId); + return await _ruleRepo.StoreGatherRuleAsync(rule, tenantId); } /// @@ -89,13 +90,13 @@ namespace AutopilotMonitor.Functions.Services { // Always check global rules for type determination — the incoming payload // may not include isBuiltIn/isCommunity (e.g. toggle requests). - var globalRules = await _storageService.GetGatherRulesAsync("global"); + var globalRules = await _ruleRepo.GetGatherRulesAsync("global"); var globalRule = globalRules.FirstOrDefault(r => r.RuleId == rule.RuleId); if (globalRule != null && (globalRule.IsBuiltIn || globalRule.IsCommunity)) { // Built-in/community rule: only persist enabled state per tenant - return await _storageService.StoreRuleStateAsync(tenantId, rule.RuleId, rule.Enabled); + return await _ruleRepo.StoreRuleStateAsync(tenantId, rule.RuleId, rule.Enabled); } // Ensure custom rules keep correct flags (the incoming payload may @@ -103,7 +104,7 @@ namespace AutopilotMonitor.Functions.Services rule.IsBuiltIn = false; rule.IsCommunity = false; rule.UpdatedAt = DateTime.UtcNow; - return await _storageService.StoreGatherRuleAsync(rule, tenantId); + return await _ruleRepo.StoreGatherRuleAsync(rule, tenantId); } /// @@ -115,10 +116,10 @@ namespace AutopilotMonitor.Functions.Services { if (rule.IsBuiltIn || rule.IsCommunity) { - return await _storageService.DeleteRuleStateAsync(tenantId, rule.RuleId); + return await _ruleRepo.DeleteRuleStateAsync(tenantId, rule.RuleId); } - return await _storageService.DeleteGatherRuleAsync(tenantId, rule.RuleId); + return await _ruleRepo.DeleteGatherRuleAsync(tenantId, rule.RuleId); } /// @@ -130,12 +131,12 @@ namespace AutopilotMonitor.Functions.Services { _logger.LogInformation("Reseeding built-in gather rules (full re-import)..."); - var existingGlobalRules = await _storageService.GetGatherRulesAsync("global"); + var existingGlobalRules = await _ruleRepo.GetGatherRulesAsync("global"); var deleted = 0; foreach (var rule in existingGlobalRules.Where(r => r.IsBuiltIn)) { - await _storageService.DeleteGatherRuleAsync("global", rule.RuleId); + await _ruleRepo.DeleteGatherRuleAsync("global", rule.RuleId); deleted++; } _logger.LogInformation($"Deleted {deleted} old global built-in gather rules"); @@ -143,7 +144,7 @@ namespace AutopilotMonitor.Functions.Services var builtInRules = BuiltInGatherRules.GetAll(); foreach (var rule in builtInRules) { - await _storageService.StoreGatherRuleAsync(rule, "global"); + await _ruleRepo.StoreGatherRuleAsync(rule, "global"); } _logger.LogInformation($"Written {builtInRules.Count} built-in gather rules from code"); @@ -160,7 +161,7 @@ namespace AutopilotMonitor.Functions.Services { if (_seeded) return; - var existingRules = await _storageService.GetGatherRulesAsync("global"); + var existingRules = await _ruleRepo.GetGatherRulesAsync("global"); var builtInRules = BuiltInGatherRules.GetAll(); if (existingRules.Count == 0) @@ -168,7 +169,7 @@ namespace AutopilotMonitor.Functions.Services _logger.LogInformation("Seeding built-in gather rules..."); foreach (var rule in builtInRules) { - await _storageService.StoreGatherRuleAsync(rule, "global"); + await _ruleRepo.StoreGatherRuleAsync(rule, "global"); } _logger.LogInformation($"Seeded {builtInRules.Count} built-in gather rules"); } @@ -189,14 +190,14 @@ namespace AutopilotMonitor.Functions.Services || existing.Trigger != rule.Trigger || existing.TriggerPhase != rule.TriggerPhase) { - await _storageService.StoreGatherRuleAsync(rule, "global"); + await _ruleRepo.StoreGatherRuleAsync(rule, "global"); updated++; } } else { // New built-in rule added in code - await _storageService.StoreGatherRuleAsync(rule, "global"); + await _ruleRepo.StoreGatherRuleAsync(rule, "global"); updated++; } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs b/src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs index 034057d3..309fd0b9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/GlobalAdminService.cs @@ -1,7 +1,6 @@ -using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; using Azure.Data.Tables; using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Services; @@ -12,21 +11,19 @@ namespace AutopilotMonitor.Functions.Services; /// public class GlobalAdminService { - private readonly TableServiceClient _tableServiceClient; + private readonly IAdminRepository _adminRepo; private readonly IMemoryCache _cache; private readonly ILogger _logger; private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5); public GlobalAdminService( - IConfiguration configuration, + IAdminRepository adminRepo, IMemoryCache cache, ILogger logger) { + _adminRepo = adminRepo; _cache = cache; _logger = logger; - var connectionString = configuration["AzureTableStorageConnectionString"]; - _tableServiceClient = new TableServiceClient(connectionString); - // Table is initialized centrally by TableInitializerService at startup } /// @@ -55,12 +52,11 @@ public class GlobalAdminService return isAdmin; } - // Query Table Storage - _logger.LogInformation($"Querying Table Storage for Global Admin: {upn}"); - var admin = await GetGlobalAdminAsync(upn); - var result = admin != null && admin.IsEnabled; + // Query via repository + _logger.LogInformation($"Querying repository for Global Admin: {upn}"); + var result = await _adminRepo.IsGlobalAdminAsync(upn); - _logger.LogInformation($"Global Admin check result: {upn} -> {result} (Entity found: {admin != null}, IsEnabled: {admin?.IsEnabled})"); + _logger.LogInformation($"Global Admin check result: {upn} -> {result}"); // Cache the result _cache.Set(cacheKey, result, _cacheDuration); @@ -68,40 +64,6 @@ public class GlobalAdminService return result; } - /// - /// Gets a Global Admin entity by UPN - /// - private async Task GetGlobalAdminAsync(string upn) - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GlobalAdmins); - var normalizedUpn = upn.ToLowerInvariant(); - - _logger.LogDebug($"Querying Table Storage - PartitionKey: 'GlobalAdmins', RowKey: '{normalizedUpn}'"); - - // PartitionKey = "GlobalAdmins", RowKey = UPN - var entity = await tableClient.GetEntityAsync( - "GlobalAdmins", - normalizedUpn - ); - - _logger.LogDebug($"Global Admin entity found for {normalizedUpn}"); - return entity?.Value; - } - catch (Azure.RequestFailedException ex) when (ex.Status == 404) - { - // Entity not found - _logger.LogDebug($"Global Admin entity NOT found for {upn} (404)"); - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Error querying Global Admin for {upn}"); - return null; - } - } - /// /// Adds a user as a Global Admin /// @@ -112,7 +74,12 @@ public class GlobalAdminService upn = upn.ToLowerInvariant(); addedBy = addedBy.ToLowerInvariant(); - var entity = new GlobalAdminEntity + await _adminRepo.AddGlobalAdminAsync(upn, addedBy); + + // Invalidate cache + _cache.Remove($"global-admin:{upn}"); + + return new GlobalAdminEntity { PartitionKey = "GlobalAdmins", RowKey = upn, @@ -121,14 +88,6 @@ public class GlobalAdminService AddedDate = DateTime.UtcNow, AddedBy = addedBy }; - - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GlobalAdmins); - await tableClient.UpsertEntityAsync(entity); - - // Invalidate cache - _cache.Remove($"global-admin:{upn}"); - - return entity; } /// @@ -138,8 +97,7 @@ public class GlobalAdminService { upn = upn.ToLowerInvariant(); - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GlobalAdmins); - await tableClient.DeleteEntityAsync("GlobalAdmins", upn); + await _adminRepo.RemoveGlobalAdminAsync(upn); // Invalidate cache _cache.Remove($"global-admin:{upn}"); @@ -152,17 +110,10 @@ public class GlobalAdminService { upn = upn.ToLowerInvariant(); - var admin = await GetGlobalAdminAsync(upn); - if (admin != null) - { - admin.IsEnabled = false; + await _adminRepo.DisableGlobalAdminAsync(upn); - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GlobalAdmins); - await tableClient.UpdateEntityAsync(admin, Azure.ETag.All); - - // Invalidate cache - _cache.Remove($"global-admin:{upn}"); - } + // Invalidate cache + _cache.Remove($"global-admin:{upn}"); } /// @@ -170,16 +121,17 @@ public class GlobalAdminService /// public async Task> GetAllGlobalAdminsAsync() { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GlobalAdmins); + var entries = await _adminRepo.GetAllGlobalAdminsAsync(); - var admins = new List(); - await foreach (var entity in tableClient.QueryAsync( - filter: $"PartitionKey eq 'GlobalAdmins'")) + return entries.Select(e => new GlobalAdminEntity { - admins.Add(entity); - } - - return admins; + PartitionKey = "GlobalAdmins", + RowKey = e.Upn, + Upn = e.Upn, + IsEnabled = e.IsEnabled, + AddedDate = e.AddedAt, + AddedBy = e.AddedBy + }).ToList(); } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/GlobalNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/GlobalNotificationService.cs index ab10e6de..b6c8a055 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/GlobalNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/GlobalNotificationService.cs @@ -1,7 +1,6 @@ -using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; using Azure; using Azure.Data.Tables; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Services; @@ -42,19 +41,19 @@ public class GlobalNotificationDto /// /// Service for managing persistent Global Admin in-app notifications. /// Notifications survive page reloads and persist until actively dismissed. +/// Delegates storage to INotificationRepository; keeps business logic (fire-and-forget, DTO conversion). /// public class GlobalNotificationService { - private readonly TableServiceClient _tableServiceClient; + private readonly INotificationRepository _notificationRepo; private readonly ILogger _logger; public GlobalNotificationService( - IConfiguration configuration, + INotificationRepository notificationRepo, ILogger logger) { + _notificationRepo = notificationRepo; _logger = logger; - var connectionString = configuration["AzureTableStorageConnectionString"]; - _tableServiceClient = new TableServiceClient(connectionString); } /// @@ -65,24 +64,20 @@ public class GlobalNotificationService { try { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GlobalNotifications); var notificationId = Guid.NewGuid().ToString("N")[..12]; - var invertedTicks = (DateTime.MaxValue.Ticks - DateTime.UtcNow.Ticks).ToString("D19"); - var entity = new GlobalNotificationEntity + var notification = new GlobalNotification { - PartitionKey = "notifications", - RowKey = $"{invertedTicks}_{notificationId}", NotificationId = notificationId, Type = type, Title = title, Message = message, Href = href, CreatedAt = DateTime.UtcNow, - Dismissed = false + IsDismissed = false }; - await tableClient.UpsertEntityAsync(entity); + await _notificationRepo.AddNotificationAsync(notification); _logger.LogInformation("Global notification created: {NotificationId} ({Type})", notificationId, type); } catch (Exception ex) @@ -98,29 +93,17 @@ public class GlobalNotificationService { try { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GlobalNotifications); - var notifications = new List(); + var notifications = await _notificationRepo.GetNotificationsAsync(); - await foreach (var entity in tableClient.QueryAsync( - e => e.PartitionKey == "notifications" && !e.Dismissed)) + return notifications.Select(n => new GlobalNotificationDto { - notifications.Add(new GlobalNotificationDto - { - Id = entity.NotificationId, - Type = entity.Type, - Title = entity.Title, - Message = entity.Message, - Href = entity.Href, - CreatedAt = entity.CreatedAt - }); - } - - return notifications; - } - catch (RequestFailedException ex) when (ex.Status == 404) - { - _logger.LogWarning("GlobalNotifications table not found — returning empty list"); - return new List(); + Id = n.NotificationId, + Type = n.Type, + Title = n.Title, + Message = n.Message, + Href = n.Href, + CreatedAt = n.CreatedAt + }).ToList(); } catch (Exception ex) { @@ -137,18 +120,7 @@ public class GlobalNotificationService { try { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GlobalNotifications); - - await foreach (var entity in tableClient.QueryAsync( - e => e.PartitionKey == "notifications" && e.NotificationId == notificationId)) - { - entity.Dismissed = true; - await tableClient.UpdateEntityAsync(entity, entity.ETag, TableUpdateMode.Replace); - _logger.LogInformation("Global notification dismissed: {NotificationId}", notificationId); - return true; - } - - return false; + return await _notificationRepo.DismissNotificationAsync(notificationId, string.Empty); } catch (Exception ex) { @@ -164,19 +136,7 @@ public class GlobalNotificationService { try { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.GlobalNotifications); - var count = 0; - - await foreach (var entity in tableClient.QueryAsync( - e => e.PartitionKey == "notifications" && !e.Dismissed)) - { - entity.Dismissed = true; - await tableClient.UpdateEntityAsync(entity, entity.ETag, TableUpdateMode.Replace); - count++; - } - - _logger.LogInformation("Dismissed {Count} global notifications", count); - return count; + return await _notificationRepo.DismissAllNotificationsAsync(); } catch (Exception ex) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/ImeLogPatternService.cs b/src/Backend/AutopilotMonitor.Functions/Services/ImeLogPatternService.cs index ae741ec5..636710a5 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/ImeLogPatternService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/ImeLogPatternService.cs @@ -1,3 +1,4 @@ +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Extensions.Logging; using System.Linq; @@ -10,13 +11,13 @@ namespace AutopilotMonitor.Functions.Services /// public class ImeLogPatternService { - private readonly TableStorageService _storageService; + private readonly IRuleRepository _ruleRepo; private readonly ILogger _logger; private bool _seeded = false; - public ImeLogPatternService(TableStorageService storageService, ILogger logger) + public ImeLogPatternService(IRuleRepository ruleRepo, ILogger logger) { - _storageService = storageService; + _ruleRepo = ruleRepo; _logger = logger; } @@ -28,8 +29,8 @@ namespace AutopilotMonitor.Functions.Services { await EnsureBuiltInPatternsSeededAsync(); - var globalPatterns = await _storageService.GetImeLogPatternsAsync("global"); - var tenantPatterns = await _storageService.GetImeLogPatternsAsync(tenantId); + var globalPatterns = await _ruleRepo.GetImeLogPatternsAsync("global"); + var tenantPatterns = await _ruleRepo.GetImeLogPatternsAsync(tenantId); var tenantOverrides = tenantPatterns.ToDictionary(p => p.PatternId, p => p); @@ -63,8 +64,8 @@ namespace AutopilotMonitor.Functions.Services { await EnsureBuiltInPatternsSeededAsync(); - var globalPatterns = await _storageService.GetImeLogPatternsAsync("global"); - var tenantPatterns = await _storageService.GetImeLogPatternsAsync(tenantId); + var globalPatterns = await _ruleRepo.GetImeLogPatternsAsync("global"); + var tenantPatterns = await _ruleRepo.GetImeLogPatternsAsync(tenantId); var tenantOverrides = tenantPatterns.ToDictionary(p => p.PatternId, p => p); var mergedPatterns = new List(); @@ -96,7 +97,7 @@ namespace AutopilotMonitor.Functions.Services public async Task CreatePatternAsync(string tenantId, ImeLogPattern pattern) { pattern.IsBuiltIn = false; - return await _storageService.StoreImeLogPatternAsync(pattern, tenantId); + return await _ruleRepo.StoreImeLogPatternAsync(pattern, tenantId); } /// @@ -105,7 +106,7 @@ namespace AutopilotMonitor.Functions.Services /// public async Task UpdatePatternAsync(string tenantId, ImeLogPattern pattern) { - return await _storageService.StoreImeLogPatternAsync(pattern, tenantId); + return await _ruleRepo.StoreImeLogPatternAsync(pattern, tenantId); } /// @@ -115,7 +116,7 @@ namespace AutopilotMonitor.Functions.Services public async Task UpdateGlobalPatternAsync(ImeLogPattern pattern) { pattern.IsBuiltIn = true; - return await _storageService.StoreImeLogPatternAsync(pattern, "global"); + return await _ruleRepo.StoreImeLogPatternAsync(pattern, "global"); } /// @@ -123,7 +124,7 @@ namespace AutopilotMonitor.Functions.Services /// public async Task DeletePatternAsync(string tenantId, string patternId) { - return await _storageService.DeleteImeLogPatternAsync(tenantId, patternId); + return await _ruleRepo.DeleteImeLogPatternAsync(tenantId, patternId); } /// @@ -132,7 +133,7 @@ namespace AutopilotMonitor.Functions.Services public async Task DeleteGlobalPatternAsync(string patternId) { _seeded = false; - return await _storageService.DeleteImeLogPatternAsync("global", patternId); + return await _ruleRepo.DeleteImeLogPatternAsync("global", patternId); } /// @@ -143,12 +144,12 @@ namespace AutopilotMonitor.Functions.Services { _logger.LogInformation("Reseeding built-in IME log patterns (full re-import)..."); - var existingGlobalPatterns = await _storageService.GetImeLogPatternsAsync("global"); + var existingGlobalPatterns = await _ruleRepo.GetImeLogPatternsAsync("global"); var deleted = 0; foreach (var pattern in existingGlobalPatterns.Where(p => p.IsBuiltIn)) { - await _storageService.DeleteImeLogPatternAsync("global", pattern.PatternId); + await _ruleRepo.DeleteImeLogPatternAsync("global", pattern.PatternId); deleted++; } _logger.LogInformation($"Deleted {deleted} old global built-in IME log patterns"); @@ -157,7 +158,7 @@ namespace AutopilotMonitor.Functions.Services foreach (var pattern in builtInPatterns) { pattern.IsBuiltIn = true; - await _storageService.StoreImeLogPatternAsync(pattern, "global"); + await _ruleRepo.StoreImeLogPatternAsync(pattern, "global"); } _logger.LogInformation($"Written {builtInPatterns.Count} built-in IME log patterns from code"); @@ -174,7 +175,7 @@ namespace AutopilotMonitor.Functions.Services { if (_seeded) return; - var existingPatterns = await _storageService.GetImeLogPatternsAsync("global"); + var existingPatterns = await _ruleRepo.GetImeLogPatternsAsync("global"); var builtInPatterns = BuiltInImeLogPatterns.GetAll(); if (existingPatterns.Count == 0) @@ -183,7 +184,7 @@ namespace AutopilotMonitor.Functions.Services foreach (var pattern in builtInPatterns) { pattern.IsBuiltIn = true; - await _storageService.StoreImeLogPatternAsync(pattern, "global"); + await _ruleRepo.StoreImeLogPatternAsync(pattern, "global"); } _logger.LogInformation($"Seeded {builtInPatterns.Count} built-in IME log patterns"); } @@ -200,13 +201,13 @@ namespace AutopilotMonitor.Functions.Services if (existing.Pattern != pattern.Pattern || existing.Action != pattern.Action || existing.Category != pattern.Category || existing.Description != pattern.Description) { - await _storageService.StoreImeLogPatternAsync(pattern, "global"); + await _ruleRepo.StoreImeLogPatternAsync(pattern, "global"); updated++; } } else { - await _storageService.StoreImeLogPatternAsync(pattern, "global"); + await _ruleRepo.StoreImeLogPatternAsync(pattern, "global"); updated++; } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs index df91c3a4..ebe3d523 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.Aggregation.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Text; using System.Threading.Tasks; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; @@ -31,7 +32,7 @@ namespace AutopilotMonitor.Functions.Services try { - if (await _storageService.HasUsageMetricsSnapshotAsync(dateStr)) + if (await _metricsRepo.HasUsageMetricsSnapshotAsync(dateStr)) continue; _logger.LogInformation($"Catch-up: Aggregating metrics for missed date {dateStr}"); @@ -62,7 +63,7 @@ namespace AutopilotMonitor.Functions.Services { var targetDateStr = targetDate.ToString("yyyy-MM-dd"); - var targetDateSessions = await _storageService.GetSessionsByDateRangeAsync(targetDate, targetDate.AddDays(1)); + var targetDateSessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(targetDate, targetDate.AddDays(1)); if (targetDateSessions.Count == 0) { @@ -71,13 +72,13 @@ namespace AutopilotMonitor.Functions.Services } var globalMetrics = await ComputeUsageMetricsSnapshotAsync(targetDateStr, "global", targetDateSessions); - await _storageService.SaveUsageMetricsSnapshotAsync(globalMetrics); + await _metricsRepo.SaveUsageMetricsSnapshotAsync(globalMetrics); var tenantGroups = targetDateSessions.GroupBy(s => s.TenantId); foreach (var tenantGroup in tenantGroups) { var tenantMetrics = await ComputeUsageMetricsSnapshotAsync(targetDateStr, tenantGroup.Key, tenantGroup.ToList()); - await _storageService.SaveUsageMetricsSnapshotAsync(tenantMetrics); + await _metricsRepo.SaveUsageMetricsSnapshotAsync(tenantMetrics); } aggregateStart.Stop(); @@ -128,7 +129,7 @@ namespace AutopilotMonitor.Functions.Services .ToList(); var targetDate = DateTime.ParseExact(date, "yyyy-MM-dd", null); - var (uniqueUsers, loginCount) = await _storageService.GetUserActivityForDateAsync( + var (uniqueUsers, loginCount) = await _metricsRepo.GetUserActivityForDateAsync( tenantId == "global" ? null : tenantId, targetDate); computeStart.Stop(); @@ -168,7 +169,7 @@ namespace AutopilotMonitor.Functions.Services try { - var tenantIds = await _storageService.GetAllTenantIdsAsync(); + var tenantIds = await _maintenanceRepo.GetAllTenantIdsAsync(); int totalSessionsDeleted = 0; int totalEventsDeleted = 0; @@ -188,7 +189,7 @@ namespace AutopilotMonitor.Functions.Services var cutoffDate = DateTime.UtcNow.AddDays(-retentionDays); - var oldSessions = await _storageService.GetSessionsOlderThanAsync(tenantId, cutoffDate); + var oldSessions = await _maintenanceRepo.GetSessionsOlderThanAsync(tenantId, cutoffDate); if (oldSessions.Count == 0) { @@ -203,23 +204,23 @@ namespace AutopilotMonitor.Functions.Services foreach (var session in oldSessions) { - var deletedEvents = await _storageService.DeleteSessionEventsAsync(session.TenantId, session.SessionId); + var deletedEvents = await _maintenanceRepo.DeleteSessionEventsAsync(session.TenantId, session.SessionId); eventCount += deletedEvents; - var deletedRuleResults = await _storageService.DeleteSessionRuleResultsAsync(session.TenantId, session.SessionId); + var deletedRuleResults = await _maintenanceRepo.DeleteSessionRuleResultsAsync(session.TenantId, session.SessionId); ruleResultCount += deletedRuleResults; - var deletedAppSummaries = await _storageService.DeleteSessionAppInstallSummariesAsync(session.TenantId, session.SessionId); + var deletedAppSummaries = await _maintenanceRepo.DeleteSessionAppInstallSummariesAsync(session.TenantId, session.SessionId); appSummaryCount += deletedAppSummaries; - await _storageService.DeleteSessionAsync(session.TenantId, session.SessionId); + await _sessionRepo.DeleteSessionAsync(session.TenantId, session.SessionId); sessionCount++; } totalSessionsDeleted += sessionCount; totalEventsDeleted += eventCount; - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( tenantId, "DataRetentionCleanup", "Session", @@ -263,7 +264,7 @@ namespace AutopilotMonitor.Functions.Services try { - var tenantIds = await _storageService.GetAllTenantIdsAsync(); + var tenantIds = await _maintenanceRepo.GetAllTenantIdsAsync(); var allConfigs = await _tenantConfigService.GetAllConfigurationsAsync(); long totalEnrollments = 0; long successfulEnrollments = 0; @@ -273,7 +274,7 @@ namespace AutopilotMonitor.Functions.Services foreach (var tid in tenantIds) { - var sessionsPage = await _storageService.GetSessionsAsync(tid, maxResults: 10000); + var sessionsPage = await _sessionRepo.GetSessionsAsync(tid, maxResults: 10000); var sessions = sessionsPage.Sessions; totalEnrollments += sessions.Count; successfulEnrollments += sessions.Count(s => s.Status == SessionStatus.Succeeded); @@ -286,11 +287,11 @@ namespace AutopilotMonitor.Functions.Services totalEvents += s.EventCount; } - var userMetrics = await _storageService.GetUserActivityMetricsAsync(tid); + var userMetrics = await _metricsRepo.GetUserActivityMetricsAsync(tid); totalUsers += userMetrics.TotalUniqueUsers; } - var existingStats = await _storageService.GetPlatformStatsAsync(); + var existingStats = await _metricsRepo.GetPlatformStatsAsync(); var stats = new PlatformStats { @@ -306,7 +307,7 @@ namespace AutopilotMonitor.Functions.Services LastUpdated = DateTime.UtcNow }; - await _storageService.SavePlatformStatsAsync(stats); + await _metricsRepo.SavePlatformStatsAsync(stats); await TryPublishPlatformStatsJsonAsync(stats); sw.Stop(); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs index 72f20da5..c6224653 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs @@ -5,6 +5,7 @@ using System.IO; using System.Linq; using System.Text; using System.Threading.Tasks; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; @@ -40,7 +41,9 @@ namespace AutopilotMonitor.Functions.Services /// public partial class MaintenanceService { - private readonly TableStorageService _storageService; + private readonly IMaintenanceRepository _maintenanceRepo; + private readonly ISessionRepository _sessionRepo; + private readonly IMetricsRepository _metricsRepo; private readonly TenantConfigurationService _tenantConfigService; private readonly UsageMetricsService _usageMetricsService; private readonly AdminConfigurationService _adminConfigurationService; @@ -53,7 +56,9 @@ namespace AutopilotMonitor.Functions.Services private const string PlatformStatsVersionedCacheControl = "public, max-age=31536000, immutable"; public MaintenanceService( - TableStorageService storageService, + IMaintenanceRepository maintenanceRepo, + ISessionRepository sessionRepo, + IMetricsRepository metricsRepo, TenantConfigurationService tenantConfigService, UsageMetricsService usageMetricsService, AdminConfigurationService adminConfigurationService, @@ -61,7 +66,9 @@ namespace AutopilotMonitor.Functions.Services TenantAdminsService tenantAdminsService, ILogger logger) { - _storageService = storageService; + _maintenanceRepo = maintenanceRepo; + _sessionRepo = sessionRepo; + _metricsRepo = metricsRepo; _tenantConfigService = tenantConfigService; _usageMetricsService = usageMetricsService; _adminConfigurationService = adminConfigurationService; @@ -131,12 +138,12 @@ namespace AutopilotMonitor.Functions.Services // --- Backfill & repair tasks (manual-only, not in timer path) --- // Safety net: backfill any sessions missing from SessionsIndex - await _storageService.BackfillSessionIndexAsync(); + await _maintenanceRepo.BackfillSessionIndexAsync(); // One-time cleanup: remove ghost SessionsIndex entries caused by the // StoreSessionAsync Replace-mode IndexRowKey bug (now fixed). // TODO: Remove after 2026-06-01 - await _storageService.CleanupGhostSessionIndexEntriesAsync(); + await _maintenanceRepo.CleanupGhostSessionIndexEntriesAsync(); // Backfill OnboardedAt for tenants that don't have it yet // TODO: Remove once all tenants have been backfilled @@ -174,7 +181,7 @@ namespace AutopilotMonitor.Functions.Services try { - var tenantIds = await _storageService.GetAllTenantIdsAsync(); + var tenantIds = await _maintenanceRepo.GetAllTenantIdsAsync(); int totalSessionsTimedOut = 0; foreach (var tenantId in tenantIds) @@ -185,7 +192,7 @@ namespace AutopilotMonitor.Functions.Services var timeoutHours = config?.SessionTimeoutHours ?? 5; var cutoffTime = DateTime.UtcNow.AddHours(-timeoutHours); - var stalledSessions = await _storageService.GetStalledSessionsAsync(tenantId, cutoffTime); + var stalledSessions = await _maintenanceRepo.GetStalledSessionsAsync(tenantId, cutoffTime); if (stalledSessions.Count == 0) { @@ -197,7 +204,7 @@ namespace AutopilotMonitor.Functions.Services foreach (var session in stalledSessions) { - await _storageService.UpdateSessionStatusAsync( + await _sessionRepo.UpdateSessionStatusAsync( session.TenantId, session.SessionId, SessionStatus.Failed, @@ -208,7 +215,7 @@ namespace AutopilotMonitor.Functions.Services totalSessionsTimedOut += sessionCount; - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( tenantId, "SessionTimeout", "Session", @@ -260,14 +267,14 @@ namespace AutopilotMonitor.Functions.Services var windowCutoff = DateTime.UtcNow.AddHours(-adminConfig.MaxSessionWindowHours); var blockDurationHours = adminConfig.MaintenanceBlockDurationHours; - var tenantIds = await _storageService.GetAllTenantIdsAsync(); + var tenantIds = await _maintenanceRepo.GetAllTenantIdsAsync(); int totalBlocked = 0; foreach (var tenantId in tenantIds) { try { - var sessions = await _storageService.GetExcessiveDataSendersAsync(tenantId, windowCutoff, adminConfig.MaxSessionWindowHours); + var sessions = await _maintenanceRepo.GetExcessiveDataSendersAsync(tenantId, windowCutoff, adminConfig.MaxSessionWindowHours); if (sessions.Count == 0) { @@ -302,7 +309,7 @@ namespace AutopilotMonitor.Functions.Services if (blockedCount > 0) { - await _storageService.LogAuditEntryAsync( + await _maintenanceRepo.LogAuditEntryAsync( tenantId, "ExcessiveDataBlock", "Device", diff --git a/src/Backend/AutopilotMonitor.Functions/Services/PlatformMetricsService.cs b/src/Backend/AutopilotMonitor.Functions/Services/PlatformMetricsService.cs index cb498c1a..dcc47963 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/PlatformMetricsService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/PlatformMetricsService.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Services @@ -13,7 +14,7 @@ namespace AutopilotMonitor.Functions.Services /// public class PlatformMetricsService { - private readonly TableStorageService _storageService; + private readonly ISessionRepository _sessionRepo; private readonly ILogger _logger; // In-memory cache (same pattern as UsageMetricsService) @@ -23,10 +24,10 @@ namespace AutopilotMonitor.Functions.Services private static readonly object _cacheLock = new object(); public PlatformMetricsService( - TableStorageService storageService, + ISessionRepository sessionRepo, ILogger logger) { - _storageService = storageService; + _sessionRepo = sessionRepo; _logger = logger; } @@ -73,7 +74,7 @@ namespace AutopilotMonitor.Functions.Services private async Task ComputePlatformMetricsInternalAsync() { // 1. Fetch the 100 most recent sessions across all tenants - var page = await _storageService.GetAllSessionsAsync(maxResults: 100); + var page = await _sessionRepo.GetAllSessionsAsync(maxResults: 100); var allSessions = page.Sessions; if (allSessions.Count == 0) @@ -88,7 +89,7 @@ namespace AutopilotMonitor.Functions.Services { try { - var events = await _storageService.GetSessionEventsAsync(session.TenantId, session.SessionId); + var events = await _sessionRepo.GetSessionEventsAsync(session.TenantId, session.SessionId); var snapshots = events .Where(e => e.EventType == "agent_metrics_snapshot" && e.Data != null) .Select(e => e.Data) diff --git a/src/Backend/AutopilotMonitor.Functions/Services/PreviewWhitelistService.cs b/src/Backend/AutopilotMonitor.Functions/Services/PreviewWhitelistService.cs index 3f3c35ad..6d327dad 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/PreviewWhitelistService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/PreviewWhitelistService.cs @@ -1,8 +1,6 @@ -using AutopilotMonitor.Shared; -using Azure; -using Azure.Data.Tables; +using AutopilotMonitor.Functions.DataAccess.TableStorage; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Services; @@ -10,25 +8,24 @@ namespace AutopilotMonitor.Functions.Services; /// /// Service for managing the Private Preview tenant whitelist. /// Tenants in this list are allowed full portal access; others see a waitlist page. +/// Caching and business logic layer — delegates storage to IConfigRepository. /// Temporary — remove after GA. /// public class PreviewWhitelistService { - private readonly TableServiceClient _tableServiceClient; + private readonly IConfigRepository _configRepo; private readonly IMemoryCache _cache; private readonly ILogger _logger; private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); public PreviewWhitelistService( - IConfiguration configuration, + IConfigRepository configRepo, IMemoryCache cache, ILogger logger) { + _configRepo = configRepo; _cache = cache; _logger = logger; - var connectionString = configuration["AzureTableStorageConnectionString"]; - _tableServiceClient = new TableServiceClient(connectionString); - // Table is initialized centrally by TableInitializerService at startup } /// @@ -45,18 +42,11 @@ public class PreviewWhitelistService try { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PreviewWhitelist); - var entity = await tableClient.GetEntityAsync(tenantId, "approved"); - var result = entity?.Value != null; + var result = await _configRepo.IsInPreviewWhitelistAsync(tenantId); _cache.Set(cacheKey, result, CacheDuration); return result; } - catch (RequestFailedException ex) when (ex.Status == 404) - { - _cache.Set(cacheKey, false, CacheDuration); - return false; - } catch (Exception ex) { _logger.LogError(ex, "Error checking preview whitelist for tenant {TenantId}", tenantId); @@ -70,16 +60,7 @@ public class PreviewWhitelistService /// public async Task ApproveAsync(string tenantId, string approvedBy) { - var entity = new PreviewWhitelistEntity - { - PartitionKey = tenantId, - RowKey = "approved", - ApprovedAt = DateTime.UtcNow, - ApprovedBy = approvedBy - }; - - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PreviewWhitelist); - await tableClient.UpsertEntityAsync(entity); + await _configRepo.AddToPreviewWhitelistAsync(tenantId, approvedBy); _cache.Remove($"preview:{tenantId}"); _logger.LogInformation("Tenant {TenantId} approved for preview by {ApprovedBy}", tenantId, approvedBy); @@ -90,8 +71,7 @@ public class PreviewWhitelistService /// public async Task RevokeAsync(string tenantId) { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PreviewWhitelist); - await tableClient.DeleteEntityAsync(tenantId, "approved"); + await _configRepo.RemoveFromPreviewWhitelistAsync(tenantId); _cache.Remove($"preview:{tenantId}"); _logger.LogInformation("Tenant {TenantId} revoked from preview", tenantId); @@ -99,19 +79,18 @@ public class PreviewWhitelistService /// /// Returns all approved tenants (for Global Admin overview). + /// Returns PreviewWhitelistEntity list for backward compatibility with existing API consumers. /// public async Task> GetAllApprovedAsync() { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PreviewWhitelist); - var results = new List(); + var tenantIds = await _configRepo.GetPreviewWhitelistAsync(); - await foreach (var entity in tableClient.QueryAsync( - filter: "RowKey eq 'approved'")) + // Convert string list back to entity list for backward compatibility + return tenantIds.Select(id => new PreviewWhitelistEntity { - results.Add(entity); - } - - return results.OrderBy(e => e.PartitionKey).ToList(); + PartitionKey = id, + RowKey = "approved" + }).ToList(); } /// @@ -119,21 +98,7 @@ public class PreviewWhitelistService /// public async Task GetNotificationEmailAsync(string tenantId) { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PreviewWhitelist); - var entity = await tableClient.GetEntityAsync(tenantId, "notification-email"); - return entity?.Value?.Email; - } - catch (RequestFailedException ex) when (ex.Status == 404) - { - return null; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to read notification email for tenant {TenantId}", tenantId); - return null; - } + return await _configRepo.GetNotificationEmailAsync(tenantId); } /// @@ -141,51 +106,6 @@ public class PreviewWhitelistService /// public async Task SaveNotificationEmailAsync(string tenantId, string? email) { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.PreviewWhitelist); - - if (string.IsNullOrWhiteSpace(email)) - { - // Clear: delete the row if it exists - try { await tableClient.DeleteEntityAsync(tenantId, "notification-email"); } - catch (RequestFailedException ex) when (ex.Status == 404) { /* already gone */ } - return; - } - - var entity = new PreviewNotificationEntity - { - PartitionKey = tenantId, - RowKey = "notification-email", - Email = email.Trim() - }; - await tableClient.UpsertEntityAsync(entity); + await _configRepo.SaveNotificationEmailAsync(tenantId, email); } } - -/// -/// Entity representing an approved tenant in the PreviewWhitelist table. -/// -public class PreviewWhitelistEntity : ITableEntity -{ - public string PartitionKey { get; set; } = string.Empty; // TenantId - public string RowKey { get; set; } = "approved"; - public DateTimeOffset? Timestamp { get; set; } - public ETag ETag { get; set; } - - public DateTime ApprovedAt { get; set; } - public string ApprovedBy { get; set; } = string.Empty; -} - -/// -/// Entity storing the notification email for a tenant in the PreviewWhitelist table. -/// PartitionKey = TenantId, RowKey = "notification-email". -/// Temporary — remove after GA. -/// -public class PreviewNotificationEntity : ITableEntity -{ - public string PartitionKey { get; set; } = string.Empty; // TenantId - public string RowKey { get; set; } = "notification-email"; - public DateTimeOffset? Timestamp { get; set; } - public ETag ETag { get; set; } - - public string Email { get; set; } = string.Empty; -} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs index 1fe2d4b9..b47350d9 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/RuleEngine.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Extensions.Logging; @@ -12,13 +13,15 @@ namespace AutopilotMonitor.Functions.Services public partial class RuleEngine { private readonly AnalyzeRuleService _ruleService; - private readonly TableStorageService _storageService; + private readonly IRuleRepository _ruleRepo; + private readonly ISessionRepository _sessionRepo; private readonly ILogger _logger; - public RuleEngine(AnalyzeRuleService ruleService, TableStorageService storageService, ILogger logger) + public RuleEngine(AnalyzeRuleService ruleService, IRuleRepository ruleRepo, ISessionRepository sessionRepo, ILogger logger) { _ruleService = ruleService; - _storageService = storageService; + _ruleRepo = ruleRepo; + _sessionRepo = sessionRepo; _logger = logger; } @@ -34,7 +37,7 @@ namespace AutopilotMonitor.Functions.Services try { var activeRules = await _ruleService.GetActiveRulesForTenantAsync(tenantId); - var allEvents = await _storageService.GetSessionEventsAsync(tenantId, sessionId); + var allEvents = await _sessionRepo.GetSessionEventsAsync(tenantId, sessionId); if (allEvents.Count == 0) { @@ -51,7 +54,7 @@ namespace AutopilotMonitor.Functions.Services } else { - var existingResults = await _storageService.GetRuleResultsAsync(tenantId, sessionId); + var existingResults = await _ruleRepo.GetRuleResultsAsync(tenantId, sessionId); existingRuleIds = new HashSet(existingResults.Select(r => r.RuleId)); } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs b/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs index 54c5e16e..28f8f282 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/SessionReportService.cs @@ -4,11 +4,9 @@ using System.IO; using System.IO.Compression; using System.Text; using System.Threading.Tasks; -using Azure; -using Azure.Data.Tables; using Azure.Storage.Blobs; using Azure.Storage.Blobs.Models; -using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -19,21 +17,21 @@ namespace AutopilotMonitor.Functions.Services /// /// Manages session report submissions from Tenant Admins. /// Creates a ZIP with session data, events, analysis results, timeline TXT, events CSV, - /// and optional screenshot, uploads to central blob storage, and stores metadata in Table Storage. + /// and optional screenshot, uploads to central blob storage, and stores metadata via INotificationRepository. /// public class SessionReportService { - private readonly TableStorageService _storageService; + private readonly INotificationRepository _notificationRepo; private readonly ILogger _logger; private readonly string _blobConnectionString; private const string ContainerName = "session-reports"; public SessionReportService( - TableStorageService storageService, + INotificationRepository notificationRepo, IConfiguration configuration, ILogger logger) { - _storageService = storageService; + _notificationRepo = notificationRepo; _logger = logger; _blobConnectionString = configuration["AzureBlobStorageConnectionString"] ?? throw new InvalidOperationException("AzureBlobStorageConnectionString is not configured"); @@ -142,25 +140,9 @@ namespace AutopilotMonitor.Functions.Services "Session report uploaded: ReportId={ReportId}, BlobName={BlobName}, Tenant={TenantId}, Session={SessionId}", reportId, blobName, request.TenantId, request.SessionId); - // 3. Store metadata in Table Storage + // 3. Store metadata via repository var now = DateTime.UtcNow; - var invertedTicks = (DateTime.MaxValue.Ticks - now.Ticks).ToString("D19"); - var entity = new TableEntity("reports", $"{invertedTicks}_{reportId}") - { - ["ReportId"] = reportId, - ["TenantId"] = request.TenantId ?? string.Empty, - ["SessionId"] = request.SessionId ?? string.Empty, - ["Comment"] = request.Comment ?? string.Empty, - ["Email"] = request.Email ?? string.Empty, - ["BlobName"] = blobName, - ["SubmittedBy"] = submittedBy ?? string.Empty, - ["SubmittedAt"] = now - }; - - var tableClient = _storageService.GetTableServiceClient().GetTableClient(Constants.TableNames.SessionReports); - await tableClient.UpsertEntityAsync(entity); - - return new SessionReportMetadata + var metadata = new SessionReportMetadata { ReportId = reportId, TenantId = request.TenantId, @@ -171,6 +153,10 @@ namespace AutopilotMonitor.Functions.Services SubmittedBy = submittedBy, SubmittedAt = now }; + + await _notificationRepo.StoreSessionReportMetadataAsync(metadata); + + return metadata; } /// @@ -178,34 +164,7 @@ namespace AutopilotMonitor.Functions.Services /// public async Task> GetAllReportsAsync() { - var tableClient = _storageService.GetTableServiceClient().GetTableClient(Constants.TableNames.SessionReports); - var results = new List(); - - try - { - await foreach (var entity in tableClient.QueryAsync(e => e.PartitionKey == "reports")) - { - results.Add(new SessionReportMetadata - { - ReportId = entity.GetString("ReportId") ?? string.Empty, - TenantId = entity.GetString("TenantId") ?? string.Empty, - SessionId = entity.GetString("SessionId") ?? string.Empty, - Comment = entity.GetString("Comment") ?? string.Empty, - Email = entity.GetString("Email") ?? string.Empty, - BlobName = entity.GetString("BlobName") ?? string.Empty, - SubmittedBy = entity.GetString("SubmittedBy") ?? string.Empty, - SubmittedAt = entity.GetDateTimeOffset("SubmittedAt")?.UtcDateTime ?? DateTime.MinValue, - AdminNote = entity.GetString("AdminNote") ?? string.Empty - }); - } - } - catch (RequestFailedException ex) when (ex.Status == 404) - { - // Table doesn't exist yet — no reports have been submitted - _logger.LogDebug("SessionReports table does not exist yet, returning empty list"); - } - - return results; + return await _notificationRepo.GetSessionReportsAsync(); } /// @@ -213,30 +172,7 @@ namespace AutopilotMonitor.Functions.Services /// public async Task UpdateAdminNoteAsync(string reportId, string adminNote) { - var tableClient = _storageService.GetTableServiceClient().GetTableClient(Constants.TableNames.SessionReports); - - // Find the entity by scanning for matching ReportId - TableEntity? found = null; - await foreach (var entity in tableClient.QueryAsync(e => e.PartitionKey == "reports")) - { - if (entity.GetString("ReportId") == reportId) - { - found = entity; - break; - } - } - - if (found == null) - { - _logger.LogWarning("UpdateAdminNote: report {ReportId} not found", reportId); - return false; - } - - found["AdminNote"] = adminNote ?? string.Empty; - await tableClient.UpsertEntityAsync(found, TableUpdateMode.Merge); - - _logger.LogInformation("Updated AdminNote for report {ReportId}", reportId); - return true; + return await _notificationRepo.UpdateSessionReportAdminNoteAsync(reportId, adminNote); } private static void AddJsonEntry(ZipArchive archive, string name, object data) diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs index 291e3019..80e1b572 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TelegramNotificationService.cs @@ -2,10 +2,7 @@ using System; using System.Net.Http; using System.Text; using System.Threading.Tasks; -using AutopilotMonitor.Shared; -using Azure; -using Azure.Data.Tables; -using Microsoft.Extensions.Configuration; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Extensions.Logging; using Newtonsoft.Json; @@ -13,27 +10,23 @@ namespace AutopilotMonitor.Functions.Services { /// /// Sends Telegram notifications for Private Preview signup events. - /// Webhook URL is read from the PreviewConfig table (PartitionKey="TelegramBot", RowKey="config"). + /// Webhook URL is read from the PreviewConfig table via IConfigRepository. /// Temporary — remove after GA. /// public class TelegramNotificationService { private readonly HttpClient _http; - private readonly TableClient _tableClient; + private readonly IConfigRepository _configRepo; private readonly ILogger _logger; public TelegramNotificationService( HttpClient http, - IConfiguration configuration, + IConfigRepository configRepo, ILogger logger) { _http = http; + _configRepo = configRepo; _logger = logger; - - var connectionString = configuration["AzureTableStorageConnectionString"]; - var serviceClient = new TableServiceClient(connectionString); - _tableClient = serviceClient.GetTableClient(Constants.TableNames.PreviewConfig); - // Table is initialized centrally by TableInitializerService at startup } /// @@ -197,13 +190,9 @@ namespace AutopilotMonitor.Functions.Services { try { - var entity = await _tableClient.GetEntityAsync("TelegramBot", "config"); - entity.Value.TryGetValue("WebhookUrl", out var value); - return value?.ToString(); - } - catch (RequestFailedException ex) when (ex.Status == 404) - { - return null; + var config = await _configRepo.GetPreviewConfigAsync(); + config.TryGetValue("WebhookUrl", out var value); + return value; } catch (Exception ex) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantAdminsService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantAdminsService.cs index 42263dc4..7943073f 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantAdminsService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantAdminsService.cs @@ -1,8 +1,8 @@ using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; using Azure.Data.Tables; using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Services; @@ -13,21 +13,19 @@ namespace AutopilotMonitor.Functions.Services; /// public class TenantAdminsService { - private readonly TableServiceClient _tableServiceClient; + private readonly IAdminRepository _adminRepo; private readonly IMemoryCache _cache; private readonly ILogger _logger; private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5); public TenantAdminsService( - IConfiguration configuration, + IAdminRepository adminRepo, IMemoryCache cache, ILogger logger) { + _adminRepo = adminRepo; _cache = cache; _logger = logger; - var connectionString = configuration["AzureTableStorageConnectionString"]; - _tableServiceClient = new TableServiceClient(connectionString); - // Table is initialized centrally by TableInitializerService at startup } /// @@ -58,55 +56,20 @@ public class TenantAdminsService return isAdmin; } - // Query Table Storage - _logger.LogInformation($"Querying Table Storage for Tenant Admin: {upn} @ {tenantId}"); - var admin = await GetTenantAdminAsync(tenantId, upn); + // Query via repository + _logger.LogInformation($"Querying repository for Tenant Admin: {upn} @ {tenantId}"); + var member = await _adminRepo.GetTenantMemberAsync(tenantId, upn); // Only true for Admin role (null Role = Admin for backward compat) - var result = admin != null && admin.IsEnabled - && (admin.Role == null || admin.Role == Constants.TenantRoles.Admin); + var result = member != null && member.IsEnabled + && (member.Role == null || member.Role == Constants.TenantRoles.Admin); - _logger.LogInformation($"Tenant Admin check result: {upn} @ {tenantId} -> {result} (Entity found: {admin != null}, IsEnabled: {admin?.IsEnabled})"); + _logger.LogInformation($"Tenant Admin check result: {upn} @ {tenantId} -> {result} (Member found: {member != null}, IsEnabled: {member?.IsEnabled})"); _cache.Set(cacheKey, result, _cacheDuration); return result; } - /// - /// Gets a Tenant Admin entity by tenant ID and UPN - /// - private async Task GetTenantAdminAsync(string tenantId, string upn) - { - try - { - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantAdmins); - var normalizedTenantId = tenantId.ToLowerInvariant(); - var normalizedUpn = upn.ToLowerInvariant(); - - _logger.LogDebug($"Querying Table Storage - PartitionKey: '{normalizedTenantId}', RowKey: '{normalizedUpn}'"); - - // PartitionKey = TenantId, RowKey = UPN - var entity = await tableClient.GetEntityAsync( - normalizedTenantId, - normalizedUpn - ); - - _logger.LogDebug($"Tenant Admin entity found for {normalizedUpn} @ {normalizedTenantId}"); - return entity?.Value; - } - catch (Azure.RequestFailedException ex) when (ex.Status == 404) - { - // Entity not found - _logger.LogDebug($"Tenant Admin entity NOT found for {upn} @ {tenantId} (404)"); - return null; - } - catch (Exception ex) - { - _logger.LogError(ex, $"Error querying Tenant Admin for {upn} @ {tenantId}"); - return null; - } - } - /// /// Adds a user as a Tenant Admin /// @@ -119,7 +82,13 @@ public class TenantAdminsService upn = upn.ToLowerInvariant(); addedBy = addedBy.ToLowerInvariant(); - var entity = new TenantAdminEntity + await _adminRepo.AddTenantMemberAsync(tenantId, upn, addedBy, Constants.TenantRoles.Admin); + + InvalidateMemberCache(tenantId, upn); + + _logger.LogInformation($"Added Tenant Admin: {upn} to tenant {tenantId} by {addedBy}"); + + return new TenantAdminEntity { PartitionKey = tenantId, RowKey = upn, @@ -129,15 +98,6 @@ public class TenantAdminsService AddedDate = DateTime.UtcNow, AddedBy = addedBy }; - - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantAdmins); - await tableClient.UpsertEntityAsync(entity); - - InvalidateMemberCache(tenantId, upn); - - _logger.LogInformation($"Added Tenant Admin: {upn} to tenant {tenantId} by {addedBy}"); - - return entity; } /// @@ -148,8 +108,7 @@ public class TenantAdminsService tenantId = tenantId.ToLowerInvariant(); upn = upn.ToLowerInvariant(); - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantAdmins); - await tableClient.DeleteEntityAsync(tenantId, upn); + await _adminRepo.RemoveTenantMemberAsync(tenantId, upn); InvalidateMemberCache(tenantId, upn); @@ -164,18 +123,11 @@ public class TenantAdminsService tenantId = tenantId.ToLowerInvariant(); upn = upn.ToLowerInvariant(); - var admin = await GetTenantAdminAsync(tenantId, upn); - if (admin != null) - { - admin.IsEnabled = false; + await _adminRepo.SetTenantMemberEnabledAsync(tenantId, upn, false); - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantAdmins); - await tableClient.UpdateEntityAsync(admin, Azure.ETag.All); + InvalidateMemberCache(tenantId, upn); - InvalidateMemberCache(tenantId, upn); - - _logger.LogInformation($"Disabled Tenant Admin: {upn} for tenant {tenantId}"); - } + _logger.LogInformation($"Disabled Tenant Admin: {upn} for tenant {tenantId}"); } /// @@ -186,18 +138,11 @@ public class TenantAdminsService tenantId = tenantId.ToLowerInvariant(); upn = upn.ToLowerInvariant(); - var admin = await GetTenantAdminAsync(tenantId, upn); - if (admin != null) - { - admin.IsEnabled = true; + await _adminRepo.SetTenantMemberEnabledAsync(tenantId, upn, true); - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantAdmins); - await tableClient.UpdateEntityAsync(admin, Azure.ETag.All); + InvalidateMemberCache(tenantId, upn); - InvalidateMemberCache(tenantId, upn); - - _logger.LogInformation($"Enabled Tenant Admin: {upn} for tenant {tenantId}"); - } + _logger.LogInformation($"Enabled Tenant Admin: {upn} for tenant {tenantId}"); } /// @@ -208,18 +153,23 @@ public class TenantAdminsService SecurityValidator.EnsureValidGuid(tenantId, nameof(tenantId)); tenantId = tenantId.ToLowerInvariant(); - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantAdmins); - var admins = new List(); - await foreach (var entity in tableClient.QueryAsync( - filter: $"PartitionKey eq '{tenantId}'")) + var members = await _adminRepo.GetTenantMembersAsync(tenantId); + + _logger.LogInformation($"Retrieved {members.Count} Tenant Admins for tenant {tenantId}"); + + return members.Select(m => new TenantAdminEntity { - admins.Add(entity); - } - - _logger.LogInformation($"Retrieved {admins.Count} Tenant Admins for tenant {tenantId}"); - - return admins; + PartitionKey = m.TenantId, + RowKey = m.Upn, + TenantId = m.TenantId, + Upn = m.Upn, + IsEnabled = m.IsEnabled, + AddedDate = m.AddedAt, + AddedBy = m.AddedBy, + Role = m.Role, + CanManageBootstrapTokens = m.CanManageBootstrapTokens + }).ToList(); } /// @@ -252,8 +202,8 @@ public class TenantAdminsService if (_cache.TryGetValue(cacheKey, out var cached)) return cached; - var entity = await GetTenantAdminAsync(tenantId, upn); - if (entity == null || !entity.IsEnabled) + var member = await _adminRepo.GetTenantMemberAsync(tenantId, upn); + if (member == null || !member.IsEnabled) { _cache.Set(cacheKey, (MemberRoleInfo?)null, _cacheDuration); return null; @@ -261,8 +211,8 @@ public class TenantAdminsService var role = new MemberRoleInfo { - Role = entity.Role ?? Constants.TenantRoles.Admin, - CanManageBootstrapTokens = entity.CanManageBootstrapTokens + Role = member.Role ?? Constants.TenantRoles.Admin, + CanManageBootstrapTokens = member.CanManageBootstrapTokens }; _cache.Set(cacheKey, (MemberRoleInfo?)role, _cacheDuration); @@ -298,7 +248,13 @@ public class TenantAdminsService upn = upn.ToLowerInvariant(); addedBy = addedBy.ToLowerInvariant(); - var entity = new TenantAdminEntity + await _adminRepo.AddTenantMemberAsync(tenantId, upn, addedBy, role, canManageBootstrapTokens); + + InvalidateMemberCache(tenantId, upn); + + _logger.LogInformation("Added tenant member: {Upn} with role {Role} to tenant {TenantId} by {AddedBy}", upn, role, tenantId, addedBy); + + return new TenantAdminEntity { PartitionKey = tenantId, RowKey = upn, @@ -310,15 +266,6 @@ public class TenantAdminsService Role = role, CanManageBootstrapTokens = canManageBootstrapTokens }; - - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantAdmins); - await tableClient.UpsertEntityAsync(entity); - - InvalidateMemberCache(tenantId, upn); - - _logger.LogInformation("Added tenant member: {Upn} with role {Role} to tenant {TenantId} by {AddedBy}", upn, role, tenantId, addedBy); - - return entity; } /// @@ -329,20 +276,15 @@ public class TenantAdminsService tenantId = tenantId.ToLowerInvariant(); upn = upn.ToLowerInvariant(); - var entity = await GetTenantAdminAsync(tenantId, upn); - if (entity == null) return false; + var result = await _adminRepo.UpdateMemberPermissionsAsync(tenantId, upn, role, canManageBootstrapTokens); - entity.Role = role; - entity.CanManageBootstrapTokens = canManageBootstrapTokens; + if (result) + { + InvalidateMemberCache(tenantId, upn); + _logger.LogInformation("Updated member permissions: {Upn} -> role={Role}, canManageBootstrap={CanManageBootstrap} in tenant {TenantId}", upn, role, canManageBootstrapTokens, tenantId); + } - var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.TenantAdmins); - await tableClient.UpdateEntityAsync(entity, Azure.ETag.All); - - InvalidateMemberCache(tenantId, upn); - - _logger.LogInformation("Updated member permissions: {Upn} -> role={Role}, canManageBootstrap={CanManageBootstrap} in tenant {TenantId}", upn, role, canManageBootstrapTokens, tenantId); - - return true; + return result; } /// diff --git a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs index 2657c850..52e933f7 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/TenantConfigurationService.cs @@ -1,38 +1,30 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; -using AutopilotMonitor.Shared; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; -using Azure; -using Azure.Data.Tables; using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; namespace AutopilotMonitor.Functions.Services { /// - /// Service for managing tenant-specific configuration in Azure Table Storage + /// Service for managing tenant-specific configuration. + /// Caching and business logic layer — delegates storage to IConfigRepository. /// public class TenantConfigurationService { - private readonly TableClient _tableClient; + private readonly IConfigRepository _configRepo; private readonly ILogger _logger; private readonly IMemoryCache _cache; private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); - public TenantConfigurationService(IConfiguration configuration, ILogger logger, IMemoryCache cache) + public TenantConfigurationService(IConfigRepository configRepo, ILogger logger, IMemoryCache cache) { + _configRepo = configRepo; _logger = logger; _cache = cache; - - var connectionString = configuration["AzureTableStorageConnectionString"]; - var serviceClient = new TableServiceClient(connectionString); - _tableClient = serviceClient.GetTableClient(Constants.TableNames.TenantConfiguration); - // Table is initialized centrally by TableInitializerService at startup } /// @@ -55,25 +47,23 @@ namespace AutopilotMonitor.Functions.Services try { - // Load from Table Storage - var entity = await _tableClient.GetEntityAsync(tenantId, "config"); - var config = ConvertFromTableEntity(entity.Value); + // Load from repository + var config = await _configRepo.GetTenantConfigurationAsync(tenantId); - _cache.Set(cacheKey, config, CacheDuration); + if (config != null) + { + _cache.Set(cacheKey, config, CacheDuration); + return config; + } - return config; - } - catch (RequestFailedException ex) when (ex.Status == 404) - { // Configuration not found - create and save default immediately _logger.LogInformation($"Configuration not found for tenant {tenantId}, creating and saving default configuration"); var defaultConfig = TenantConfiguration.CreateDefault(tenantId); try { - // Save the default configuration to Table Storage - var entity = ConvertToTableEntity(defaultConfig); - await _tableClient.UpsertEntityAsync(entity); + // Save the default configuration via repository + await _configRepo.SaveTenantConfigurationAsync(defaultConfig); _cache.Set(cacheKey, defaultConfig, CacheDuration); @@ -108,8 +98,7 @@ namespace AutopilotMonitor.Functions.Services { config.LastUpdated = DateTime.UtcNow; - var entity = ConvertToTableEntity(config); - await _tableClient.UpsertEntityAsync(entity); + await _configRepo.SaveTenantConfigurationAsync(config); // Invalidate cache _cache.Remove($"tenant-config:{config.TenantId}"); @@ -147,13 +136,13 @@ namespace AutopilotMonitor.Functions.Services try { - var entity = await _tableClient.GetEntityAsync(tenantId, "config"); - var config = ConvertFromTableEntity(entity.Value); - _cache.Set(cacheKey, config, CacheDuration); - return (config, true); - } - catch (RequestFailedException ex) when (ex.Status == 404) - { + var config = await _configRepo.GetTenantConfigurationAsync(tenantId); + if (config != null) + { + _cache.Set(cacheKey, config, CacheDuration); + return (config, true); + } + return (TenantConfiguration.CreateDefault(tenantId), false); } catch (Exception ex) @@ -171,15 +160,7 @@ namespace AutopilotMonitor.Functions.Services { try { - var configurations = new List(); - - await foreach (var entity in _tableClient.QueryAsync(filter: "RowKey eq 'config'")) - { - var config = ConvertFromTableEntity(entity); - configurations.Add(config); - } - - return configurations.OrderBy(c => c.TenantId).ToList(); + return await _configRepo.GetAllTenantConfigurationsAsync(); } catch (Exception ex) { @@ -187,128 +168,5 @@ namespace AutopilotMonitor.Functions.Services throw; } } - - private TableEntity ConvertToTableEntity(TenantConfiguration config) - { - var entity = new TableEntity(config.TenantId, "config") - { - { "DomainName", config.DomainName }, - { "LastUpdated", config.LastUpdated }, - { "UpdatedBy", config.UpdatedBy }, - { "Disabled", config.Disabled }, - { "DisabledReason", config.DisabledReason }, - { "DisabledUntil", config.DisabledUntil }, - { "RateLimitRequestsPerMinute", config.RateLimitRequestsPerMinute }, - { "CustomRateLimitRequestsPerMinute", config.CustomRateLimitRequestsPerMinute }, - { "ManufacturerWhitelist", config.ManufacturerWhitelist }, - { "ModelWhitelist", config.ModelWhitelist }, - { "ValidateAutopilotDevice", config.ValidateAutopilotDevice }, - { "ValidateCorporateIdentifier", config.ValidateCorporateIdentifier }, - { "AllowInsecureAgentRequests", config.AllowInsecureAgentRequests }, - { "DataRetentionDays", config.DataRetentionDays }, - { "SessionTimeoutHours", config.SessionTimeoutHours }, - { "MaxNdjsonPayloadSizeMB", config.MaxNdjsonPayloadSizeMB }, - { "EnablePerformanceCollector", config.EnablePerformanceCollector }, - { "PerformanceCollectorIntervalSeconds", config.PerformanceCollectorIntervalSeconds }, - { "MaxAuthFailures", config.MaxAuthFailures }, - { "AuthFailureTimeoutMinutes", config.AuthFailureTimeoutMinutes }, - { "SelfDestructOnComplete", config.SelfDestructOnComplete }, - { "KeepLogFile", config.KeepLogFile }, - { "RebootOnComplete", config.RebootOnComplete }, - { "RebootDelaySeconds", config.RebootDelaySeconds }, - { "EnableGeoLocation", config.EnableGeoLocation }, - { "EnableImeMatchLog", config.EnableImeMatchLog }, - { "LogLevel", config.LogLevel }, - { "MaxBatchSize", config.MaxBatchSize }, - { "DiagnosticsBlobSasUrl", config.DiagnosticsBlobSasUrl }, - { "DiagnosticsUploadMode", config.DiagnosticsUploadMode }, - { "DiagnosticsLogPathsJson", config.DiagnosticsLogPathsJson }, - { "TeamsWebhookUrl", config.TeamsWebhookUrl }, - { "TeamsNotifyOnSuccess", config.TeamsNotifyOnSuccess }, - { "TeamsNotifyOnFailure", config.TeamsNotifyOnFailure }, - { "WebhookProviderType", config.WebhookProviderType }, - { "WebhookUrl", config.WebhookUrl }, - { "WebhookNotifyOnSuccess", config.WebhookNotifyOnSuccess }, - { "WebhookNotifyOnFailure", config.WebhookNotifyOnFailure }, - { "ShowScriptOutput", config.ShowScriptOutput }, - { "ShowEnrollmentSummary", config.ShowEnrollmentSummary }, - { "EnrollmentSummaryTimeoutSeconds", config.EnrollmentSummaryTimeoutSeconds }, - { "EnrollmentSummaryBrandingImageUrl", config.EnrollmentSummaryBrandingImageUrl }, - { "EnrollmentSummaryLaunchRetrySeconds", config.EnrollmentSummaryLaunchRetrySeconds }, - { "HelloWaitTimeoutSeconds", config.HelloWaitTimeoutSeconds }, - { "AgentMaxLifetimeMinutes", config.AgentMaxLifetimeMinutes }, - { "SendTraceEvents", config.SendTraceEvents }, - { "EnableLocalAdminAnalyzer", config.EnableLocalAdminAnalyzer }, - { "EnableSoftwareInventoryAnalyzer", config.EnableSoftwareInventoryAnalyzer }, - { "LocalAdminAllowedAccountsJson", config.LocalAdminAllowedAccountsJson }, - { "BootstrapTokenEnabled", config.BootstrapTokenEnabled }, - { "UnrestrictedModeEnabled", config.UnrestrictedModeEnabled }, - { "UnrestrictedMode", config.UnrestrictedMode }, - { "OnboardedAt", config.OnboardedAt } - }; - - return entity; - } - - private TenantConfiguration ConvertFromTableEntity(TableEntity entity) - { - return new TenantConfiguration - { - TenantId = entity.PartitionKey, - DomainName = entity.GetString("DomainName") ?? "", - LastUpdated = entity.GetDateTime("LastUpdated") ?? DateTime.UtcNow, - UpdatedBy = entity.GetString("UpdatedBy") ?? "Unknown", - Disabled = entity.GetBoolean("Disabled") ?? false, - DisabledReason = entity.GetString("DisabledReason"), - DisabledUntil = entity.GetDateTime("DisabledUntil"), - RateLimitRequestsPerMinute = entity.GetInt32("RateLimitRequestsPerMinute") ?? 100, - CustomRateLimitRequestsPerMinute = entity.GetInt32("CustomRateLimitRequestsPerMinute"), - ManufacturerWhitelist = entity.GetString("ManufacturerWhitelist") ?? "Dell*,HP*,Lenovo*,Microsoft Corporation", - ModelWhitelist = entity.GetString("ModelWhitelist") ?? "*", - ValidateAutopilotDevice = entity.GetBoolean("ValidateAutopilotDevice") ?? entity.GetBoolean("ValidateSerialNumber") ?? false, - ValidateCorporateIdentifier = entity.GetBoolean("ValidateCorporateIdentifier") ?? false, - AllowInsecureAgentRequests = entity.GetBoolean("AllowInsecureAgentRequests") ?? false, - DataRetentionDays = entity.GetInt32("DataRetentionDays") ?? 90, - SessionTimeoutHours = entity.GetInt32("SessionTimeoutHours") ?? 5, - MaxNdjsonPayloadSizeMB = entity.GetInt32("MaxNdjsonPayloadSizeMB") ?? 5, - EnablePerformanceCollector = entity.GetBoolean("EnablePerformanceCollector") ?? false, - PerformanceCollectorIntervalSeconds = entity.GetInt32("PerformanceCollectorIntervalSeconds") ?? 30, - MaxAuthFailures = entity.GetInt32("MaxAuthFailures"), - AuthFailureTimeoutMinutes = entity.GetInt32("AuthFailureTimeoutMinutes"), - SelfDestructOnComplete = entity.GetBoolean("SelfDestructOnComplete"), - KeepLogFile = entity.GetBoolean("KeepLogFile"), - RebootOnComplete = entity.GetBoolean("RebootOnComplete"), - RebootDelaySeconds = entity.GetInt32("RebootDelaySeconds"), - EnableGeoLocation = entity.GetBoolean("EnableGeoLocation"), - EnableImeMatchLog = entity.GetBoolean("EnableImeMatchLog"), - LogLevel = entity.GetString("LogLevel"), - MaxBatchSize = entity.GetInt32("MaxBatchSize"), - DiagnosticsBlobSasUrl = entity.GetString("DiagnosticsBlobSasUrl"), - DiagnosticsUploadMode = entity.GetString("DiagnosticsUploadMode") ?? "Off", - DiagnosticsLogPathsJson = entity.GetString("DiagnosticsLogPathsJson"), - TeamsWebhookUrl = entity.GetString("TeamsWebhookUrl"), - TeamsNotifyOnSuccess = entity.GetBoolean("TeamsNotifyOnSuccess") ?? true, - TeamsNotifyOnFailure = entity.GetBoolean("TeamsNotifyOnFailure") ?? true, - WebhookProviderType = entity.GetInt32("WebhookProviderType") ?? 0, - WebhookUrl = entity.GetString("WebhookUrl"), - WebhookNotifyOnSuccess = entity.GetBoolean("WebhookNotifyOnSuccess") ?? true, - WebhookNotifyOnFailure = entity.GetBoolean("WebhookNotifyOnFailure") ?? true, - ShowScriptOutput = entity.GetBoolean("ShowScriptOutput") ?? true, - ShowEnrollmentSummary = entity.GetBoolean("ShowEnrollmentSummary"), - EnrollmentSummaryTimeoutSeconds = entity.GetInt32("EnrollmentSummaryTimeoutSeconds"), - EnrollmentSummaryBrandingImageUrl = entity.GetString("EnrollmentSummaryBrandingImageUrl"), - EnrollmentSummaryLaunchRetrySeconds = entity.GetInt32("EnrollmentSummaryLaunchRetrySeconds"), - HelloWaitTimeoutSeconds = entity.GetInt32("HelloWaitTimeoutSeconds") ?? 30, - AgentMaxLifetimeMinutes = entity.GetInt32("AgentMaxLifetimeMinutes"), - SendTraceEvents = entity.GetBoolean("SendTraceEvents") ?? true, - EnableLocalAdminAnalyzer = entity.GetBoolean("EnableLocalAdminAnalyzer"), - EnableSoftwareInventoryAnalyzer = entity.GetBoolean("EnableSoftwareInventoryAnalyzer"), - LocalAdminAllowedAccountsJson = entity.GetString("LocalAdminAllowedAccountsJson"), - BootstrapTokenEnabled = entity.GetBoolean("BootstrapTokenEnabled") ?? false, - UnrestrictedModeEnabled = entity.GetBoolean("UnrestrictedModeEnabled") ?? false, - UnrestrictedMode = entity.GetBoolean("UnrestrictedMode") ?? false, - OnboardedAt = entity.GetDateTime("OnboardedAt") - }; - } } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/UsageMetricsService.cs b/src/Backend/AutopilotMonitor.Functions/Services/UsageMetricsService.cs index fc23469b..f1e5aa3a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/UsageMetricsService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/UsageMetricsService.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; +using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using Microsoft.Extensions.Logging; @@ -13,7 +14,8 @@ namespace AutopilotMonitor.Functions.Services /// public class UsageMetricsService { - private readonly TableStorageService _storageService; + private readonly IMetricsRepository _metricsRepo; + private readonly IMaintenanceRepository _maintenanceRepo; private readonly ILogger _logger; // In-memory cache @@ -23,10 +25,12 @@ namespace AutopilotMonitor.Functions.Services private static readonly object _cacheLock = new object(); public UsageMetricsService( - TableStorageService storageService, + IMetricsRepository metricsRepo, + IMaintenanceRepository maintenanceRepo, ILogger logger) { - _storageService = storageService; + _metricsRepo = metricsRepo; + _maintenanceRepo = maintenanceRepo; _logger = logger; } @@ -95,7 +99,7 @@ namespace AutopilotMonitor.Functions.Services // Query sessions from the last 90 days only — covers all displayed metrics (today/7d/30d) // with a generous margin. Avoids loading the entire table into memory. // "Total" counters use PlatformStats (cumulative, already tracked separately). - var allSessions = await _storageService.GetSessionsByDateRangeAsync(DateTime.UtcNow.AddDays(-90), DateTime.UtcNow.AddDays(1)); + var allSessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(DateTime.UtcNow.AddDays(-90), DateTime.UtcNow.AddDays(1)); var now = DateTime.UtcNow; var today = now.Date; @@ -132,7 +136,7 @@ namespace AutopilotMonitor.Functions.Services }; // User Metrics (from UserActivity table) - var userActivity = await _storageService.GetAllUserActivityMetricsAsync(); + var userActivity = await _metricsRepo.GetAllUserActivityMetricsAsync(); var userMetrics = new UserMetrics { Total = userActivity.TotalUniqueUsers, @@ -196,7 +200,7 @@ namespace AutopilotMonitor.Functions.Services }; // Platform Stats (cumulative since release) - var platformStats = await _storageService.GetPlatformStatsAsync(); + var platformStats = await _metricsRepo.GetPlatformStatsAsync(); return new PlatformUsageMetrics { @@ -223,7 +227,7 @@ namespace AutopilotMonitor.Functions.Services private async Task ComputeTenantUsageMetricsInternalAsync(string tenantId) { // Query sessions for specific tenant only — last 90 days - var tenantSessions = await _storageService.GetSessionsByDateRangeAsync(DateTime.UtcNow.AddDays(-90), DateTime.UtcNow.AddDays(1), tenantId); + var tenantSessions = await _maintenanceRepo.GetSessionsByDateRangeAsync(DateTime.UtcNow.AddDays(-90), DateTime.UtcNow.AddDays(1), tenantId); var now = DateTime.UtcNow; var today = now.Date; @@ -252,7 +256,7 @@ namespace AutopilotMonitor.Functions.Services }; // User Metrics (from UserActivity table) - var userActivity = await _storageService.GetUserActivityMetricsAsync(tenantId); + var userActivity = await _metricsRepo.GetUserActivityMetricsAsync(tenantId); var userMetrics = new UserMetrics { Total = userActivity.TotalUniqueUsers, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs index 51e6580b..b29ed1e3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/Vulnerability/VulnerabilityCorrelationService.cs @@ -5,14 +5,15 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Threading; using System.Threading.Tasks; -using Azure.Data.Tables; +using AutopilotMonitor.Shared.DataAccess; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Services.Vulnerability { public class VulnerabilityCorrelationService { - private readonly TableStorageService _storage; + private readonly IVulnerabilityRepository _vulnRepo; + private readonly ISessionRepository _sessionRepo; private readonly NvdApiClient _nvdApi; private readonly KevDataService _kevService; private readonly MsrcApiClient _msrcApi; @@ -30,14 +31,16 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability }; public VulnerabilityCorrelationService( - TableStorageService storage, + IVulnerabilityRepository vulnRepo, + ISessionRepository sessionRepo, NvdApiClient nvdApi, KevDataService kevService, MsrcApiClient msrcApi, AdminConfigurationService adminConfig, ILogger logger) { - _storage = storage; + _vulnRepo = vulnRepo; + _sessionRepo = sessionRepo; _nvdApi = nvdApi; _kevService = kevService; _msrcApi = msrcApi; @@ -199,7 +202,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability { try { - await _storage.UpsertSoftwareInventoryAsync(tenantId, inventoryItems, sessionId, cpeMappingsDict); + await _vulnRepo.UpsertSoftwareInventoryAsync(tenantId, inventoryItems, sessionId, cpeMappingsDict); } catch (Exception ex) { @@ -303,7 +306,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability } // ----------------------------------------------------------------------- - // CVE Lookup (with Table Storage caching) + // CVE Lookup (with Table Storage caching via IVulnerabilityRepository) // ----------------------------------------------------------------------- private const int CacheTtlDays = 7; @@ -349,22 +352,20 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability } /// - /// Read cached CVE data from the VulnerabilityCache table. + /// Read cached CVE data from the repository. /// Returns null on cache miss or read errors. /// private async Task GetCachedCvesAsync(string cacheKey) { try { - var tableClient = _storage.GetTableClient(AutopilotMonitor.Shared.Constants.TableNames.VulnerabilityCache); - var response = await tableClient.GetEntityAsync(cacheKey, "cache_result"); - var entity = response?.Value; + var entity = await _vulnRepo.GetCveCacheEntryAsync(cacheKey, "cache_result"); if (entity == null) return null; - var cachedAtStr = entity.GetString("CachedAt"); + var cachedAtStr = entity.ContainsKey("CachedAt") ? entity["CachedAt"]?.ToString() : null; if (string.IsNullOrWhiteSpace(cachedAtStr)) return null; - var cveDataJson = entity.GetString("CveDataJson") ?? "[]"; + var cveDataJson = entity.ContainsKey("CveDataJson") ? entity["CveDataJson"]?.ToString() ?? "[]" : "[]"; var cves = JsonSerializer.Deserialize>(cveDataJson, JsonOptions) ?? new List(); return new CachedCveResult @@ -373,10 +374,6 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability CachedAt = DateTime.Parse(cachedAtStr, null, System.Globalization.DateTimeStyles.RoundtripKind) }; } - catch (Azure.RequestFailedException ex) when (ex.Status == 404) - { - return null; // Cache miss - } catch (Exception ex) { _logger.LogDebug(ex, "Failed to read CVE cache for key {CacheKey}", cacheKey); @@ -385,7 +382,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability } /// - /// Query NVD API for CVEs matching a CPE, then cache the results in table storage. + /// Query NVD API for CVEs matching a CPE, then cache the results via the repository. /// Returns the parsed CVE data regardless of whether caching succeeds. /// private async Task FetchAndCacheCvesFromNvdAsync( @@ -474,7 +471,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability CachedAt = DateTime.UtcNow }; - // Persist to table storage (best-effort, don't fail the request) + // Persist to repository (best-effort, don't fail the request) try { var cveDataJson = JsonSerializer.Serialize(cves, JsonOptions); @@ -493,7 +490,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability cpeUri, reduced.Count); } - var entity = new TableEntity(cacheKey, "cache_result") + var data = new Dictionary { { "CveDataJson", cveDataJson }, { "CachedAt", DateTime.UtcNow.ToString("o") }, @@ -501,8 +498,7 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability { "TotalCves", cves.Count } }; - var tableClient = _storage.GetTableClient(AutopilotMonitor.Shared.Constants.TableNames.VulnerabilityCache); - await tableClient.UpsertEntityAsync(entity); + await _vulnRepo.UpsertCveCacheEntryAsync(cacheKey, "cache_result", data); _logger.LogDebug("Cached {Count} CVEs for {CpeUri}", cves.Count, cpeUri); } catch (Exception ex) @@ -630,38 +626,34 @@ namespace AutopilotMonitor.Functions.Services.Vulnerability _cpeMappings = new List(); - // Load ALL CPE mappings from VulnerabilityCache table + // Load ALL CPE mappings from repository // (seed + custom + community — all in one table, distinguished by PK prefix) try { - var tableClient = _storage.GetTableClient(AutopilotMonitor.Shared.Constants.TableNames.VulnerabilityCache); + var allMappings = await _vulnRepo.GetAllCpeMappingsAsync(); - // Query all cpe_map_* entries (seed, custom, community) - var allMappings = tableClient.QueryAsync( - filter: "PartitionKey ge 'cpe_map' and PartitionKey lt 'cpe_mar'"); - - await foreach (var entity in allMappings) + foreach (var entity in allMappings) { _cpeMappings.Add(new CpeMappingEntry { - NormalizedVendor = entity.GetString("NormalizedVendor") ?? "", - NormalizedProduct = entity.GetString("NormalizedProduct") ?? "", - CpeVendor = entity.GetString("CpeVendor") ?? "", - CpeProduct = entity.GetString("CpeProduct") ?? "", - CpeUri = entity.GetString("CpeUri") ?? "", - Category = entity.GetString("Category") ?? "", - DisplayNamePatterns = DeserializeStringList(entity.GetString("DisplayNamePatternsJson")), - PublisherPatterns = DeserializeStringList(entity.GetString("PublisherPatternsJson")) + NormalizedVendor = entity.ContainsKey("NormalizedVendor") ? entity["NormalizedVendor"]?.ToString() ?? "" : "", + NormalizedProduct = entity.ContainsKey("NormalizedProduct") ? entity["NormalizedProduct"]?.ToString() ?? "" : "", + CpeVendor = entity.ContainsKey("CpeVendor") ? entity["CpeVendor"]?.ToString() ?? "" : "", + CpeProduct = entity.ContainsKey("CpeProduct") ? entity["CpeProduct"]?.ToString() ?? "" : "", + CpeUri = entity.ContainsKey("CpeUri") ? entity["CpeUri"]?.ToString() ?? "" : "", + Category = entity.ContainsKey("Category") ? entity["Category"]?.ToString() ?? "" : "", + DisplayNamePatterns = DeserializeStringList(entity.ContainsKey("DisplayNamePatternsJson") ? entity["DisplayNamePatternsJson"]?.ToString() : null), + PublisherPatterns = DeserializeStringList(entity.ContainsKey("PublisherPatternsJson") ? entity["PublisherPatternsJson"]?.ToString() : null) }); } } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to load CPE mappings from table"); + _logger.LogWarning(ex, "Failed to load CPE mappings from repository"); } _seedLoaded = true; - _logger.LogInformation("CPE mappings ready: {Count} total entries (from table)", _cpeMappings.Count); + _logger.LogInformation("CPE mappings ready: {Count} total entries (from repository)", _cpeMappings.Count); } finally { diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs new file mode 100644 index 00000000..c14ba8c2 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs @@ -0,0 +1,69 @@ +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Repository for admin and tenant member management. + /// Covers: GlobalAdmins, TenantAdmins, ApiKeys tables. + /// + public interface IAdminRepository + { + // --- Global Admins --- + Task IsGlobalAdminAsync(string upn); + Task> GetAllGlobalAdminsAsync(); + Task AddGlobalAdminAsync(string upn, string addedBy); + Task RemoveGlobalAdminAsync(string upn); + Task DisableGlobalAdminAsync(string upn); + + // --- Tenant Members --- + Task> GetTenantMembersAsync(string tenantId); + Task AddTenantMemberAsync(string tenantId, string upn, string addedBy, string role, bool canManageBootstrapTokens = false); + Task RemoveTenantMemberAsync(string tenantId, string upn); + Task UpdateMemberPermissionsAsync(string tenantId, string upn, string role, bool canManageBootstrapTokens); + Task SetTenantMemberEnabledAsync(string tenantId, string upn, bool isEnabled); + Task GetTenantMemberAsync(string tenantId, string upn); + Task IsTenantAdminAsync(string tenantId, string upn); + Task IsTenantMemberAsync(string tenantId, string upn); + + // --- API Keys --- + Task ValidateApiKeyAsync(string apiKeyHash); + Task StoreApiKeyAsync(string tenantId, ApiKeyEntry entry); + Task> GetApiKeysAsync(string tenantId); + Task> GetAllApiKeysAsync(); + Task GetApiKeyAsync(string partitionKey, string keyId); + Task RevokeApiKeyAsync(string tenantId, string keyId); + Task IncrementApiKeyRequestCountAsync(string partitionKey, string keyId); + } + + public class GlobalAdminEntry + { + public string Upn { get; set; } = string.Empty; + public bool IsEnabled { get; set; } = true; + public DateTime AddedAt { get; set; } + public string AddedBy { get; set; } = string.Empty; + } + + public class TenantMember + { + public string Upn { get; set; } = string.Empty; + public string TenantId { get; set; } = string.Empty; + public string Role { get; set; } = "Admin"; + public bool IsEnabled { get; set; } = true; + public bool CanManageBootstrapTokens { get; set; } + public DateTime AddedAt { get; set; } + public string AddedBy { get; set; } = string.Empty; + } + + public class ApiKeyEntry + { + public string KeyId { get; set; } = string.Empty; + public string TenantId { get; set; } = string.Empty; + public string KeyHash { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Scope { get; set; } = "tenant"; + public string Upn { get; set; } = string.Empty; + public string CreatedBy { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public DateTime? ExpiresAt { get; set; } + public bool IsActive { get; set; } = true; + public long RequestCount { get; set; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IBootstrapRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IBootstrapRepository.cs new file mode 100644 index 00000000..fc400200 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IBootstrapRepository.cs @@ -0,0 +1,19 @@ +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Repository for bootstrap session management. + /// Covers: BootstrapSessions table. + /// + public interface IBootstrapRepository + { + Task CreateBootstrapSessionAsync(BootstrapSession session); + Task GetBootstrapSessionByCodeAsync(string shortCode); + Task ValidateBootstrapTokenAsync(string token); + Task> GetBootstrapSessionsAsync(string tenantId); + Task RevokeBootstrapSessionAsync(string shortCode); + Task IncrementBootstrapUsageAsync(string shortCode); + Task CleanupExpiredAsync(); + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IConfigRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IConfigRepository.cs new file mode 100644 index 00000000..7f7d4eea --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IConfigRepository.cs @@ -0,0 +1,51 @@ +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Repository for tenant and admin configuration. + /// Covers: TenantConfiguration, AdminConfiguration, PreviewWhitelist, PreviewConfig tables. + /// + public interface IConfigRepository + { + // --- Tenant Configuration --- + Task GetTenantConfigurationAsync(string tenantId); + Task SaveTenantConfigurationAsync(TenantConfiguration config); + Task> GetAllTenantConfigurationsAsync(); + + // --- Admin Configuration --- + Task GetAdminConfigurationAsync(); + Task SaveAdminConfigurationAsync(AdminConfiguration config); + + // --- Preview Whitelist --- + Task IsInPreviewWhitelistAsync(string tenantId); + Task AddToPreviewWhitelistAsync(string tenantId, string addedBy); + Task RemoveFromPreviewWhitelistAsync(string tenantId); + Task> GetPreviewWhitelistAsync(); + + // --- Preview Config --- + Task> GetPreviewConfigAsync(); + Task SavePreviewConfigAsync(string key, string value); + + // --- Preview Notification Email --- + Task GetNotificationEmailAsync(string tenantId); + Task SaveNotificationEmailAsync(string tenantId, string? email); + + // --- Feedback (stored in PreviewConfig table, PK="Feedback") --- + Task GetFeedbackEntryAsync(string upn); + Task SaveFeedbackEntryAsync(FeedbackEntry entry); + Task> GetAllFeedbackEntriesAsync(); + } + + public class FeedbackEntry + { + public string Upn { get; set; } = string.Empty; + public string TenantId { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public int? Rating { get; set; } + public string? Comment { get; set; } + public bool Dismissed { get; set; } + public bool Submitted { get; set; } + public DateTime? InteractedAt { get; set; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IDataEventPublisher.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IDataEventPublisher.cs new file mode 100644 index 00000000..a7e71fb1 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IDataEventPublisher.cs @@ -0,0 +1,27 @@ +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Publishes data change events for downstream consumers (Event Hub, Service Bus, etc.). + /// Default implementation is NullDataEventPublisher (no-op). + /// + public interface IDataEventPublisher + { + /// + /// Publishes a data change event. + /// + /// Domain event type (e.g. "session.created", "event.ingested", "rule.evaluated") + /// The event payload (typically the domain object) + /// Optional tenant context for routing + Task PublishAsync(string eventType, object payload, string? tenantId = null); + } + + /// + /// No-op implementation of IDataEventPublisher. + /// Used when event streaming is not configured. + /// + public class NullDataEventPublisher : IDataEventPublisher + { + public Task PublishAsync(string eventType, object payload, string? tenantId = null) + => Task.CompletedTask; + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IDeviceSecurityRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IDeviceSecurityRepository.cs new file mode 100644 index 00000000..bececb33 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IDeviceSecurityRepository.cs @@ -0,0 +1,44 @@ +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Repository for device and version blocking. + /// Covers: BlockedDevices, BlockedVersions tables. + /// + public interface IDeviceSecurityRepository + { + // --- Blocked Devices --- + Task<(bool isBlocked, DateTime? unblockAt, string action)> IsDeviceBlockedAsync(string tenantId, string serialNumber); + Task> GetBlockedDevicesAsync(string tenantId); + Task> GetAllBlockedDevicesAsync(); + Task BlockDeviceAsync(string tenantId, string serialNumber, int durationHours, + string blockedByEmail, string? reason = null, string action = "Block"); + Task UnblockDeviceAsync(string tenantId, string serialNumber); + + // --- Blocked Versions --- + Task<(bool isBlocked, string action, string? matchedPattern)> IsVersionBlockedAsync(string agentVersion); + Task> GetBlockedVersionsAsync(); + Task BlockVersionAsync(string versionPattern, string action, string createdByEmail, string? reason = null); + Task UnblockVersionAsync(string versionPattern); + } + + public class BlockedDeviceEntry + { + public string TenantId { get; set; } = string.Empty; + public string SerialNumber { get; set; } = string.Empty; + public DateTime BlockedAt { get; set; } + public DateTime? UnblockAt { get; set; } + public string? BlockedByEmail { get; set; } + public int DurationHours { get; set; } + public string? Reason { get; set; } + public string Action { get; set; } = "Block"; + } + + public class BlockedVersionEntry + { + public string VersionPattern { get; set; } = string.Empty; + public string Action { get; set; } = "Block"; + public string CreatedByEmail { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + public string? Reason { get; set; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs new file mode 100644 index 00000000..d99ac548 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMaintenanceRepository.cs @@ -0,0 +1,39 @@ +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Repository for audit logs and data retention/maintenance operations. + /// Covers: AuditLogs table + maintenance queries across Sessions/Events/RuleResults. + /// + public interface IMaintenanceRepository + { + // --- Audit Logs --- + Task LogAuditEntryAsync(string tenantId, string action, string entityType, + string entityId, string performedBy, Dictionary? details = null); + Task> GetAuditLogsAsync(string tenantId, int maxResults = 100); + Task> GetAllAuditLogsAsync(int maxResults = 100); + + // --- Data Retention Queries --- + Task> GetSessionsOlderThanAsync(string tenantId, DateTime cutoffDate); + Task> GetSessionsByDateRangeAsync(DateTime startDate, DateTime endDate, string? tenantId = null); + Task> GetStalledSessionsAsync(string tenantId, DateTime cutoffTime); + Task> GetExcessiveDataSendersAsync(string tenantId, DateTime windowCutoff, int maxSessionWindowHours); + + // --- Tenant Discovery --- + Task> GetAllTenantIdsAsync(); + + // --- Cleanup --- + Task DeleteSessionEventsAsync(string tenantId, string sessionId); + Task DeleteSessionRuleResultsAsync(string tenantId, string sessionId); + Task DeleteSessionAppInstallSummariesAsync(string tenantId, string sessionId); + + // --- Index Maintenance --- + Task BackfillSessionIndexAsync(); + Task CleanupGhostSessionIndexEntriesAsync(); + Task IsSessionIndexEmptyAsync(); + + // --- Tenant Offboarding --- + Task> DeleteAllTenantDataAsync(string tenantId); + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs new file mode 100644 index 00000000..c0cad9fd --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IMetricsRepository.cs @@ -0,0 +1,36 @@ +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Repository for usage metrics, platform stats, and user activity tracking. + /// Covers: UsageMetrics, PlatformStats, UserActivity, AppInstallSummaries tables. + /// + public interface IMetricsRepository + { + // --- Usage Metrics Snapshots --- + Task SaveUsageMetricsSnapshotAsync(UsageMetricsSnapshot metrics); + Task> GetUsageMetricsSnapshotAsync( + string? tenantId = null, string? startDate = null, string? endDate = null, int maxResults = 100); + Task HasUsageMetricsSnapshotAsync(string date); + + // --- App Install Summaries --- + Task StoreAppInstallSummaryAsync(AppInstallSummary summary); + Task> GetAppInstallSummariesByTenantAsync(string tenantId); + Task> GetAllAppInstallSummariesAsync(); + + // --- Platform Stats --- + Task GetPlatformStatsAsync(); + Task SavePlatformStatsAsync(PlatformStats stats); + Task IncrementPlatformStatAsync(string field, long amount = 1); + + // --- User Activity --- + Task RecordUserLoginAsync(string tenantId, string upn, string? displayName, string? objectId); + Task GetUserActivityMetricsAsync(string tenantId); + Task GetAllUserActivityMetricsAsync(); + Task<(int uniqueUsers, int loginCount)> GetUserActivityForDateAsync(string? tenantId, DateTime date); + + // --- Metrics Summary (Agent API) --- + Task> GetMetricsSummaryAsync(string? tenantId); + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/INotificationRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/INotificationRepository.cs new file mode 100644 index 00000000..df84d5b3 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/INotificationRepository.cs @@ -0,0 +1,38 @@ +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Repository for global notifications and session reports. + /// Covers: GlobalNotifications, SessionReports tables. + /// + public interface INotificationRepository + { + // --- Global Notifications --- + Task AddNotificationAsync(GlobalNotification notification); + Task> GetNotificationsAsync(int maxResults = 50); + Task DismissNotificationAsync(string notificationId, string dismissedBy); + Task DismissAllNotificationsAsync(); + + // --- Session Reports --- + Task StoreSessionReportMetadataAsync(SessionReportMetadata metadata); + Task> GetSessionReportsAsync(string? tenantId = null, int maxResults = 50); + Task GetSessionReportAsync(string reportId); + Task UpdateSessionReportAdminNoteAsync(string reportId, string adminNote); + } + + public class GlobalNotification + { + public string NotificationId { get; set; } = string.Empty; + public string Title { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + public string Type { get; set; } = "info"; + public string Severity { get; set; } = "info"; + public string? Href { get; set; } + public DateTime CreatedAt { get; set; } + public string CreatedBy { get; set; } = string.Empty; + public bool IsDismissed { get; set; } + public string? DismissedBy { get; set; } + public DateTime? DismissedAt { get; set; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IRuleRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IRuleRepository.cs new file mode 100644 index 00000000..a490782d --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IRuleRepository.cs @@ -0,0 +1,35 @@ +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Repository for rule definitions, results, and patterns. + /// Covers: RuleResults, GatherRules, AnalyzeRules, ImeLogPatterns, RuleStates tables. + /// + public interface IRuleRepository + { + // --- Rule Results --- + Task StoreRuleResultAsync(RuleResult result); + Task> GetRuleResultsAsync(string tenantId, string sessionId); + + // --- Gather Rules --- + Task StoreGatherRuleAsync(GatherRule rule, string tenantId = "global"); + Task> GetGatherRulesAsync(string partitionKey); + Task DeleteGatherRuleAsync(string tenantId, string ruleId); + + // --- Rule States --- + Task StoreRuleStateAsync(string tenantId, string ruleId, bool enabled); + Task> GetRuleStatesAsync(string tenantId); + Task DeleteRuleStateAsync(string tenantId, string ruleId); + + // --- Analyze Rules --- + Task StoreAnalyzeRuleAsync(AnalyzeRule rule, string tenantId = "global"); + Task> GetAnalyzeRulesAsync(string partitionKey); + Task DeleteAnalyzeRuleAsync(string tenantId, string ruleId); + + // --- IME Log Patterns --- + Task StoreImeLogPatternAsync(ImeLogPattern pattern, string tenantId = "global"); + Task> GetImeLogPatternsAsync(string partitionKey); + Task DeleteImeLogPatternAsync(string tenantId, string patternId); + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs new file mode 100644 index 00000000..3227cffe --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/ISessionRepository.cs @@ -0,0 +1,54 @@ +using AutopilotMonitor.Shared.Models; + +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Repository for session and event data. + /// Covers: Sessions, SessionsIndex, Events tables. + /// + public interface ISessionRepository + { + // --- Session CRUD --- + Task StoreSessionAsync(SessionRegistration registration); + Task GetSessionAsync(string tenantId, string sessionId); + Task GetSessionsAsync(string tenantId, int maxResults = 100, string? cursor = null); + Task GetAllSessionsAsync(int maxResults = 100, string? cursor = null); + Task DeleteSessionAsync(string tenantId, string sessionId); + + // --- Session Updates --- + Task 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); + Task IncrementSessionEventCountAsync( + string tenantId, string sessionId, int increment, + DateTime? earliestEventTimestamp = null, DateTime? latestEventTimestamp = null, + EnrollmentPhase? currentPhase = null); + Task UpdateSessionDiagnosticsBlobAsync(string tenantId, string sessionId, string blobName); + 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); + + // --- Events --- + Task StoreEventAsync(EnrollmentEvent evt); + Task> StoreEventsBatchAsync(List events); + Task> GetSessionEventsAsync(string tenantId, string sessionId, int maxResults = 1000); + + // --- Search --- + Task> SearchSessionsAsync(string? tenantId, SessionSearchFilter filter); + Task> SearchSessionsByEventAsync( + string? tenantId, string eventType, string? source, string? severity, + string? phase, int limit = 50); + Task> SearchSessionsByCveAsync( + string? tenantId, string cveId, double? minCvssScore, string? overallRisk, int limit = 50); + + // --- Agent Indexes --- + Task UpsertEventTypeIndexBatchAsync(string tenantId, string sessionId, IEnumerable eventTypes); + Task UpsertDeviceSnapshotAsync(string tenantId, string sessionId, IEnumerable events); + Task UpsertCveIndexEntriesAsync(string tenantId, string sessionId, List> findings); + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IStorageInitializer.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IStorageInitializer.cs new file mode 100644 index 00000000..34b7f646 --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IStorageInitializer.cs @@ -0,0 +1,11 @@ +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Initializes the storage backend (creates tables, containers, collections, etc.). + /// Each storage provider implements this for its specific setup needs. + /// + public interface IStorageInitializer + { + Task InitializeAsync(); + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IVulnerabilityRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IVulnerabilityRepository.cs new file mode 100644 index 00000000..c9ca865b --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IVulnerabilityRepository.cs @@ -0,0 +1,34 @@ +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Repository for vulnerability data (CPE mappings, CVE reports, software inventory). + /// Covers: VulnerabilityCache, VulnerabilityReports, SoftwareInventory, CveIndex tables. + /// + public interface IVulnerabilityRepository + { + // --- Vulnerability Reports --- + Task StoreVulnerabilityReportAsync(string tenantId, string sessionId, Dictionary reportData); + Task?> GetVulnerabilityReportAsync(string tenantId, string sessionId); + + // --- Software Inventory --- + Task UpsertSoftwareInventoryAsync(string tenantId, List> inventoryItems, + string sessionId, Dictionary? cpeMappings = null); + Task>> GetSoftwareInventoryAsync(string tenantId); + Task>> GetAllSoftwareInventoryAsync(); + Task UpdateSoftwareInventoryCpeAsync(string normalizedName, string cpeUri); + + // --- CPE Mappings --- + Task ImportCpeMappingSeedFromJsonAsync(string json); + Task ImportCpeCommunityMappingsFromJsonAsync(string json); + Task>> GetAllCpeMappingsAsync(); + Task>> GetCpeMappingsByPartitionAsync(string partitionKey); + Task UpsertCpeMappingAsync(string partitionKey, string rowKey, Dictionary properties); + Task DeleteCpeMappingAsync(string partitionKey, string rowKey); + Task DeleteCpeMappingsByPartitionAsync(string partitionKey); + + // --- CVE Cache --- + Task?> GetCveCacheEntryAsync(string partitionKey, string rowKey); + Task>> GetStaleCveCacheEntriesAsync(); + Task UpsertCveCacheEntryAsync(string partitionKey, string rowKey, Dictionary data); + } +} diff --git a/tasks/todo.md b/tasks/todo.md index 7958cf89..7b9e703e 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,16 +1,63 @@ -# Tenant Config Report (Global Admin) +# Data Access Layer — Migration Plan -## Goal -New global-admin-only page that shows a read-only report of a tenant's full configuration, -including admin-set parameters and computed runtime (agent) parameters. +## Phase 1: Foundation ✅ +- [x] Define 10 Repository Interfaces in `Shared/DataAccess/` +- [x] Define `IDataEventPublisher` + `NullDataEventPublisher` +- [x] Define `IStorageInitializer` +- [x] Create Table Storage implementations +- [x] Create `DataAccessServiceExtensions` with `AddTableStorageDataAccess()` +- [x] Register DAL in `Program.cs` -## Plan -- [x] Write plan -- [ ] Create `/app/tenant-config-report/page.tsx` — global admin only, read-only report - - Tenant selector (reuse pattern from gather-rules page) - - Fetch config via `GET /api/config/{tenantId}` (existing endpoint, GA cross-tenant) - - Display all TenantConfiguration fields grouped by section - - Show computed runtime parameters (AgentConfigResponse equivalents with defaults) - - Highlight non-default values for quick scanning -- [ ] Add nav item to global admin section in `globalNavConfig.tsx` -- [ ] Commit and push to feature branch +## Phase 2: Migrate Core Functions ✅ +- [x] All 11 Session Functions → `ISessionRepository` +- [x] All Metrics Functions → `IMetricsRepository` / `IMaintenanceRepository` +- [x] All Admin/Audit Functions → `IMaintenanceRepository` +- [x] All Rules Functions → `IRuleRepository` +- [x] IngestEventsFunction → 5 repositories +- [x] Auth, Progress, Feedback Functions → appropriate repos + +## Phase 2b: Migrate Domain Services ✅ +- [x] `RuleEngine`, `AnalyzeRuleService`, `GatherRuleService`, `ImeLogPatternService` → `IRuleRepository` +- [x] `UsageMetricsService` → `IMetricsRepository` + `IMaintenanceRepository` +- [x] `PlatformMetricsService` → `ISessionRepository` +- [x] `MaintenanceService` → `IMaintenanceRepository` + `ISessionRepository` + `IMetricsRepository` + +## Phase 3: Migrate GetTableClient Users ✅ +- [x] All 8 Vulnerability Functions → `IVulnerabilityRepository` +- [x] `ReseedFromGitHubFunction` → `IVulnerabilityRepository` + `IRuleRepository` +- [x] `ApiKeyManagementFunction` + `ApiKeyMiddleware` → `IAdminRepository` + +## Phase 4: Migrate Bypass Services ✅ +All services that created their own `TableServiceClient` now use repository interfaces: +- [x] `TenantConfigurationService` → `IConfigRepository` +- [x] `AdminConfigurationService` → `IConfigRepository` +- [x] `PreviewWhitelistService` → `IConfigRepository` +- [x] `TelegramNotificationService` → `IConfigRepository` +- [x] `GlobalAdminService` → `IAdminRepository` +- [x] `TenantAdminsService` → `IAdminRepository` +- [x] `BootstrapSessionService` → `IBootstrapRepository` +- [x] `GlobalNotificationService` → `INotificationRepository` +- [x] `SessionReportService` → `INotificationRepository` +- [x] `BlockedDeviceService` → `IDeviceSecurityRepository` +- [x] `BlockedVersionService` → `IDeviceSecurityRepository` +- [x] `VulnerabilityCorrelationService` → `IVulnerabilityRepository` + +## Current State ✅ +`new TableServiceClient()` only exists in: +- 5 Repository implementations (DataAccess/TableStorage/) — correct, they ARE the storage layer +- `TableStorageService.cs` — the core implementation used by repos +- `FeedbackFunction.cs` — domain-specific feedback CRUD (PreviewConfig table) +- `TenantOffboardFunction.cs` — bulk tenant data deletion across all tables + +All 10 repository interfaces have Table Storage implementations registered in DI. + +## Phase 5: Event Streaming (When Ready) +- [ ] Implement `EventHubPublisher` (or `ServiceBusPublisher`) +- [ ] Register via `services.AddEventStreaming()` +- [ ] Define event schemas for key domain events + +## Phase 6: Cosmos DB Migration (When Ready) +- [ ] Create `CosmosSessionRepository`, `CosmosRuleRepository`, etc. +- [ ] Create `AddCosmosDataAccess()` extension method +- [ ] Replace inverted-tick indexing with native ORDER BY DESC +- [ ] Swap `AddTableStorageDataAccess()` → `AddCosmosDataAccess()` in Program.cs