added MCP capabilities

This commit is contained in:
Oliver Kieselbach
2026-03-28 04:20:50 +01:00
parent 802e59799c
commit a6f552a877
21 changed files with 1515 additions and 5 deletions
@@ -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,
};
}
}
@@ -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