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