mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
Merge pull request #31 from okieselbach/claude/data-access-layer-pyC29
Implement repository pattern for data access abstraction
This commit is contained in:
@@ -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<EventHubPublisher>();
|
||||
/// </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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+378
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+275
@@ -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") ?? ""
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+582
@@ -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;
|
||||
}
|
||||
}
|
||||
+356
@@ -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 <= 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 <= 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);
|
||||
}
|
||||
}
|
||||
+245
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -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);
|
||||
}
|
||||
}
|
||||
+292
@@ -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);
|
||||
}
|
||||
}
|
||||
+113
@@ -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);
|
||||
}
|
||||
}
|
||||
+22
@@ -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();
|
||||
}
|
||||
}
|
||||
+211
@@ -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
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+22
-59
@@ -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();
|
||||
|
||||
+9
-8
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -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",
|
||||
|
||||
+5
-4
@@ -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
-4
@@ -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
-4
@@ -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
|
||||
|
||||
+25
-24
@@ -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
|
||||
|
||||
+5
-5
@@ -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);
|
||||
|
||||
|
||||
+8
-5
@@ -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);
|
||||
|
||||
+5
-5
@@ -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 =>
|
||||
|
||||
+5
-5
@@ -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);
|
||||
|
||||
|
||||
+9
-6
@@ -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);
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+5
-4
@@ -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);
|
||||
|
||||
+5
-4
@@ -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
|
||||
|
||||
+10
-7
@@ -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);
|
||||
|
||||
+10
-7
@@ -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);
|
||||
|
||||
+10
-6
@@ -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;
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+11
-12
@@ -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);
|
||||
|
||||
+19
-24
@@ -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
-4
@@ -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
|
||||
|
||||
+15
-17
@@ -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
|
||||
|
||||
+18
-18
@@ -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)
|
||||
{
|
||||
|
||||
+9
-50
@@ -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; } = "";
|
||||
|
||||
+6
-5
@@ -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)
|
||||
|
||||
+16
-20
@@ -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 <= 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,
|
||||
|
||||
+30
-38
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user