Merge pull request #31 from okieselbach/claude/data-access-layer-pyC29

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