mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
added MCP capabilities
This commit is contained in:
@@ -1 +1 @@
|
||||
648
|
||||
656
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.19.0" />
|
||||
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.23.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="2.51.0" />
|
||||
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="2.50.0" />
|
||||
|
||||
@@ -33,6 +33,7 @@ namespace AutopilotMonitor.Functions.DataAccess
|
||||
services.AddSingleton<IBootstrapRepository, TableBootstrapRepository>();
|
||||
services.AddSingleton<INotificationRepository, TableNotificationRepository>();
|
||||
services.AddSingleton<IDeviceSecurityRepository, TableDeviceSecurityRepository>();
|
||||
services.AddSingleton<IApiUsageRepository, TableApiUsageRepository>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -272,6 +272,9 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
["RequestCount"] = entry.RequestCount,
|
||||
};
|
||||
|
||||
if (entry.CustomRateLimitPerMinute.HasValue)
|
||||
entity["CustomRateLimitPerMinute"] = entry.CustomRateLimitPerMinute.Value;
|
||||
|
||||
if (entry.ExpiresAt.HasValue)
|
||||
entity["ExpiresAt"] = new DateTimeOffset(entry.ExpiresAt.Value, TimeSpan.Zero);
|
||||
|
||||
@@ -372,6 +375,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
|
||||
ExpiresAt = entity.GetDateTimeOffset("ExpiresAt")?.UtcDateTime,
|
||||
IsActive = entity.GetBoolean("IsActive") ?? true,
|
||||
RequestCount = entity.TryGetValue("RequestCount", out var rc) ? Convert.ToInt64(rc) : 0L,
|
||||
CustomRateLimitPerMinute = entity.TryGetValue("CustomRateLimitPerMinute", out var crl) ? Convert.ToInt32(crl) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
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 IApiUsageRepository.
|
||||
/// PartitionKey: keyId, RowKey: {yyyyMMdd}_{normalizedEndpoint}
|
||||
/// </summary>
|
||||
public class TableApiUsageRepository : IApiUsageRepository
|
||||
{
|
||||
private readonly TableClient _tableClient;
|
||||
private readonly ILogger<TableApiUsageRepository> _logger;
|
||||
|
||||
public TableApiUsageRepository(
|
||||
TableStorageService storage,
|
||||
ILogger<TableApiUsageRepository> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_tableClient = storage.GetTableClient(Constants.TableNames.ApiUsageLog);
|
||||
}
|
||||
|
||||
public async Task IncrementUsageAsync(string keyId, string tenantId, string scope, string endpoint)
|
||||
{
|
||||
var date = DateTime.UtcNow.ToString("yyyyMMdd");
|
||||
var rowKey = $"{date}_{endpoint}";
|
||||
|
||||
const int maxRetries = 3;
|
||||
for (var attempt = 0; attempt < maxRetries; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _tableClient.GetEntityAsync<TableEntity>(keyId, rowKey);
|
||||
var entity = result.Value;
|
||||
var count = entity.TryGetValue("RequestCount", out var c) ? Convert.ToInt64(c) : 0L;
|
||||
entity["RequestCount"] = count + 1;
|
||||
entity["LastRequestAt"] = DateTimeOffset.UtcNow;
|
||||
await _tableClient.UpdateEntityAsync(entity, entity.ETag);
|
||||
return;
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 404)
|
||||
{
|
||||
// Entity doesn't exist yet — create it
|
||||
var entity = new TableEntity(keyId, rowKey)
|
||||
{
|
||||
["Date"] = date,
|
||||
["Endpoint"] = endpoint,
|
||||
["KeyId"] = keyId,
|
||||
["TenantId"] = tenantId,
|
||||
["Scope"] = scope,
|
||||
["RequestCount"] = 1L,
|
||||
["LastRequestAt"] = DateTimeOffset.UtcNow,
|
||||
};
|
||||
try
|
||||
{
|
||||
await _tableClient.AddEntityAsync(entity);
|
||||
return;
|
||||
}
|
||||
catch (RequestFailedException addEx) when (addEx.Status == 409)
|
||||
{
|
||||
// Another request created it concurrently — retry the update
|
||||
continue;
|
||||
}
|
||||
}
|
||||
catch (RequestFailedException ex) when (ex.Status == 412)
|
||||
{
|
||||
// ETag conflict — another request updated concurrently, retry
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Failed to increment usage after {MaxRetries} retries: key={KeyId}, endpoint={Endpoint}",
|
||||
maxRetries, keyId, endpoint);
|
||||
}
|
||||
|
||||
public async Task<List<ApiUsageRecord>> GetUsageByKeyAsync(string keyId, string? dateFrom = null, string? dateTo = null)
|
||||
{
|
||||
var filter = $"PartitionKey eq '{keyId}'";
|
||||
filter = AppendDateFilter(filter, dateFrom, dateTo);
|
||||
|
||||
var records = new List<ApiUsageRecord>();
|
||||
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(filter: filter))
|
||||
{
|
||||
records.Add(MapToRecord(entity));
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
public async Task<List<ApiUsageRecord>> GetUsageByTenantAsync(string tenantId, string? dateFrom = null, string? dateTo = null)
|
||||
{
|
||||
var filter = $"TenantId eq '{tenantId}'";
|
||||
filter = AppendDateFilter(filter, dateFrom, dateTo);
|
||||
|
||||
var records = new List<ApiUsageRecord>();
|
||||
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(filter: filter))
|
||||
{
|
||||
records.Add(MapToRecord(entity));
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
public async Task<List<ApiUsageDailySummary>> GetDailySummaryAsync(string? tenantId = null, string? dateFrom = null, string? dateTo = null)
|
||||
{
|
||||
string? filter = null;
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
filter = $"TenantId eq '{tenantId}'";
|
||||
filter = AppendDateFilter(filter, dateFrom, dateTo);
|
||||
|
||||
var records = new List<ApiUsageRecord>();
|
||||
await foreach (var entity in _tableClient.QueryAsync<TableEntity>(filter: filter))
|
||||
{
|
||||
records.Add(MapToRecord(entity));
|
||||
}
|
||||
|
||||
// Aggregate by date
|
||||
var grouped = records
|
||||
.GroupBy(r => r.Date)
|
||||
.Select(g => new ApiUsageDailySummary
|
||||
{
|
||||
Date = g.Key,
|
||||
TenantId = tenantId,
|
||||
TotalRequests = g.Sum(r => r.RequestCount),
|
||||
UniqueKeys = g.Select(r => r.KeyId).Distinct().Count(),
|
||||
UniqueEndpoints = g.Select(r => r.Endpoint).Distinct().Count(),
|
||||
})
|
||||
.OrderByDescending(s => s.Date)
|
||||
.ToList();
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
private static string AppendDateFilter(string? existingFilter, string? dateFrom, string? dateTo)
|
||||
{
|
||||
var filter = existingFilter ?? "";
|
||||
|
||||
if (!string.IsNullOrEmpty(dateFrom))
|
||||
{
|
||||
var dateVal = dateFrom.Replace("-", "");
|
||||
var clause = $"Date ge '{dateVal}'";
|
||||
filter = string.IsNullOrEmpty(filter) ? clause : $"{filter} and {clause}";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(dateTo))
|
||||
{
|
||||
var dateVal = dateTo.Replace("-", "");
|
||||
var clause = $"Date le '{dateVal}'";
|
||||
filter = string.IsNullOrEmpty(filter) ? clause : $"{filter} and {clause}";
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(filter) ? null! : filter;
|
||||
}
|
||||
|
||||
private static ApiUsageRecord MapToRecord(TableEntity entity)
|
||||
{
|
||||
return new ApiUsageRecord
|
||||
{
|
||||
KeyId = entity.GetString("KeyId") ?? entity.PartitionKey,
|
||||
TenantId = entity.GetString("TenantId") ?? string.Empty,
|
||||
Scope = entity.GetString("Scope") ?? string.Empty,
|
||||
Endpoint = entity.GetString("Endpoint") ?? string.Empty,
|
||||
Date = entity.GetString("Date") ?? string.Empty,
|
||||
RequestCount = entity.TryGetValue("RequestCount", out var rc) ? Convert.ToInt64(rc) : 0L,
|
||||
LastRequestAt = entity.GetDateTimeOffset("LastRequestAt")?.UtcDateTime ?? DateTime.MinValue,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Admin
|
||||
{
|
||||
public class ApiUsageFunction
|
||||
{
|
||||
private readonly ILogger<ApiUsageFunction> _logger;
|
||||
private readonly IApiUsageRepository _usageRepo;
|
||||
|
||||
public ApiUsageFunction(ILogger<ApiUsageFunction> logger, IApiUsageRepository usageRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_usageRepo = usageRepo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/api-keys/{keyId}/usage?dateFrom=&dateTo=
|
||||
/// Returns usage records for a specific API key.
|
||||
/// </summary>
|
||||
[Function("GetApiKeyUsage")]
|
||||
public async Task<HttpResponseData> GetKeyUsage(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "api-keys/{keyId}/usage")] HttpRequestData req,
|
||||
string keyId)
|
||||
{
|
||||
_logger.LogInformation("GetApiKeyUsage: keyId={KeyId}", keyId);
|
||||
|
||||
try
|
||||
{
|
||||
var dateFrom = req.Query["dateFrom"];
|
||||
var dateTo = req.Query["dateTo"];
|
||||
|
||||
var records = await _usageRepo.GetUsageByKeyAsync(keyId, dateFrom, dateTo);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
keyId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
count = records.Count,
|
||||
totalRequests = records.Sum(r => r.RequestCount),
|
||||
records
|
||||
});
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting API key usage");
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(new { error = "Internal server error" });
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/api-usage?tenantId=&dateFrom=&dateTo=
|
||||
/// Returns usage records across keys, optionally filtered by tenant.
|
||||
/// </summary>
|
||||
[Function("GetGlobalApiUsage")]
|
||||
public async Task<HttpResponseData> GetGlobalUsage(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/api-usage")] HttpRequestData req)
|
||||
{
|
||||
_logger.LogInformation("GetGlobalApiUsage processing request");
|
||||
|
||||
try
|
||||
{
|
||||
var tenantId = req.Query["tenantId"];
|
||||
var dateFrom = req.Query["dateFrom"];
|
||||
var dateTo = req.Query["dateTo"];
|
||||
|
||||
List<ApiUsageRecord> records;
|
||||
if (!string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
records = await _usageRepo.GetUsageByTenantAsync(tenantId, dateFrom, dateTo);
|
||||
}
|
||||
else
|
||||
{
|
||||
// All usage — query without tenant filter via daily summary
|
||||
var summary = await _usageRepo.GetDailySummaryAsync(null, dateFrom, dateTo);
|
||||
var response2 = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response2.WriteAsJsonAsync(new
|
||||
{
|
||||
tenantId = (string?)null,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
totalRequests = summary.Sum(s => s.TotalRequests),
|
||||
summary
|
||||
});
|
||||
return response2;
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
tenantId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
count = records.Count,
|
||||
totalRequests = records.Sum(r => r.RequestCount),
|
||||
records
|
||||
});
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting global API usage");
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(new { error = "Internal server error" });
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/api-usage/daily?tenantId=&dateFrom=&dateTo=
|
||||
/// Returns aggregated daily usage summaries.
|
||||
/// </summary>
|
||||
[Function("GetGlobalApiUsageDaily")]
|
||||
public async Task<HttpResponseData> GetDailyUsage(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/api-usage/daily")] HttpRequestData req)
|
||||
{
|
||||
_logger.LogInformation("GetGlobalApiUsageDaily processing request");
|
||||
|
||||
try
|
||||
{
|
||||
var tenantId = req.Query["tenantId"];
|
||||
var dateFrom = req.Query["dateFrom"];
|
||||
var dateTo = req.Query["dateTo"];
|
||||
|
||||
var summary = await _usageRepo.GetDailySummaryAsync(tenantId, dateFrom, dateTo);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
tenantId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
days = summary.Count,
|
||||
totalRequests = summary.Sum(s => s.TotalRequests),
|
||||
summary
|
||||
});
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting daily API usage");
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(new { error = "Internal server error" });
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Config
|
||||
{
|
||||
public class PlanManagementFunction
|
||||
{
|
||||
private readonly ILogger<PlanManagementFunction> _logger;
|
||||
private readonly IConfigRepository _configRepo;
|
||||
private readonly IAdminRepository _adminRepo;
|
||||
|
||||
public PlanManagementFunction(
|
||||
ILogger<PlanManagementFunction> logger,
|
||||
IConfigRepository configRepo,
|
||||
IAdminRepository adminRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_configRepo = configRepo;
|
||||
_adminRepo = adminRepo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PATCH /api/config/{tenantId}/plan
|
||||
/// Sets the plan tier for a tenant. Body: { "planTier": "pro" }
|
||||
/// </summary>
|
||||
[Function("SetTenantPlanTier")]
|
||||
public async Task<HttpResponseData> SetPlanTier(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "config/{tenantId}/plan")] HttpRequestData req,
|
||||
string tenantId)
|
||||
{
|
||||
_logger.LogInformation("SetTenantPlanTier: tenantId={TenantId}", tenantId);
|
||||
|
||||
try
|
||||
{
|
||||
var body = await req.ReadFromJsonAsync<SetPlanTierRequest>();
|
||||
if (body == null || string.IsNullOrWhiteSpace(body.PlanTier))
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { error = "planTier is required" });
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
var validTiers = new[] { "free", "pro", "enterprise" };
|
||||
if (!validTiers.Contains(body.PlanTier.ToLowerInvariant()))
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { error = $"Invalid planTier. Valid values: {string.Join(", ", validTiers)}" });
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
var config = await _configRepo.GetTenantConfigurationAsync(tenantId);
|
||||
if (config == null)
|
||||
{
|
||||
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
|
||||
await notFound.WriteAsJsonAsync(new { error = "Tenant not found" });
|
||||
return notFound;
|
||||
}
|
||||
|
||||
config.PlanTier = body.PlanTier.ToLowerInvariant();
|
||||
await _configRepo.SaveTenantConfigurationAsync(config);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { tenantId, planTier = config.PlanTier });
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error setting plan tier");
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(new { error = "Internal server error" });
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PATCH /api/api-keys/{keyId}/rate-limit
|
||||
/// Sets a custom rate limit for an API key. Body: { "rateLimitPerMinute": 300 }
|
||||
/// Send null to remove the override.
|
||||
/// </summary>
|
||||
[Function("SetApiKeyRateLimit")]
|
||||
public async Task<HttpResponseData> SetKeyRateLimit(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "api-keys/{keyId}/rate-limit")] HttpRequestData req,
|
||||
string keyId)
|
||||
{
|
||||
_logger.LogInformation("SetApiKeyRateLimit: keyId={KeyId}", keyId);
|
||||
|
||||
try
|
||||
{
|
||||
var body = await req.ReadFromJsonAsync<SetRateLimitRequest>();
|
||||
if (body == null)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { error = "Request body is required" });
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
if (body.RateLimitPerMinute.HasValue && body.RateLimitPerMinute.Value < 1)
|
||||
{
|
||||
var badRequest = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await badRequest.WriteAsJsonAsync(new { error = "rateLimitPerMinute must be >= 1 or null" });
|
||||
return badRequest;
|
||||
}
|
||||
|
||||
// Try to find the key — check both GLOBAL and all tenants
|
||||
var allKeys = await _adminRepo.GetAllApiKeysAsync();
|
||||
var key = allKeys.FirstOrDefault(k => k.KeyId == keyId);
|
||||
|
||||
if (key == null)
|
||||
{
|
||||
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
|
||||
await notFound.WriteAsJsonAsync(new { error = "API key not found" });
|
||||
return notFound;
|
||||
}
|
||||
|
||||
key.CustomRateLimitPerMinute = body.RateLimitPerMinute;
|
||||
|
||||
var partitionKey = key.Scope == "global" ? "GLOBAL" : key.TenantId;
|
||||
await _adminRepo.StoreApiKeyAsync(partitionKey, key);
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
keyId = key.KeyId,
|
||||
customRateLimitPerMinute = key.CustomRateLimitPerMinute,
|
||||
scope = key.Scope
|
||||
});
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error setting API key rate limit");
|
||||
var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await errorResponse.WriteAsJsonAsync(new { error = "Internal server error" });
|
||||
return errorResponse;
|
||||
}
|
||||
}
|
||||
|
||||
private class SetPlanTierRequest
|
||||
{
|
||||
public string PlanTier { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private class SetRateLimitRequest
|
||||
{
|
||||
public int? RateLimitPerMinute { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
{
|
||||
public class AppInsightsQueryFunction
|
||||
{
|
||||
private readonly ILogger<AppInsightsQueryFunction> _logger;
|
||||
private static readonly HttpClient _httpClient = new() { Timeout = TimeSpan.FromSeconds(30) };
|
||||
private static readonly DefaultAzureCredential _credential = new();
|
||||
|
||||
// Application Insights REST API scope for Azure AD auth
|
||||
private const string AppInsightsScope = "https://api.applicationinsights.io/.default";
|
||||
|
||||
public AppInsightsQueryFunction(ILogger<AppInsightsQueryFunction> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST /api/global/raw/logs — Proxy KQL queries to Application Insights
|
||||
/// Body: { "query": "traces | where ...", "timespan": "PT1H" }
|
||||
/// Uses Managed Identity (DefaultAzureCredential) for authentication.
|
||||
/// Requires APPINSIGHTS_APP_ID app setting and "Monitoring Reader" RBAC role.
|
||||
/// </summary>
|
||||
[Function("QueryBackendLogs")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "global/raw/logs")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appId = Environment.GetEnvironmentVariable("APPINSIGHTS_APP_ID");
|
||||
|
||||
if (string.IsNullOrEmpty(appId))
|
||||
{
|
||||
var unavailable = req.CreateResponse(HttpStatusCode.ServiceUnavailable);
|
||||
await unavailable.WriteAsJsonAsync(new { error = "Application Insights diagnostics is not configured. Set the APPINSIGHTS_APP_ID app setting and assign 'Monitoring Reader' role to the Function App's Managed Identity." });
|
||||
return unavailable;
|
||||
}
|
||||
|
||||
var body = await req.ReadFromJsonAsync<LogQueryRequest>();
|
||||
if (body == null || string.IsNullOrWhiteSpace(body.Query))
|
||||
{
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new { error = "query is required" });
|
||||
return bad;
|
||||
}
|
||||
|
||||
var timespan = body.Timespan ?? "PT1H";
|
||||
|
||||
// Acquire Bearer token via Managed Identity / DefaultAzureCredential
|
||||
var tokenResult = await _credential.GetTokenAsync(
|
||||
new Azure.Core.TokenRequestContext(new[] { AppInsightsScope }));
|
||||
|
||||
// Call Application Insights REST API
|
||||
var aiUrl = $"https://api.applicationinsights.io/v1/apps/{appId}/query";
|
||||
var requestBody = JsonSerializer.Serialize(new
|
||||
{
|
||||
query = body.Query,
|
||||
timespan
|
||||
});
|
||||
|
||||
using var aiRequest = new HttpRequestMessage(HttpMethod.Post, aiUrl);
|
||||
aiRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokenResult.Token);
|
||||
aiRequest.Content = new StringContent(requestBody, Encoding.UTF8, "application/json");
|
||||
|
||||
using var aiResponse = await _httpClient.SendAsync(aiRequest);
|
||||
var responseText = await aiResponse.Content.ReadAsStringAsync();
|
||||
|
||||
if (!aiResponse.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("App Insights query failed: {StatusCode} {Response}",
|
||||
aiResponse.StatusCode, responseText);
|
||||
|
||||
var errorResp = req.CreateResponse(HttpStatusCode.BadGateway);
|
||||
await errorResp.WriteAsJsonAsync(new
|
||||
{
|
||||
error = "Application Insights query failed",
|
||||
statusCode = (int)aiResponse.StatusCode,
|
||||
details = responseText
|
||||
});
|
||||
return errorResp;
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
response.Headers.Add("Content-Type", "application/json");
|
||||
await response.WriteStringAsync(responseText);
|
||||
return response;
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
var timeout = req.CreateResponse(HttpStatusCode.GatewayTimeout);
|
||||
await timeout.WriteAsJsonAsync(new { error = "Application Insights query timed out (30s limit)" });
|
||||
return timeout;
|
||||
}
|
||||
catch (Azure.Identity.CredentialUnavailableException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Managed Identity not available for App Insights query");
|
||||
var err = req.CreateResponse(HttpStatusCode.ServiceUnavailable);
|
||||
await err.WriteAsJsonAsync(new { error = "Managed Identity is not configured. Enable System-assigned Managed Identity on the Function App and assign 'Monitoring Reader' role on the Application Insights resource." });
|
||||
return err;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error querying Application Insights");
|
||||
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await err.WriteAsJsonAsync(new { error = "Internal server error" });
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
private class LogQueryRequest
|
||||
{
|
||||
public string Query { get; set; } = string.Empty;
|
||||
public string? Timespan { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
{
|
||||
public class QueryRawEventsFunction
|
||||
{
|
||||
private readonly ILogger<QueryRawEventsFunction> _logger;
|
||||
private readonly ISessionRepository _sessionRepo;
|
||||
|
||||
public QueryRawEventsFunction(ILogger<QueryRawEventsFunction> logger, ISessionRepository sessionRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_sessionRepo = sessionRepo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/raw/events — Tenant-scoped raw event query (cross-session)
|
||||
/// </summary>
|
||||
[Function("QueryRawEvents")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "raw/events")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = TenantHelper.GetTenantId(req);
|
||||
return await QueryEvents(req, tenantId);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
var err = req.CreateResponse(HttpStatusCode.Unauthorized);
|
||||
await err.WriteAsJsonAsync(new { error = "Unauthorized" });
|
||||
return err;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error querying raw events");
|
||||
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await err.WriteAsJsonAsync(new { error = "Internal server error" });
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/raw/events — Cross-tenant raw event query (GlobalAdminOnly)
|
||||
/// </summary>
|
||||
[Function("QueryRawEventsGlobal")]
|
||||
public async Task<HttpResponseData> RunGlobal(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/raw/events")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = req.Query["tenantId"];
|
||||
if (string.IsNullOrEmpty(tenantId))
|
||||
{
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new { error = "tenantId query parameter is required for global raw event queries" });
|
||||
return bad;
|
||||
}
|
||||
return await QueryEvents(req, tenantId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error querying global raw events");
|
||||
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await err.WriteAsJsonAsync(new { error = "Internal server error" });
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpResponseData> QueryEvents(HttpRequestData req, string tenantId)
|
||||
{
|
||||
var sessionId = req.Query["sessionId"];
|
||||
var eventType = req.Query["eventType"];
|
||||
var severity = req.Query["severity"];
|
||||
var source = req.Query["source"];
|
||||
var startedAfter = req.Query["startedAfter"];
|
||||
var startedBefore = req.Query["startedBefore"];
|
||||
var limitStr = req.Query["limit"];
|
||||
var limit = int.TryParse(limitStr, out var l) ? Math.Clamp(l, 1, 500) : 100;
|
||||
|
||||
List<EnrollmentEvent> events;
|
||||
|
||||
if (!string.IsNullOrEmpty(sessionId))
|
||||
{
|
||||
// Single session query — use existing repo method
|
||||
events = await _sessionRepo.GetSessionEventsAsync(tenantId, sessionId, limit);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Cross-session: We need to search by event type via the EventTypeIndex
|
||||
// then fetch events for matched sessions
|
||||
if (string.IsNullOrEmpty(eventType))
|
||||
{
|
||||
var bad = req.CreateResponse(HttpStatusCode.BadRequest);
|
||||
await bad.WriteAsJsonAsync(new { error = "Either sessionId or eventType is required for raw event queries" });
|
||||
return bad;
|
||||
}
|
||||
|
||||
// Use search by event to find sessions, then fetch their events
|
||||
var sessions = await _sessionRepo.SearchSessionsByEventAsync(tenantId, eventType, source, severity, null, limit: 20);
|
||||
events = new List<EnrollmentEvent>();
|
||||
foreach (var session in sessions)
|
||||
{
|
||||
var sessionEvents = await _sessionRepo.GetSessionEventsAsync(tenantId, session.SessionId, 200);
|
||||
events.AddRange(sessionEvents.Where(e =>
|
||||
(string.IsNullOrEmpty(eventType) || e.EventType == eventType) &&
|
||||
(string.IsNullOrEmpty(severity) || e.Severity.ToString().Equals(severity, StringComparison.OrdinalIgnoreCase)) &&
|
||||
(string.IsNullOrEmpty(source) || (e.Source ?? "").Contains(source, StringComparison.OrdinalIgnoreCase))
|
||||
));
|
||||
|
||||
if (events.Count >= limit) break;
|
||||
}
|
||||
events = events.Take(limit).ToList();
|
||||
}
|
||||
|
||||
// Apply client-side filters for single-session queries
|
||||
if (!string.IsNullOrEmpty(sessionId))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(eventType))
|
||||
events = events.Where(e => e.EventType == eventType).ToList();
|
||||
if (!string.IsNullOrEmpty(severity))
|
||||
events = events.Where(e => e.Severity.ToString().Equals(severity, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
if (!string.IsNullOrEmpty(source))
|
||||
events = events.Where(e => (e.Source ?? "").Contains(source, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(startedAfter) && DateTime.TryParse(startedAfter, out var after))
|
||||
events = events.Where(e => e.Timestamp >= after).ToList();
|
||||
if (!string.IsNullOrEmpty(startedBefore) && DateTime.TryParse(startedBefore, out var before))
|
||||
events = events.Where(e => e.Timestamp <= before).ToList();
|
||||
|
||||
events = events.Take(limit).OrderBy(e => e.Timestamp).ThenBy(e => e.Sequence).ToList();
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
tenantId,
|
||||
count = events.Count,
|
||||
events
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Functions.Helpers;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
{
|
||||
public class QueryRawSessionsFunction
|
||||
{
|
||||
private readonly ILogger<QueryRawSessionsFunction> _logger;
|
||||
private readonly ISessionRepository _sessionRepo;
|
||||
|
||||
public QueryRawSessionsFunction(ILogger<QueryRawSessionsFunction> logger, ISessionRepository sessionRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_sessionRepo = sessionRepo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/raw/sessions — Tenant-scoped raw session query
|
||||
/// </summary>
|
||||
[Function("QueryRawSessions")]
|
||||
public async Task<HttpResponseData> Run(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "raw/sessions")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = TenantHelper.GetTenantId(req);
|
||||
return await QuerySessions(req, tenantId);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
var err = req.CreateResponse(HttpStatusCode.Unauthorized);
|
||||
await err.WriteAsJsonAsync(new { error = "Unauthorized" });
|
||||
return err;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error querying raw sessions");
|
||||
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await err.WriteAsJsonAsync(new { error = "Internal server error" });
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/raw/sessions — Cross-tenant raw session query (GlobalAdminOnly)
|
||||
/// </summary>
|
||||
[Function("QueryRawSessionsGlobal")]
|
||||
public async Task<HttpResponseData> RunGlobal(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/raw/sessions")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantId = req.Query["tenantId"];
|
||||
return await QuerySessions(req, tenantId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error querying global raw sessions");
|
||||
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await err.WriteAsJsonAsync(new { error = "Internal server error" });
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpResponseData> QuerySessions(HttpRequestData req, string? tenantId)
|
||||
{
|
||||
var status = req.Query["status"];
|
||||
var startedAfter = req.Query["startedAfter"];
|
||||
var startedBefore = req.Query["startedBefore"];
|
||||
var serialNumber = req.Query["serialNumber"];
|
||||
var fieldsParam = req.Query["fields"];
|
||||
var limitStr = req.Query["limit"];
|
||||
var limit = int.TryParse(limitStr, out var l) ? Math.Clamp(l, 1, 200) : 50;
|
||||
|
||||
// Build search filter for the underlying query
|
||||
var filter = new SessionSearchFilter();
|
||||
if (!string.IsNullOrEmpty(status))
|
||||
filter.Status = status;
|
||||
if (!string.IsNullOrEmpty(startedAfter) && DateTime.TryParse(startedAfter, out var after))
|
||||
filter.StartedAfter = after;
|
||||
if (!string.IsNullOrEmpty(startedBefore) && DateTime.TryParse(startedBefore, out var before))
|
||||
filter.StartedBefore = before;
|
||||
if (!string.IsNullOrEmpty(serialNumber))
|
||||
filter.SerialNumber = serialNumber;
|
||||
filter.Limit = limit;
|
||||
|
||||
var sessions = await _sessionRepo.SearchSessionsAsync(tenantId, filter);
|
||||
|
||||
// Apply field projection if requested
|
||||
object result;
|
||||
if (!string.IsNullOrEmpty(fieldsParam))
|
||||
{
|
||||
var fieldSet = new HashSet<string>(
|
||||
fieldsParam.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var projected = sessions.Select(session => ProjectFields(session, fieldSet)).ToList();
|
||||
result = new { tenantId, count = projected.Count, sessions = projected };
|
||||
}
|
||||
else
|
||||
{
|
||||
result = new { tenantId, count = sessions.Count, sessions };
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(result);
|
||||
return response;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ProjectFields(SessionSummary session, HashSet<string> fields)
|
||||
{
|
||||
var dict = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Map requested field names to session properties
|
||||
if (fields.Contains("sessionId")) dict["sessionId"] = session.SessionId;
|
||||
if (fields.Contains("tenantId")) dict["tenantId"] = session.TenantId;
|
||||
if (fields.Contains("status")) dict["status"] = session.Status.ToString();
|
||||
if (fields.Contains("serialNumber")) dict["serialNumber"] = session.SerialNumber;
|
||||
if (fields.Contains("manufacturer")) dict["manufacturer"] = session.Manufacturer;
|
||||
if (fields.Contains("model")) dict["model"] = session.Model;
|
||||
if (fields.Contains("deviceName")) dict["deviceName"] = session.DeviceName;
|
||||
if (fields.Contains("osBuild")) dict["osBuild"] = session.OsBuild;
|
||||
if (fields.Contains("osName")) dict["osName"] = session.OsName;
|
||||
if (fields.Contains("startedAt")) dict["startedAt"] = session.StartedAt;
|
||||
if (fields.Contains("completedAt")) dict["completedAt"] = session.CompletedAt;
|
||||
if (fields.Contains("durationSeconds")) dict["durationSeconds"] = session.DurationSeconds;
|
||||
if (fields.Contains("currentPhase")) dict["currentPhase"] = session.CurrentPhase;
|
||||
if (fields.Contains("failureReason")) dict["failureReason"] = session.FailureReason;
|
||||
if (fields.Contains("eventCount")) dict["eventCount"] = session.EventCount;
|
||||
if (fields.Contains("enrollmentType")) dict["enrollmentType"] = session.EnrollmentType;
|
||||
if (fields.Contains("isPreProvisioned")) dict["isPreProvisioned"] = session.IsPreProvisioned;
|
||||
if (fields.Contains("isUserDriven")) dict["isUserDriven"] = session.IsUserDriven;
|
||||
if (fields.Contains("isHybridJoin")) dict["isHybridJoin"] = session.IsHybridJoin;
|
||||
if (fields.Contains("agentVersion")) dict["agentVersion"] = session.AgentVersion;
|
||||
if (fields.Contains("geoCountry")) dict["geoCountry"] = session.GeoCountry;
|
||||
|
||||
// If no fields matched, return all
|
||||
if (dict.Count == 0)
|
||||
{
|
||||
dict["sessionId"] = session.SessionId;
|
||||
dict["tenantId"] = session.TenantId;
|
||||
dict["status"] = session.Status.ToString();
|
||||
dict["serialNumber"] = session.SerialNumber;
|
||||
dict["startedAt"] = session.StartedAt;
|
||||
dict["completedAt"] = session.CompletedAt;
|
||||
}
|
||||
|
||||
return dict;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Net;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared;
|
||||
using Azure.Data.Tables;
|
||||
using Microsoft.Azure.Functions.Worker;
|
||||
using Microsoft.Azure.Functions.Worker.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Functions.Raw
|
||||
{
|
||||
public class TableQueryFunction
|
||||
{
|
||||
private readonly ILogger<TableQueryFunction> _logger;
|
||||
private readonly TableStorageService _storage;
|
||||
|
||||
// Tables that must never be exposed (contain secrets)
|
||||
private static readonly HashSet<string> _blacklistedTables = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
Constants.TableNames.ApiKeys
|
||||
};
|
||||
|
||||
public TableQueryFunction(ILogger<TableQueryFunction> logger, TableStorageService storage)
|
||||
{
|
||||
_logger = logger;
|
||||
_storage = storage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/raw/tables — List all available table names
|
||||
/// </summary>
|
||||
[Function("ListRawTables")]
|
||||
public async Task<HttpResponseData> ListTables(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/raw/tables")] HttpRequestData req)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tables = Constants.TableNames.All
|
||||
.Where(t => !_blacklistedTables.Contains(t))
|
||||
.OrderBy(t => t)
|
||||
.ToList();
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new { count = tables.Count, tables });
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error listing tables");
|
||||
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await err.WriteAsJsonAsync(new { error = "Internal server error" });
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/global/raw/tables/{tableName} — Query any table directly
|
||||
/// Query params: partitionKey, rowKeyPrefix, filter, limit
|
||||
/// </summary>
|
||||
[Function("QueryRawTable")]
|
||||
public async Task<HttpResponseData> QueryTable(
|
||||
[HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/raw/tables/{tableName}")] HttpRequestData req,
|
||||
string tableName)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Validate table name
|
||||
if (!Constants.TableNames.All.Contains(tableName) &&
|
||||
!Constants.TableNames.All.Any(t => t.Equals(tableName, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var notFound = req.CreateResponse(HttpStatusCode.NotFound);
|
||||
await notFound.WriteAsJsonAsync(new { error = $"Table '{tableName}' not found" });
|
||||
return notFound;
|
||||
}
|
||||
|
||||
if (_blacklistedTables.Contains(tableName))
|
||||
{
|
||||
var forbidden = req.CreateResponse(HttpStatusCode.Forbidden);
|
||||
await forbidden.WriteAsJsonAsync(new { error = $"Table '{tableName}' is not accessible" });
|
||||
return forbidden;
|
||||
}
|
||||
|
||||
// Resolve actual table name (case-insensitive match)
|
||||
var actualTableName = Constants.TableNames.All
|
||||
.First(t => t.Equals(tableName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var partitionKey = req.Query["partitionKey"];
|
||||
var rowKeyPrefix = req.Query["rowKeyPrefix"];
|
||||
var filter = req.Query["filter"];
|
||||
var limitStr = req.Query["limit"];
|
||||
var limit = int.TryParse(limitStr, out var l) ? Math.Clamp(l, 1, 500) : 100;
|
||||
|
||||
// Build OData filter
|
||||
var oDataFilter = BuildFilter(partitionKey, rowKeyPrefix, filter);
|
||||
|
||||
var tableClient = _storage.GetTableClient(actualTableName);
|
||||
var entities = new List<Dictionary<string, object?>>();
|
||||
|
||||
await foreach (var entity in tableClient.QueryAsync<TableEntity>(filter: oDataFilter))
|
||||
{
|
||||
var dict = new Dictionary<string, object?>
|
||||
{
|
||||
["PartitionKey"] = entity.PartitionKey,
|
||||
["RowKey"] = entity.RowKey,
|
||||
["Timestamp"] = entity.Timestamp,
|
||||
};
|
||||
|
||||
foreach (var kvp in entity)
|
||||
{
|
||||
if (kvp.Key is "odata.etag" or "PartitionKey" or "RowKey" or "Timestamp")
|
||||
continue;
|
||||
dict[kvp.Key] = kvp.Value;
|
||||
}
|
||||
|
||||
entities.Add(dict);
|
||||
if (entities.Count >= limit)
|
||||
break;
|
||||
}
|
||||
|
||||
var response = req.CreateResponse(HttpStatusCode.OK);
|
||||
await response.WriteAsJsonAsync(new
|
||||
{
|
||||
table = actualTableName,
|
||||
count = entities.Count,
|
||||
entities
|
||||
});
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error querying table {TableName}", tableName);
|
||||
var err = req.CreateResponse(HttpStatusCode.InternalServerError);
|
||||
await err.WriteAsJsonAsync(new { error = "Internal server error" });
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? BuildFilter(string? partitionKey, string? rowKeyPrefix, string? customFilter)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(partitionKey))
|
||||
parts.Add($"PartitionKey eq '{SanitizeOData(partitionKey)}'");
|
||||
|
||||
if (!string.IsNullOrEmpty(rowKeyPrefix))
|
||||
{
|
||||
var sanitized = SanitizeOData(rowKeyPrefix);
|
||||
parts.Add($"RowKey ge '{sanitized}' and RowKey lt '{sanitized}~'");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(customFilter))
|
||||
{
|
||||
// Basic sanitization: block potentially dangerous characters
|
||||
var sanitizedFilter = customFilter
|
||||
.Replace(";", "")
|
||||
.Replace("--", "");
|
||||
parts.Add($"({sanitizedFilter})");
|
||||
}
|
||||
|
||||
return parts.Count > 0 ? string.Join(" and ", parts) : null;
|
||||
}
|
||||
|
||||
private static string SanitizeOData(string value)
|
||||
{
|
||||
// Escape single quotes for OData
|
||||
return value.Replace("'", "''");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using AutopilotMonitor.Functions.Services;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -21,6 +22,13 @@ public class ApiKeyMiddleware : IFunctionsWorkerMiddleware
|
||||
private readonly ILogger<ApiKeyMiddleware> _logger;
|
||||
private readonly IAdminRepository _adminRepo;
|
||||
private readonly RateLimitService _rateLimitService;
|
||||
private readonly RateLimitConfigService _rateLimitConfigService;
|
||||
private readonly IApiUsageRepository _apiUsageRepo;
|
||||
|
||||
// Matches GUIDs and GUID-like segments in URL paths for normalization
|
||||
private static readonly Regex GuidPattern = new(
|
||||
@"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
// Routes that bypass ApiKey middleware (use device cert / bootstrap token auth)
|
||||
private static readonly string[] _bypassPrefixes =
|
||||
@@ -33,11 +41,13 @@ public class ApiKeyMiddleware : IFunctionsWorkerMiddleware
|
||||
"/api/progress/",
|
||||
};
|
||||
|
||||
public ApiKeyMiddleware(ILogger<ApiKeyMiddleware> logger, IAdminRepository adminRepo, RateLimitService rateLimitService)
|
||||
public ApiKeyMiddleware(ILogger<ApiKeyMiddleware> logger, IAdminRepository adminRepo, RateLimitService rateLimitService, RateLimitConfigService rateLimitConfigService, IApiUsageRepository apiUsageRepo)
|
||||
{
|
||||
_logger = logger;
|
||||
_adminRepo = adminRepo;
|
||||
_rateLimitService = rateLimitService;
|
||||
_rateLimitConfigService = rateLimitConfigService;
|
||||
_apiUsageRepo = apiUsageRepo;
|
||||
}
|
||||
|
||||
public async Task Invoke(FunctionContext context, FunctionExecutionDelegate next)
|
||||
@@ -105,8 +115,8 @@ public class ApiKeyMiddleware : IFunctionsWorkerMiddleware
|
||||
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;
|
||||
// Rate limit: resolve from key override → tenant override → plan tier → fallback
|
||||
var maxRequests = await _rateLimitConfigService.ResolveRateLimitAsync(foundKey);
|
||||
var rateLimitResult = _rateLimitService.CheckRateLimit($"apikey_{foundKey.KeyId}", maxRequests);
|
||||
if (!rateLimitResult.IsAllowed)
|
||||
{
|
||||
@@ -156,6 +166,10 @@ public class ApiKeyMiddleware : IFunctionsWorkerMiddleware
|
||||
// Increment request count (fire-and-forget)
|
||||
_ = IncrementRequestCountAsync(partitionKey, foundKey.KeyId);
|
||||
|
||||
// Record per-endpoint usage (fire-and-forget)
|
||||
var normalizedEndpoint = NormalizeEndpoint(requestPath);
|
||||
_ = RecordUsageAsync(foundKey.KeyId, foundKey.TenantId ?? "", scope, normalizedEndpoint);
|
||||
|
||||
await next(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -178,6 +192,35 @@ public class ApiKeyMiddleware : IFunctionsWorkerMiddleware
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RecordUsageAsync(string keyId, string tenantId, string scope, string endpoint)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _apiUsageRepo.IncrementUsageAsync(keyId, tenantId, scope, endpoint);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Non-fatal — don't block the request
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a request path for usage grouping by replacing GUIDs with {id}.
|
||||
/// E.g. "/api/sessions/abc-def-123/events" → "sessions/{id}/events"
|
||||
/// </summary>
|
||||
internal static string NormalizeEndpoint(string path)
|
||||
{
|
||||
// Strip /api/ prefix
|
||||
var normalized = path.StartsWith("/api/", StringComparison.OrdinalIgnoreCase)
|
||||
? path.Substring(5)
|
||||
: path.TrimStart('/');
|
||||
|
||||
// Replace GUIDs with {id}
|
||||
normalized = GuidPattern.Replace(normalized, "{id}");
|
||||
|
||||
return normalized.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string ComputeHash(string key)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
|
||||
@@ -112,6 +112,7 @@ builder.Services.AddTableStorageDataAccess();
|
||||
builder.Services.AddSingleton<TenantConfigurationService>();
|
||||
builder.Services.AddSingleton<AdminConfigurationService>();
|
||||
builder.Services.AddSingleton<RateLimitService>();
|
||||
builder.Services.AddSingleton<RateLimitConfigService>();
|
||||
builder.Services.AddSingleton<UsageMetricsService>();
|
||||
builder.Services.AddSingleton<PlatformMetricsService>();
|
||||
builder.Services.AddSingleton<GlobalAdminService>();
|
||||
|
||||
@@ -101,6 +101,8 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("POST", "feedback", EndpointPolicy.AuthenticatedUser),
|
||||
|
||||
// ── MemberRead (Admin + Operator, later + Viewer) ───────────────
|
||||
new("GET", "raw/sessions", EndpointPolicy.MemberRead),
|
||||
new("GET", "raw/events", EndpointPolicy.MemberRead),
|
||||
new("GET", "search/quick", EndpointPolicy.MemberRead),
|
||||
new("GET", "search/sessions", EndpointPolicy.MemberRead),
|
||||
new("GET", "search/sessions-by-event", EndpointPolicy.MemberRead),
|
||||
@@ -156,6 +158,11 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("DELETE", "bootstrap/sessions/{code}", EndpointPolicy.BootstrapManagerOrGA),
|
||||
|
||||
// ── GlobalAdminOnly ────────────────────────────────────────────
|
||||
new("GET", "global/raw/sessions", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "global/raw/events", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "global/raw/tables", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "global/raw/tables/{tableName}", EndpointPolicy.GlobalAdminOnly),
|
||||
new("POST", "global/raw/logs", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "global/search/sessions", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "global/search/sessions-by-event", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "global/search/sessions-by-cve", EndpointPolicy.GlobalAdminOnly),
|
||||
@@ -202,6 +209,11 @@ public static class EndpointAccessPolicyCatalog
|
||||
new("DELETE", "vulnerability/cpe-mapping", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "vulnerability/cpe-mappings", EndpointPolicy.GlobalAdminOnly),
|
||||
new("POST", "rules/ime-log-patterns/reseed", EndpointPolicy.GlobalAdminOnly),
|
||||
new("PATCH", "api-keys/{keyId}/rate-limit", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "api-keys/{keyId}/usage", EndpointPolicy.GlobalAdminOnly),
|
||||
new("PATCH", "config/{tenantId}/plan", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "global/api-usage", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "global/api-usage/daily", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "feedback/all", EndpointPolicy.GlobalAdminOnly),
|
||||
new("GET", "global/notifications", EndpointPolicy.GlobalAdminOnly),
|
||||
new("POST", "global/notifications/dismiss-all", EndpointPolicy.GlobalAdminOnly),
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Text.Json;
|
||||
using AutopilotMonitor.Shared.DataAccess;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace AutopilotMonitor.Functions.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the effective API rate limit for a given API key by checking (in priority order):
|
||||
/// 1. Per-key override (ApiKeyEntry.CustomRateLimitPerMinute)
|
||||
/// 2. Per-tenant override (TenantConfiguration.CustomRateLimitRequestsPerMinute)
|
||||
/// 3. Plan tier default from AdminConfiguration.PlanTierDefinitionsJson
|
||||
/// 4. Hardcoded fallback: 60 for tenant-scoped, 120 for global-scoped
|
||||
/// </summary>
|
||||
public class RateLimitConfigService
|
||||
{
|
||||
private readonly IConfigRepository _configRepo;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly ILogger<RateLimitConfigService> _logger;
|
||||
|
||||
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
|
||||
private const string AdminConfigCacheKey = "RateLimitConfig_AdminConfig";
|
||||
|
||||
public RateLimitConfigService(
|
||||
IConfigRepository configRepo,
|
||||
IMemoryCache cache,
|
||||
ILogger<RateLimitConfigService> logger)
|
||||
{
|
||||
_configRepo = configRepo;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective rate limit (requests per minute) for the given API key.
|
||||
/// </summary>
|
||||
public async Task<int> ResolveRateLimitAsync(ApiKeyEntry key)
|
||||
{
|
||||
// 1. Per-key override
|
||||
if (key.CustomRateLimitPerMinute.HasValue && key.CustomRateLimitPerMinute.Value > 0)
|
||||
return key.CustomRateLimitPerMinute.Value;
|
||||
|
||||
var scope = key.Scope ?? "tenant";
|
||||
|
||||
// 2. Per-tenant override (only for tenant-scoped keys)
|
||||
if (scope != "global" && !string.IsNullOrEmpty(key.TenantId))
|
||||
{
|
||||
try
|
||||
{
|
||||
var tenantConfig = await GetCachedTenantConfigAsync(key.TenantId);
|
||||
if (tenantConfig?.CustomRateLimitRequestsPerMinute is > 0)
|
||||
return tenantConfig.CustomRateLimitRequestsPerMinute.Value;
|
||||
|
||||
// 3. Plan tier default
|
||||
var planTier = tenantConfig?.PlanTier ?? "free";
|
||||
var planLimit = await GetPlanTierRateLimitAsync(planTier);
|
||||
if (planLimit.HasValue)
|
||||
return planLimit.Value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to resolve tenant config for rate limit, using fallback");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Hardcoded fallback
|
||||
return scope == "global" ? 120 : 60;
|
||||
}
|
||||
|
||||
private async Task<AutopilotMonitor.Shared.Models.TenantConfiguration?> GetCachedTenantConfigAsync(string tenantId)
|
||||
{
|
||||
var cacheKey = $"RateLimitConfig_Tenant_{tenantId}";
|
||||
if (_cache.TryGetValue(cacheKey, out AutopilotMonitor.Shared.Models.TenantConfiguration? cached))
|
||||
return cached;
|
||||
|
||||
var config = await _configRepo.GetTenantConfigurationAsync(tenantId);
|
||||
if (config != null)
|
||||
_cache.Set(cacheKey, config, CacheDuration);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
private async Task<int?> GetPlanTierRateLimitAsync(string planTier)
|
||||
{
|
||||
try
|
||||
{
|
||||
var adminConfig = await GetCachedAdminConfigAsync();
|
||||
if (string.IsNullOrEmpty(adminConfig?.PlanTierDefinitionsJson))
|
||||
return null;
|
||||
|
||||
var definitions = JsonSerializer.Deserialize<Dictionary<string, PlanTierDefinition>>(
|
||||
adminConfig.PlanTierDefinitionsJson,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
||||
|
||||
if (definitions != null && definitions.TryGetValue(planTier.ToLowerInvariant(), out var def))
|
||||
return def.ApiRateLimit > 0 ? def.ApiRateLimit : null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to parse plan tier definitions");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<AutopilotMonitor.Shared.Models.AdminConfiguration?> GetCachedAdminConfigAsync()
|
||||
{
|
||||
if (_cache.TryGetValue(AdminConfigCacheKey, out AutopilotMonitor.Shared.Models.AdminConfiguration? cached))
|
||||
return cached;
|
||||
|
||||
var config = await _configRepo.GetAdminConfigurationAsync();
|
||||
if (config != null)
|
||||
_cache.Set(AdminConfigCacheKey, config, CacheDuration);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
private class PlanTierDefinition
|
||||
{
|
||||
public int ApiRateLimit { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -329,4 +329,172 @@ export function registerTools(server: McpServer, knowledgeBase?: SearchProvider)
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// Tool 10: get_api_usage
|
||||
server.tool(
|
||||
'get_api_usage',
|
||||
'Get API usage statistics for MCP/API keys. Shows request counts per endpoint, per day. ' +
|
||||
'Use to monitor how much the API is being used. Requires a global-scoped API key.',
|
||||
{
|
||||
keyId: z.string().optional().describe('Specific API key ID to query usage for'),
|
||||
tenantId: z.string().optional().describe('Filter usage by tenant ID'),
|
||||
dateFrom: z.string().optional().describe('Start date (YYYY-MM-DD)'),
|
||||
dateTo: z.string().optional().describe('End date (YYYY-MM-DD)'),
|
||||
daily: z.boolean().optional().default(false).describe('Return daily aggregated summary instead of per-endpoint breakdown'),
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
let data: unknown;
|
||||
if (args.keyId) {
|
||||
const params: Record<string, string | undefined> = { dateFrom: args.dateFrom, dateTo: args.dateTo };
|
||||
data = await apiFetch(`/api/api-keys/${args.keyId}/usage${buildQuery(params)}`);
|
||||
} else if (args.daily) {
|
||||
const params: Record<string, string | undefined> = { tenantId: args.tenantId, dateFrom: args.dateFrom, dateTo: args.dateTo };
|
||||
data = await apiFetch(`/api/global/api-usage/daily${buildQuery(params)}`);
|
||||
} else {
|
||||
const params: Record<string, string | undefined> = { tenantId: args.tenantId, dateFrom: args.dateFrom, dateTo: args.dateTo };
|
||||
data = await apiFetch(`/api/global/api-usage${buildQuery(params)}`);
|
||||
}
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes('403')) {
|
||||
return { content: [{ type: 'text' as const, text: 'Access denied. This tool requires a global-scoped API key.' }] };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ── Phase 3.1: Raw Data Tools (Tier 1 — customer-facing) ──────────
|
||||
|
||||
// Tool 11: query_raw_events
|
||||
server.tool(
|
||||
'query_raw_events',
|
||||
'Query raw enrollment events with flexible filters. Unlike get_session_events, this can query across sessions within a tenant. ' +
|
||||
'Provide sessionId for single-session raw events, or eventType for cross-session event search. ' +
|
||||
'Returns raw event data for custom analysis.',
|
||||
{
|
||||
tenantId: z.string().optional().describe('Tenant ID (global-scoped key: required; tenant-scoped key: ignored)'),
|
||||
sessionId: z.string().optional().describe('Filter to a specific session'),
|
||||
eventType: z.string().optional().describe('Event type filter (e.g. "app_install_failed", "error_detected")'),
|
||||
severity: z.enum(['Info', 'Warning', 'Error', 'Critical']).optional(),
|
||||
source: z.string().optional().describe('Filter by event source/app name (substring match)'),
|
||||
startedAfter: z.string().optional().describe('ISO 8601 datetime — only events after this'),
|
||||
startedBefore: z.string().optional().describe('ISO 8601 datetime — only events before this'),
|
||||
limit: z.number().min(1).max(500).optional().default(100),
|
||||
},
|
||||
async (args) => {
|
||||
const { tenantId, ...rest } = args;
|
||||
const isGlobal = !!tenantId;
|
||||
const basePath = isGlobal ? '/api/global/raw/events' : '/api/raw/events';
|
||||
const params: Record<string, string | number | boolean | undefined | null> = { ...rest };
|
||||
if (isGlobal) params.tenantId = tenantId;
|
||||
const data = await apiFetch(`${basePath}${buildQuery(params)}`);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
);
|
||||
|
||||
// Tool 12: query_raw_sessions
|
||||
server.tool(
|
||||
'query_raw_sessions',
|
||||
'Query raw session data with flexible filters and field projection. Returns complete session entities without search abstraction. ' +
|
||||
'Use the fields parameter to select specific properties (e.g. "sessionId,status,startedAt,serialNumber").',
|
||||
{
|
||||
tenantId: z.string().optional().describe('Tenant ID (global-scoped key: required; tenant-scoped key: ignored)'),
|
||||
status: z.enum(['InProgress', 'Succeeded', 'Failed']).optional(),
|
||||
startedAfter: z.string().optional().describe('ISO 8601 datetime'),
|
||||
startedBefore: z.string().optional().describe('ISO 8601 datetime'),
|
||||
serialNumber: z.string().optional(),
|
||||
fields: z.string().optional().describe('Comma-separated fields to return (e.g. "sessionId,status,startedAt,serialNumber,durationSeconds")'),
|
||||
limit: z.number().min(1).max(200).optional().default(50),
|
||||
},
|
||||
async (args) => {
|
||||
const { tenantId, ...rest } = args;
|
||||
const isGlobal = !!tenantId;
|
||||
const basePath = isGlobal ? '/api/global/raw/sessions' : '/api/raw/sessions';
|
||||
const params: Record<string, string | number | boolean | undefined | null> = { ...rest };
|
||||
if (isGlobal) params.tenantId = tenantId;
|
||||
const data = await apiFetch(`${basePath}${buildQuery(params)}`);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
);
|
||||
|
||||
// ── Phase 3.2: Admin Diagnostic Tools (Tier 2 — internal) ──────────
|
||||
|
||||
// Tool 13: list_tables
|
||||
server.tool(
|
||||
'list_tables',
|
||||
'List all available Azure Table Storage tables that can be queried via query_table. ' +
|
||||
'Requires a global-scoped API key.',
|
||||
{},
|
||||
async () => {
|
||||
try {
|
||||
const data = await apiFetch('/api/global/raw/tables');
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes('403')) {
|
||||
return { content: [{ type: 'text' as const, text: 'Access denied. This tool requires a global-scoped API key.' }] };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Tool 14: query_table
|
||||
server.tool(
|
||||
'query_table',
|
||||
'Query any Azure Table Storage table directly (admin only). Returns raw table entities. ' +
|
||||
'Use list_tables to see available tables. Requires a global-scoped API key.',
|
||||
{
|
||||
tableName: z.string().describe('Table name (e.g. "Sessions", "Events", "RuleResults", "TenantConfiguration")'),
|
||||
partitionKey: z.string().optional().describe('Filter by exact partition key (usually TenantId)'),
|
||||
rowKeyPrefix: z.string().optional().describe('Filter by row key prefix'),
|
||||
filter: z.string().optional().describe('OData filter expression (e.g. "Status eq \'Failed\'")'),
|
||||
limit: z.number().min(1).max(500).optional().default(100),
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const { tableName, ...rest } = args;
|
||||
const data = await apiFetch(`/api/global/raw/tables/${encodeURIComponent(tableName)}${buildQuery(rest)}`);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes('403')) {
|
||||
return { content: [{ type: 'text' as const, text: 'Access denied. This tool requires a global-scoped API key.' }] };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Tool 15: query_backend_logs
|
||||
server.tool(
|
||||
'query_backend_logs',
|
||||
'Query backend Application Insights logs using KQL (admin only). Use for debugging backend issues, ' +
|
||||
'tracing requests by correlation ID, and platform diagnostics. Requires a global-scoped API key.',
|
||||
{
|
||||
query: z.string().describe('KQL query (e.g. "traces | where message contains \'error\' | take 50")'),
|
||||
timespan: z.string().optional().default('PT1H').describe('ISO 8601 duration (default: PT1H = last 1 hour). Examples: PT30M, PT6H, P1D'),
|
||||
},
|
||||
async (args) => {
|
||||
try {
|
||||
const data = await apiFetch('/api/global/raw/logs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query: args.query, timespan: args.timespan }),
|
||||
});
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes('403')) {
|
||||
return { content: [{ type: 'text' as const, text: 'Access denied. This tool requires a global-scoped API key.' }] };
|
||||
}
|
||||
if (message.includes('503')) {
|
||||
return { content: [{ type: 'text' as const, text: 'Application Insights diagnostics is not configured. Set APPINSIGHTS_APP_ID and assign Monitoring Reader role to the Function App Managed Identity.' }] };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -275,6 +275,9 @@ namespace AutopilotMonitor.Shared
|
||||
public const string CveIndex = "CveIndex";
|
||||
public const string ApiKeys = "ApiKeys";
|
||||
|
||||
// API usage tracking
|
||||
public const string ApiUsageLog = "ApiUsageLog";
|
||||
|
||||
/// <summary>
|
||||
/// Returns all table names for initialization
|
||||
/// </summary>
|
||||
@@ -310,7 +313,8 @@ namespace AutopilotMonitor.Shared
|
||||
EventTypeIndex,
|
||||
DeviceSnapshot,
|
||||
CveIndex,
|
||||
ApiKeys
|
||||
ApiKeys,
|
||||
ApiUsageLog
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,5 +69,7 @@ namespace AutopilotMonitor.Shared.DataAccess
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public long RequestCount { get; set; }
|
||||
/// <summary>Per-key rate limit override. Null = use tenant/plan default.</summary>
|
||||
public int? CustomRateLimitPerMinute { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AutopilotMonitor.Shared.DataAccess
|
||||
{
|
||||
/// <summary>
|
||||
/// Repository for tracking API key usage over time.
|
||||
/// Stores per-key, per-day, per-endpoint request counts.
|
||||
/// </summary>
|
||||
public interface IApiUsageRepository
|
||||
{
|
||||
/// <summary>
|
||||
/// Increments the usage counter for a given key/endpoint/day combination.
|
||||
/// Uses optimistic concurrency with retry on conflict.
|
||||
/// </summary>
|
||||
Task IncrementUsageAsync(string keyId, string tenantId, string scope, string endpoint);
|
||||
|
||||
/// <summary>
|
||||
/// Gets usage records for a specific API key within an optional date range.
|
||||
/// </summary>
|
||||
Task<List<ApiUsageRecord>> GetUsageByKeyAsync(string keyId, string? dateFrom = null, string? dateTo = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets usage records for all keys belonging to a tenant within an optional date range.
|
||||
/// </summary>
|
||||
Task<List<ApiUsageRecord>> GetUsageByTenantAsync(string tenantId, string? dateFrom = null, string? dateTo = null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets aggregated daily usage summaries, optionally filtered by tenant.
|
||||
/// </summary>
|
||||
Task<List<ApiUsageDailySummary>> GetDailySummaryAsync(string? tenantId = null, string? dateFrom = null, string? dateTo = null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single usage record: one API key, one day, one endpoint.
|
||||
/// </summary>
|
||||
public class ApiUsageRecord
|
||||
{
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
public string TenantId { get; set; } = string.Empty;
|
||||
public string Scope { get; set; } = string.Empty;
|
||||
public string Endpoint { get; set; } = string.Empty;
|
||||
public string Date { get; set; } = string.Empty;
|
||||
public long RequestCount { get; set; }
|
||||
public DateTime LastRequestAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aggregated daily usage summary across endpoints (and optionally keys).
|
||||
/// </summary>
|
||||
public class ApiUsageDailySummary
|
||||
{
|
||||
public string Date { get; set; } = string.Empty;
|
||||
public string? TenantId { get; set; }
|
||||
public long TotalRequests { get; set; }
|
||||
public int UniqueKeys { get; set; }
|
||||
public int UniqueEndpoints { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,12 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public int GlobalRateLimitRequestsPerMinute { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// JSON-serialized plan tier definitions mapping tier name to rate limits and features.
|
||||
/// Example: {"free":{"apiRateLimit":60},"pro":{"apiRateLimit":300},"enterprise":{"apiRateLimit":1000}}
|
||||
/// </summary>
|
||||
public string? PlanTierDefinitionsJson { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Container SAS URL used by maintenance to publish platform stats JSON files.
|
||||
/// Expected format: https://{account}.blob.core.windows.net/{container}?sv=...&sig=...
|
||||
|
||||
@@ -78,6 +78,13 @@ namespace AutopilotMonitor.Shared.Models
|
||||
/// </summary>
|
||||
public int? CustomRateLimitRequestsPerMinute { get; set; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Tenant plan tier. Determines default API rate limits and feature gates.
|
||||
/// Values: "free", "pro", "enterprise". Default: "free".
|
||||
/// Managed by Global Admins.
|
||||
/// </summary>
|
||||
public string PlanTier { get; set; } = "free";
|
||||
|
||||
/// <summary>
|
||||
/// Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*")
|
||||
/// Comma-separated list
|
||||
|
||||
Reference in New Issue
Block a user