diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 775971ea..39adda3c 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -648 +656 diff --git a/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj b/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj index f3a951f4..11d5d4ad 100644 --- a/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj +++ b/src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj @@ -10,6 +10,7 @@ + diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs index f2bef573..8fd8e1a9 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/DataAccessServiceExtensions.cs @@ -33,6 +33,7 @@ namespace AutopilotMonitor.Functions.DataAccess services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs index a6f93873..2557ffef 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableAdminRepository.cs @@ -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, }; } } diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableApiUsageRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableApiUsageRepository.cs new file mode 100644 index 00000000..8313538e --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableApiUsageRepository.cs @@ -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 +{ + /// + /// Table Storage implementation of IApiUsageRepository. + /// PartitionKey: keyId, RowKey: {yyyyMMdd}_{normalizedEndpoint} + /// + public class TableApiUsageRepository : IApiUsageRepository + { + private readonly TableClient _tableClient; + private readonly ILogger _logger; + + public TableApiUsageRepository( + TableStorageService storage, + ILogger 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(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> GetUsageByKeyAsync(string keyId, string? dateFrom = null, string? dateTo = null) + { + var filter = $"PartitionKey eq '{keyId}'"; + filter = AppendDateFilter(filter, dateFrom, dateTo); + + var records = new List(); + await foreach (var entity in _tableClient.QueryAsync(filter: filter)) + { + records.Add(MapToRecord(entity)); + } + return records; + } + + public async Task> GetUsageByTenantAsync(string tenantId, string? dateFrom = null, string? dateTo = null) + { + var filter = $"TenantId eq '{tenantId}'"; + filter = AppendDateFilter(filter, dateFrom, dateTo); + + var records = new List(); + await foreach (var entity in _tableClient.QueryAsync(filter: filter)) + { + records.Add(MapToRecord(entity)); + } + return records; + } + + public async Task> 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(); + await foreach (var entity in _tableClient.QueryAsync(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, + }; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ApiUsageFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ApiUsageFunction.cs new file mode 100644 index 00000000..1d3f1107 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ApiUsageFunction.cs @@ -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 _logger; + private readonly IApiUsageRepository _usageRepo; + + public ApiUsageFunction(ILogger logger, IApiUsageRepository usageRepo) + { + _logger = logger; + _usageRepo = usageRepo; + } + + /// + /// GET /api/api-keys/{keyId}/usage?dateFrom=&dateTo= + /// Returns usage records for a specific API key. + /// + [Function("GetApiKeyUsage")] + public async Task 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; + } + } + + /// + /// GET /api/global/api-usage?tenantId=&dateFrom=&dateTo= + /// Returns usage records across keys, optionally filtered by tenant. + /// + [Function("GetGlobalApiUsage")] + public async Task 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 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; + } + } + + /// + /// GET /api/global/api-usage/daily?tenantId=&dateFrom=&dateTo= + /// Returns aggregated daily usage summaries. + /// + [Function("GetGlobalApiUsageDaily")] + public async Task 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; + } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs new file mode 100644 index 00000000..9a26431d --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/PlanManagementFunction.cs @@ -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 _logger; + private readonly IConfigRepository _configRepo; + private readonly IAdminRepository _adminRepo; + + public PlanManagementFunction( + ILogger logger, + IConfigRepository configRepo, + IAdminRepository adminRepo) + { + _logger = logger; + _configRepo = configRepo; + _adminRepo = adminRepo; + } + + /// + /// PATCH /api/config/{tenantId}/plan + /// Sets the plan tier for a tenant. Body: { "planTier": "pro" } + /// + [Function("SetTenantPlanTier")] + public async Task 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(); + 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; + } + } + + /// + /// 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. + /// + [Function("SetApiKeyRateLimit")] + public async Task 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(); + 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; } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/AppInsightsQueryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/AppInsightsQueryFunction.cs new file mode 100644 index 00000000..0fc84f17 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/AppInsightsQueryFunction.cs @@ -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 _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 logger) + { + _logger = logger; + } + + /// + /// 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. + /// + [Function("QueryBackendLogs")] + public async Task 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(); + 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; } + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs new file mode 100644 index 00000000..4c921730 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawEventsFunction.cs @@ -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 _logger; + private readonly ISessionRepository _sessionRepo; + + public QueryRawEventsFunction(ILogger logger, ISessionRepository sessionRepo) + { + _logger = logger; + _sessionRepo = sessionRepo; + } + + /// + /// GET /api/raw/events — Tenant-scoped raw event query (cross-session) + /// + [Function("QueryRawEvents")] + public async Task 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; + } + } + + /// + /// GET /api/global/raw/events — Cross-tenant raw event query (GlobalAdminOnly) + /// + [Function("QueryRawEventsGlobal")] + public async Task 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 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 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(); + 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; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs new file mode 100644 index 00000000..e7e81706 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/QueryRawSessionsFunction.cs @@ -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 _logger; + private readonly ISessionRepository _sessionRepo; + + public QueryRawSessionsFunction(ILogger logger, ISessionRepository sessionRepo) + { + _logger = logger; + _sessionRepo = sessionRepo; + } + + /// + /// GET /api/raw/sessions — Tenant-scoped raw session query + /// + [Function("QueryRawSessions")] + public async Task 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; + } + } + + /// + /// GET /api/global/raw/sessions — Cross-tenant raw session query (GlobalAdminOnly) + /// + [Function("QueryRawSessionsGlobal")] + public async Task 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 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( + 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 ProjectFields(SessionSummary session, HashSet fields) + { + var dict = new Dictionary(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; + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Raw/TableQueryFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/TableQueryFunction.cs new file mode 100644 index 00000000..d526432b --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Raw/TableQueryFunction.cs @@ -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 _logger; + private readonly TableStorageService _storage; + + // Tables that must never be exposed (contain secrets) + private static readonly HashSet _blacklistedTables = new(StringComparer.OrdinalIgnoreCase) + { + Constants.TableNames.ApiKeys + }; + + public TableQueryFunction(ILogger logger, TableStorageService storage) + { + _logger = logger; + _storage = storage; + } + + /// + /// GET /api/global/raw/tables — List all available table names + /// + [Function("ListRawTables")] + public async Task 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; + } + } + + /// + /// GET /api/global/raw/tables/{tableName} — Query any table directly + /// Query params: partitionKey, rowKeyPrefix, filter, limit + /// + [Function("QueryRawTable")] + public async Task 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>(); + + await foreach (var entity in tableClient.QueryAsync(filter: oDataFilter)) + { + var dict = new Dictionary + { + ["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(); + + 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("'", "''"); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/ApiKeyMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/ApiKeyMiddleware.cs index d6df2e09..2c5bc2ed 100644 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/ApiKeyMiddleware.cs +++ b/src/Backend/AutopilotMonitor.Functions/Middleware/ApiKeyMiddleware.cs @@ -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 _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 logger, IAdminRepository adminRepo, RateLimitService rateLimitService) + public ApiKeyMiddleware(ILogger 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 + } + } + + /// + /// Normalizes a request path for usage grouping by replacing GUIDs with {id}. + /// E.g. "/api/sessions/abc-def-123/events" → "sessions/{id}/events" + /// + 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(); diff --git a/src/Backend/AutopilotMonitor.Functions/Program.cs b/src/Backend/AutopilotMonitor.Functions/Program.cs index e2893b61..4c260e32 100644 --- a/src/Backend/AutopilotMonitor.Functions/Program.cs +++ b/src/Backend/AutopilotMonitor.Functions/Program.cs @@ -112,6 +112,7 @@ builder.Services.AddTableStorageDataAccess(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs index 983f72f2..34b5d743 100644 --- a/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs +++ b/src/Backend/AutopilotMonitor.Functions/Security/EndpointAccessPolicyCatalog.cs @@ -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), diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RateLimitConfigService.cs b/src/Backend/AutopilotMonitor.Functions/Services/RateLimitConfigService.cs new file mode 100644 index 00000000..5501681f --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions/Services/RateLimitConfigService.cs @@ -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 +{ + /// + /// 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 + /// + public class RateLimitConfigService + { + private readonly IConfigRepository _configRepo; + private readonly IMemoryCache _cache; + private readonly ILogger _logger; + + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); + private const string AdminConfigCacheKey = "RateLimitConfig_AdminConfig"; + + public RateLimitConfigService( + IConfigRepository configRepo, + IMemoryCache cache, + ILogger logger) + { + _configRepo = configRepo; + _cache = cache; + _logger = logger; + } + + /// + /// Resolves the effective rate limit (requests per minute) for the given API key. + /// + public async Task 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 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 GetPlanTierRateLimitAsync(string planTier) + { + try + { + var adminConfig = await GetCachedAdminConfigAsync(); + if (string.IsNullOrEmpty(adminConfig?.PlanTierDefinitionsJson)) + return null; + + var definitions = JsonSerializer.Deserialize>( + 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 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; } + } + } +} diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools.ts b/src/McpServer/autopilot-monitor-mcp/src/tools.ts index 5f89a1ef..bdb16e13 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/tools.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/tools.ts @@ -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 = { dateFrom: args.dateFrom, dateTo: args.dateTo }; + data = await apiFetch(`/api/api-keys/${args.keyId}/usage${buildQuery(params)}`); + } else if (args.daily) { + const params: Record = { tenantId: args.tenantId, dateFrom: args.dateFrom, dateTo: args.dateTo }; + data = await apiFetch(`/api/global/api-usage/daily${buildQuery(params)}`); + } else { + const params: Record = { 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 = { ...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 = { ...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; + } + } + ); } diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index b9ee48e2..69ea2335 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -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"; + /// /// Returns all table names for initialization /// @@ -310,7 +313,8 @@ namespace AutopilotMonitor.Shared EventTypeIndex, DeviceSnapshot, CveIndex, - ApiKeys + ApiKeys, + ApiUsageLog }; } } diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs index 82f93d79..e96f1db2 100644 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IAdminRepository.cs @@ -69,5 +69,7 @@ namespace AutopilotMonitor.Shared.DataAccess public DateTime? ExpiresAt { get; set; } public bool IsActive { get; set; } = true; public long RequestCount { get; set; } + /// Per-key rate limit override. Null = use tenant/plan default. + public int? CustomRateLimitPerMinute { get; set; } } } diff --git a/src/Shared/AutopilotMonitor.Shared/DataAccess/IApiUsageRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IApiUsageRepository.cs new file mode 100644 index 00000000..bbb5495c --- /dev/null +++ b/src/Shared/AutopilotMonitor.Shared/DataAccess/IApiUsageRepository.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace AutopilotMonitor.Shared.DataAccess +{ + /// + /// Repository for tracking API key usage over time. + /// Stores per-key, per-day, per-endpoint request counts. + /// + public interface IApiUsageRepository + { + /// + /// Increments the usage counter for a given key/endpoint/day combination. + /// Uses optimistic concurrency with retry on conflict. + /// + Task IncrementUsageAsync(string keyId, string tenantId, string scope, string endpoint); + + /// + /// Gets usage records for a specific API key within an optional date range. + /// + Task> GetUsageByKeyAsync(string keyId, string? dateFrom = null, string? dateTo = null); + + /// + /// Gets usage records for all keys belonging to a tenant within an optional date range. + /// + Task> GetUsageByTenantAsync(string tenantId, string? dateFrom = null, string? dateTo = null); + + /// + /// Gets aggregated daily usage summaries, optionally filtered by tenant. + /// + Task> GetDailySummaryAsync(string? tenantId = null, string? dateFrom = null, string? dateTo = null); + } + + /// + /// A single usage record: one API key, one day, one endpoint. + /// + 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; } + } + + /// + /// Aggregated daily usage summary across endpoints (and optionally keys). + /// + 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; } + } +} diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs index 7c499509..fd359d01 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs @@ -41,6 +41,12 @@ namespace AutopilotMonitor.Shared.Models /// public int GlobalRateLimitRequestsPerMinute { get; set; } = 100; + /// + /// JSON-serialized plan tier definitions mapping tier name to rate limits and features. + /// Example: {"free":{"apiRateLimit":60},"pro":{"apiRateLimit":300},"enterprise":{"apiRateLimit":1000}} + /// + public string? PlanTierDefinitionsJson { get; set; } + /// /// Container SAS URL used by maintenance to publish platform stats JSON files. /// Expected format: https://{account}.blob.core.windows.net/{container}?sv=...&sig=... diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs index 5bdc1991..05c40ae9 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/TenantConfiguration.cs @@ -78,6 +78,13 @@ namespace AutopilotMonitor.Shared.Models /// public int? CustomRateLimitRequestsPerMinute { get; set; } = null; + /// + /// Tenant plan tier. Determines default API rate limits and feature gates. + /// Values: "free", "pro", "enterprise". Default: "free". + /// Managed by Global Admins. + /// + public string PlanTier { get; set; } = "free"; + /// /// Hardware whitelist: Allowed manufacturers (supports wildcards like "Dell*") /// Comma-separated list