From be5a37e7bb2985532d23cfaf8bf28354a2ddb638 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sat, 28 Mar 2026 06:15:08 +0100 Subject: [PATCH] oauth for mcp server --- .../TableStorage/TableApiUsageRepository.cs | 171 ---------- .../Functions/Admin/ApiUsageFunction.cs | 156 --------- .../Auth/ApiKeyManagementFunction.cs | 256 --------------- .../Middleware/ApiKeyMiddleware.cs | 230 ------------- .../Services/RateLimitConfigService.cs | 123 ------- .../DataAccess/IApiUsageRepository.cs | 60 ---- .../app/admin/components/ApiKeysSection.tsx | 1 - .../security/sections/SectionApiKeys.tsx | 21 -- .../access/sections/SectionApiKeys.tsx | 20 -- .../settings/components/ApiKeysSection.tsx | 310 ------------------ 10 files changed, 1348 deletions(-) delete mode 100644 src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableApiUsageRepository.cs delete mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Admin/ApiUsageFunction.cs delete mode 100644 src/Backend/AutopilotMonitor.Functions/Functions/Auth/ApiKeyManagementFunction.cs delete mode 100644 src/Backend/AutopilotMonitor.Functions/Middleware/ApiKeyMiddleware.cs delete mode 100644 src/Backend/AutopilotMonitor.Functions/Services/RateLimitConfigService.cs delete mode 100644 src/Shared/AutopilotMonitor.Shared/DataAccess/IApiUsageRepository.cs delete mode 100644 src/Web/autopilot-monitor-web/app/admin/components/ApiKeysSection.tsx delete mode 100644 src/Web/autopilot-monitor-web/app/admin/security/sections/SectionApiKeys.tsx delete mode 100644 src/Web/autopilot-monitor-web/app/settings/access/sections/SectionApiKeys.tsx delete mode 100644 src/Web/autopilot-monitor-web/app/settings/components/ApiKeysSection.tsx diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableApiUsageRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableApiUsageRepository.cs deleted file mode 100644 index 8313538e..00000000 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableApiUsageRepository.cs +++ /dev/null @@ -1,171 +0,0 @@ -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 deleted file mode 100644 index 1d3f1107..00000000 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Admin/ApiUsageFunction.cs +++ /dev/null @@ -1,156 +0,0 @@ -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/Auth/ApiKeyManagementFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Auth/ApiKeyManagementFunction.cs deleted file mode 100644 index 4b3565c1..00000000 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Auth/ApiKeyManagementFunction.cs +++ /dev/null @@ -1,256 +0,0 @@ -using System.Net; -using System.Security.Cryptography; -using System.Text; -using AutopilotMonitor.Functions.Helpers; -using AutopilotMonitor.Functions.Services; -using AutopilotMonitor.Shared.DataAccess; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Http; -using Microsoft.Extensions.Logging; -using Newtonsoft.Json.Linq; - -namespace AutopilotMonitor.Functions.Functions.Auth; - -public class ApiKeyManagementFunction -{ - private readonly ILogger _logger; - private readonly IAdminRepository _adminRepo; - private readonly GlobalAdminService _globalAdminService; - - public ApiKeyManagementFunction( - ILogger logger, - IAdminRepository adminRepo, - GlobalAdminService globalAdminService) - { - _logger = logger; - _adminRepo = adminRepo; - _globalAdminService = globalAdminService; - } - - /// GET /api/api-keys — list API keys for the calling tenant - [Function("ListApiKeys")] - public async Task List( - [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "api-keys")] HttpRequestData req) - { - try - { - var tenantId = TenantHelper.GetTenantId(req); - var upn = TenantHelper.GetUserIdentifier(req); - var query = System.Web.HttpUtility.ParseQueryString(req.Url.Query); - - // GA can query other tenants - string targetTenantId = tenantId; - if (await _globalAdminService.IsGlobalAdminAsync(upn)) - { - var overrideTenant = query["tenantId"]; - if (!string.IsNullOrEmpty(overrideTenant)) - targetTenantId = overrideTenant; - } - - var entries = await _adminRepo.GetApiKeysAsync(targetTenantId); - var keys = entries.Select(MapKeyEntry).ToList(); - - var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new { success = true, count = keys.Count, keys }); - return response; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error listing API keys"); - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteAsJsonAsync(new { success = false, message = "Internal server error" }); - return err; - } - } - - /// GET /api/global/api-keys — list all API keys (GlobalAdmin only) - [Function("ListApiKeysGlobal")] - public async Task ListGlobal( - [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/api-keys")] HttpRequestData req) - { - try - { - var entries = await _adminRepo.GetAllApiKeysAsync(); - var keys = entries.Select(MapKeyEntry).ToList(); - - var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new { success = true, count = keys.Count, keys }); - return response; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error listing all API keys"); - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteAsJsonAsync(new { success = false, message = "Internal server error" }); - return err; - } - } - - /// POST /api/api-keys — create a new API key - [Function("CreateApiKey")] - public async Task Create( - [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "api-keys")] HttpRequestData req) - { - try - { - var tenantId = TenantHelper.GetTenantId(req); - var upn = TenantHelper.GetUserIdentifier(req); - var isGA = await _globalAdminService.IsGlobalAdminAsync(upn); - - string body; - using (var reader = new System.IO.StreamReader(req.Body)) - body = await reader.ReadToEndAsync(); - - JObject json; - try { json = JObject.Parse(body); } - catch - { - var br = req.CreateResponse(HttpStatusCode.BadRequest); - await br.WriteAsJsonAsync(new { success = false, message = "Invalid JSON body" }); - return br; - } - - var label = json["label"]?.ToString() ?? "API Key"; - var scope = json["scope"]?.ToString() ?? "tenant"; - var expiresInDays = json["expiresInDays"]?.Value(); - - // Only global admins can create global-scoped keys - if (scope == "global" && !isGA) - { - var forbidden = req.CreateResponse(HttpStatusCode.Forbidden); - await forbidden.WriteAsJsonAsync(new { success = false, message = "Only Global Admins can create global-scoped API keys" }); - return forbidden; - } - - // Generate a 32-byte random key - var rawKeyBytes = RandomNumberGenerator.GetBytes(32); - var rawKey = Convert.ToHexString(rawKeyBytes).ToLowerInvariant(); - - // SHA-256 hash — only stored value - using var sha = SHA256.Create(); - var hashBytes = sha.ComputeHash(Encoding.UTF8.GetBytes(rawKey)); - var keyHash = Convert.ToHexString(hashBytes).ToLowerInvariant(); - - var keyId = Guid.NewGuid().ToString(); - var partitionKey = scope == "global" ? "GLOBAL" : tenantId; - var createdAt = DateTime.UtcNow; - DateTime? expiresAt = expiresInDays.HasValue - ? DateTime.UtcNow.AddDays(expiresInDays.Value) - : null; - - var entry = new ApiKeyEntry - { - KeyId = keyId, - TenantId = tenantId, - KeyHash = keyHash, - Name = label, - Scope = scope, - Upn = upn, - CreatedBy = upn, - CreatedAt = createdAt, - ExpiresAt = expiresAt, - IsActive = true, - RequestCount = 0L, - }; - - await _adminRepo.StoreApiKeyAsync(partitionKey, entry); - - _logger.LogInformation("API key created: scope={Scope}, tenantId={TenantId}, createdBy={CreatedBy}", scope, tenantId, upn); - - var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new - { - success = true, - keyId, - rawKey, // Only returned on creation — never again - scope, - label, - tenantId, - createdAt, - expiresAt - }); - return response; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error creating API key"); - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteAsJsonAsync(new { success = false, message = "Internal server error" }); - return err; - } - } - - /// DELETE /api/api-keys/{keyId} — revoke an API key - [Function("DeleteApiKey")] - public async Task Delete( - [HttpTrigger(AuthorizationLevel.Anonymous, "delete", Route = "api-keys/{keyId}")] HttpRequestData req, - string keyId) - { - try - { - var tenantId = TenantHelper.GetTenantId(req); - var upn = TenantHelper.GetUserIdentifier(req); - var isGA = await _globalAdminService.IsGlobalAdminAsync(upn); - - // Find the key by keyId. Try tenant PK first, then GLOBAL. - ApiKeyEntry? foundEntry = null; - string? foundPartitionKey = null; - foreach (var partitionKey in new[] { tenantId, "GLOBAL" }) - { - var entry = await _adminRepo.GetApiKeyAsync(partitionKey, keyId); - if (entry != null) - { - foundEntry = entry; - foundPartitionKey = partitionKey; - break; - } - } - - if (foundEntry == null) - { - var notFound = req.CreateResponse(HttpStatusCode.NotFound); - await notFound.WriteAsJsonAsync(new { success = false, message = "API key not found" }); - return notFound; - } - - // Ownership check: non-GA users can only delete their own tenant's keys - if (!isGA && !string.Equals(foundEntry.TenantId, tenantId, StringComparison.OrdinalIgnoreCase)) - { - var forbidden = req.CreateResponse(HttpStatusCode.Forbidden); - await forbidden.WriteAsJsonAsync(new { success = false, message = "Access denied" }); - return forbidden; - } - - await _adminRepo.RevokeApiKeyAsync(foundPartitionKey!, keyId); - - _logger.LogInformation("API key deleted: keyId={KeyId}, deletedBy={Upn}", keyId, upn); - - var response = req.CreateResponse(HttpStatusCode.OK); - await response.WriteAsJsonAsync(new { success = true }); - return response; - } - catch (Exception ex) - { - _logger.LogError(ex, "Error deleting API key {KeyId}", keyId); - var err = req.CreateResponse(HttpStatusCode.InternalServerError); - await err.WriteAsJsonAsync(new { success = false, message = "Internal server error" }); - return err; - } - } - - private static object MapKeyEntry(ApiKeyEntry entry) - { - return new - { - keyId = entry.KeyId, - scope = entry.Scope ?? "tenant", - tenantId = entry.TenantId, - label = entry.Name ?? "", - createdBy = entry.CreatedBy ?? "", - createdAt = entry.CreatedAt, - expiresAt = entry.ExpiresAt, - isActive = entry.IsActive, - requestCount = entry.RequestCount, - }; - } -} diff --git a/src/Backend/AutopilotMonitor.Functions/Middleware/ApiKeyMiddleware.cs b/src/Backend/AutopilotMonitor.Functions/Middleware/ApiKeyMiddleware.cs deleted file mode 100644 index 2c5bc2ed..00000000 --- a/src/Backend/AutopilotMonitor.Functions/Middleware/ApiKeyMiddleware.cs +++ /dev/null @@ -1,230 +0,0 @@ -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; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Middleware; -using Microsoft.Extensions.Logging; - -namespace AutopilotMonitor.Functions.Middleware; - -/// -/// Middleware that processes X-Api-Key header authentication before the JWT AuthenticationMiddleware. -/// When a valid API key is found, it builds a synthetic ClaimsPrincipal so downstream middleware -/// (PolicyEnforcementMiddleware) can evaluate route policies normally. -/// -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 = - { - "/api/agent/", - "/api/bootstrap/", - "/api/health", - "/api/stats/", - "/api/realtime/", - "/api/progress/", - }; - - 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) - { - var httpContext = context.GetHttpContext(); - if (httpContext == null) { await next(context); return; } - - var requestPath = httpContext.Request.Path.Value ?? string.Empty; - - // Skip if this is a bypass route (agent/bootstrap/health) - foreach (var prefix in _bypassPrefixes) - { - if (requestPath.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) - { - await next(context); return; - } - } - - // If request already has a Bearer token, let JWT auth handle it - var authHeader = httpContext.Request.Headers.Authorization.ToString(); - if (!string.IsNullOrEmpty(authHeader) && authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) - { - await next(context); return; - } - - // Check for X-Api-Key header - var apiKey = httpContext.Request.Headers["X-Api-Key"].FirstOrDefault(); - if (string.IsNullOrEmpty(apiKey)) - { - // No API key — let normal auth pipeline handle it - await next(context); return; - } - - // Hash the provided key and look it up - var keyHash = ComputeHash(apiKey); - - try - { - var foundKey = await _adminRepo.ValidateApiKeyAsync(keyHash); - - if (foundKey == null) - { - _logger.LogWarning("ApiKey: invalid key presented (hash not found)"); - httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized; - await httpContext.Response.WriteAsJsonAsync(new { error = "Invalid API key" }); - return; - } - - // Check IsActive - if (!foundKey.IsActive) - { - httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized; - await httpContext.Response.WriteAsJsonAsync(new { error = "API key is revoked" }); - return; - } - - // Check expiry - if (foundKey.ExpiresAt.HasValue && foundKey.ExpiresAt.Value < DateTime.UtcNow) - { - httpContext.Response.StatusCode = (int)HttpStatusCode.Unauthorized; - await httpContext.Response.WriteAsJsonAsync(new { error = "API key has expired" }); - return; - } - - var scope = foundKey.Scope ?? "tenant"; - var keyTenantId = foundKey.TenantId; - - // 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) - { - _logger.LogWarning("ApiKey rate limit exceeded: keyId={KeyId}", foundKey.KeyId); - httpContext.Response.StatusCode = 429; - httpContext.Response.ContentType = "application/json"; - if (rateLimitResult.RetryAfter.HasValue) - httpContext.Response.Headers.Append("Retry-After", ((int)rateLimitResult.RetryAfter.Value.TotalSeconds + 1).ToString()); - await httpContext.Response.WriteAsJsonAsync(new { error = "RateLimitExceeded", message = "Too many requests. Please slow down.", retryAfterSeconds = (int)(rateLimitResult.RetryAfter?.TotalSeconds ?? 60) + 1 }); - return; - } - - // Build synthetic ClaimsPrincipal for downstream policy evaluation - var claims = new List - { - new Claim("auth_method", "api_key"), - new Claim("key_id", foundKey.KeyId), - new Claim("key_scope", scope), - }; - - // Determine partition key for increment (matches storage layout) - var partitionKey = scope == "global" ? "GLOBAL" : keyTenantId; - - if (scope == "global") - { - // Global key: mark as GlobalAdmin for policy purposes - claims.Add(new Claim("is_global_admin", "true")); - claims.Add(new Claim("upn", $"apikey-global@{foundKey.KeyId}")); - context.Items["ApiKeyScope"] = "global"; - } - else - { - // Tenant key: inject tenantId so PolicyEnforcementMiddleware can scope correctly - claims.Add(new Claim("tid", keyTenantId ?? "")); - claims.Add(new Claim("upn", $"apikey-tenant@{keyTenantId}")); - context.Items["ApiKeyTenantId"] = keyTenantId ?? ""; - context.Items["ApiKeyScope"] = "tenant"; - } - - var identity = new ClaimsIdentity(claims, "ApiKey"); - var principal = new ClaimsPrincipal(identity); - httpContext.User = principal; - context.Items["ClaimsPrincipal"] = principal; - - _logger.LogDebug("ApiKey auth: scope={Scope}, tenantId={TenantId}, keyId={KeyId}", scope, keyTenantId, foundKey.KeyId); - - // 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) - { - _logger.LogError(ex, "Error processing API key"); - httpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError; - await httpContext.Response.WriteAsJsonAsync(new { error = "Internal server error" }); - } - } - - private async Task IncrementRequestCountAsync(string partitionKey, string keyId) - { - try - { - await _adminRepo.IncrementApiKeyRequestCountAsync(partitionKey, keyId); - } - catch - { - // Non-fatal — don't block the request - } - } - - 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(); - var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(key)); - return Convert.ToHexString(bytes).ToLowerInvariant(); - } -} diff --git a/src/Backend/AutopilotMonitor.Functions/Services/RateLimitConfigService.cs b/src/Backend/AutopilotMonitor.Functions/Services/RateLimitConfigService.cs deleted file mode 100644 index 5501681f..00000000 --- a/src/Backend/AutopilotMonitor.Functions/Services/RateLimitConfigService.cs +++ /dev/null @@ -1,123 +0,0 @@ -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/Shared/AutopilotMonitor.Shared/DataAccess/IApiUsageRepository.cs b/src/Shared/AutopilotMonitor.Shared/DataAccess/IApiUsageRepository.cs deleted file mode 100644 index bbb5495c..00000000 --- a/src/Shared/AutopilotMonitor.Shared/DataAccess/IApiUsageRepository.cs +++ /dev/null @@ -1,60 +0,0 @@ -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/Web/autopilot-monitor-web/app/admin/components/ApiKeysSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/ApiKeysSection.tsx deleted file mode 100644 index 5dd49b4a..00000000 --- a/src/Web/autopilot-monitor-web/app/admin/components/ApiKeysSection.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default } from "../../settings/components/ApiKeysSection"; diff --git a/src/Web/autopilot-monitor-web/app/admin/security/sections/SectionApiKeys.tsx b/src/Web/autopilot-monitor-web/app/admin/security/sections/SectionApiKeys.tsx deleted file mode 100644 index 7e20540c..00000000 --- a/src/Web/autopilot-monitor-web/app/admin/security/sections/SectionApiKeys.tsx +++ /dev/null @@ -1,21 +0,0 @@ -"use client"; - -import { useAdminConfig } from "../../AdminConfigContext"; -import { AdminNotifications } from "../../AdminNotifications"; -import ApiKeysSection from "../../components/ApiKeysSection"; - -export function SectionApiKeys() { - const { getAccessToken, setError, setSuccessMessage, tenants } = useAdminConfig(); - return ( - <> - - ({ tenantId: t.tenantId, displayName: t.domainName ?? t.tenantId }))} - /> - - ); -} diff --git a/src/Web/autopilot-monitor-web/app/settings/access/sections/SectionApiKeys.tsx b/src/Web/autopilot-monitor-web/app/settings/access/sections/SectionApiKeys.tsx deleted file mode 100644 index f9553002..00000000 --- a/src/Web/autopilot-monitor-web/app/settings/access/sections/SectionApiKeys.tsx +++ /dev/null @@ -1,20 +0,0 @@ -"use client"; - -import { useTenantConfig } from "../../TenantConfigContext"; -import { TenantNotifications } from "../../TenantNotifications"; -import ApiKeysSection from "../../components/ApiKeysSection"; - -export function SectionApiKeys() { - const { getAccessToken, setError, setSuccessMessage } = useTenantConfig(); - return ( - <> - - - - ); -} diff --git a/src/Web/autopilot-monitor-web/app/settings/components/ApiKeysSection.tsx b/src/Web/autopilot-monitor-web/app/settings/components/ApiKeysSection.tsx deleted file mode 100644 index 23c48436..00000000 --- a/src/Web/autopilot-monitor-web/app/settings/components/ApiKeysSection.tsx +++ /dev/null @@ -1,310 +0,0 @@ -"use client"; - -import { useState, useEffect, useCallback } from "react"; -import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; - -interface ApiKey { - keyId: string; - label: string; - scope: string; - tenantId: string; - createdBy: string; - createdAt: string; - expiresAt: string | null; - isActive: boolean; - requestCount: number; -} - -interface ApiKeysSectionProps { - getAccessToken: () => Promise; - setError: (e: string | null) => void; - setSuccessMessage: (m: string | null) => void; - isGlobalAdmin?: boolean; // if true, show scope selector and load /global/api-keys - tenants?: { tenantId: string; displayName?: string }[]; // for GA to pick tenant when creating tenant-scoped key -} - -export default function ApiKeysSection({ - getAccessToken, setError, setSuccessMessage, isGlobalAdmin = false, tenants = [] -}: ApiKeysSectionProps) { - const [keys, setKeys] = useState([]); - const [loading, setLoading] = useState(false); - const [creatingKey, setCreatingKey] = useState(false); - const [revokingKey, setRevokingKey] = useState(null); - const [showCreateForm, setShowCreateForm] = useState(false); - const [newLabel, setNewLabel] = useState(""); - const [newScope, setNewScope] = useState<"tenant" | "global">("tenant"); - const [newTenantId, setNewTenantId] = useState(""); - const [newExpiryDays, setNewExpiryDays] = useState(""); - const [createdKey, setCreatedKey] = useState<{ rawKey: string; label: string } | null>(null); - const [copiedKey, setCopiedKey] = useState(false); - const [confirmRevokeId, setConfirmRevokeId] = useState(null); - - const loadKeys = useCallback(async () => { - try { - setLoading(true); - const endpoint = isGlobalAdmin ? api.apiKeys.globalList() : api.apiKeys.list(); - const res = await authenticatedFetch(endpoint, getAccessToken); - if (!res.ok) throw new Error(`Failed to load API keys: ${res.statusText}`); - const data = await res.json(); - setKeys(data.keys ?? []); - } catch (err) { - if (err instanceof TokenExpiredError) return; - setError(err instanceof Error ? err.message : "Failed to load API keys"); - } finally { - setLoading(false); - } - }, [getAccessToken, isGlobalAdmin, setError]); - - useEffect(() => { loadKeys(); }, [loadKeys]); - - const handleCreate = async () => { - if (!newLabel.trim()) { setError("Label is required"); return; } - try { - setCreatingKey(true); - setError(null); - const body: Record = { label: newLabel.trim(), scope: newScope }; - if (newExpiryDays && parseInt(newExpiryDays) > 0) body.expiresInDays = parseInt(newExpiryDays); - if (newScope === "tenant" && newTenantId) body.tenantIdOverride = newTenantId; - const res = await authenticatedFetch(api.apiKeys.list(), getAccessToken, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - if (!res.ok) { - const d = await res.json().catch(() => ({})); - throw new Error(d.message ?? `Failed: ${res.statusText}`); - } - const data = await res.json(); - setCreatedKey({ rawKey: data.rawKey, label: newLabel.trim() }); - setNewLabel(""); setNewScope("tenant"); setNewTenantId(""); setNewExpiryDays(""); setShowCreateForm(false); - await loadKeys(); - } catch (err) { - if (err instanceof TokenExpiredError) return; - setError(err instanceof Error ? err.message : "Failed to create API key"); - } finally { - setCreatingKey(false); - } - }; - - const handleRevoke = async (keyId: string) => { - try { - setRevokingKey(keyId); - setError(null); - const res = await authenticatedFetch(api.apiKeys.delete(keyId), getAccessToken, { method: "DELETE" }); - if (!res.ok) throw new Error(`Failed to revoke: ${res.statusText}`); - setSuccessMessage("API key revoked successfully"); - await loadKeys(); - } catch (err) { - if (err instanceof TokenExpiredError) return; - setError(err instanceof Error ? err.message : "Failed to revoke API key"); - } finally { - setRevokingKey(null); - setConfirmRevokeId(null); - } - }; - - const copyToClipboard = () => { - if (createdKey) { - navigator.clipboard.writeText(createdKey.rawKey).then(() => { - setCopiedKey(true); - setTimeout(() => setCopiedKey(false), 2000); - }); - } - }; - - const formatDate = (d: string | null) => d ? new Date(d).toLocaleDateString() : "Never"; - const isExpired = (expiresAt: string | null) => expiresAt ? new Date(expiresAt) < new Date() : false; - - return ( -
-
-
-
- - - -
-

API Keys

-

Manage API keys for AI agent and MCP server access

-
-
- -
-
- - {/* New key one-time display */} - {createdKey && ( -
-
-
-

API key created: {createdKey.label}

-

Copy this key now — it will not be shown again.

-
- - {createdKey.rawKey} - - -
-
- -
-
- )} - - {/* Create form */} - {showCreateForm && ( -
-

New API Key

-
-
- - setNewLabel(e.target.value)} - placeholder="e.g. Claude Desktop Local" - className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-indigo-500 focus:border-indigo-500" - /> -
- {isGlobalAdmin && ( -
- -
- - -
- {newScope === "global" && ( -

Global keys have access to all tenants. Use with care.

- )} -
- )} -
- - setNewExpiryDays(e.target.value)} - placeholder="e.g. 365 (or blank for no expiry)" - min="1" - className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-indigo-500 focus:border-indigo-500" - /> -
-
- - -
-
-
- )} - - {/* Key list */} -
- {loading ? ( -

Loading API keys...

- ) : keys.length === 0 ? ( -

No API keys yet. Create one to enable AI agent access.

- ) : ( -
- {keys.map((key) => ( -
-
-
- {key.label} - - {key.scope === "global" ? "Global" : "Tenant"} - - {!key.isActive && ( - Revoked - )} - {key.isActive && isExpired(key.expiresAt) && ( - Expired - )} -
-
- Created by {key.createdBy || "unknown"} - • - Created {formatDate(key.createdAt)} - • - Expires {formatDate(key.expiresAt)} - • - {key.requestCount.toLocaleString()} requests - {isGlobalAdmin && key.scope === "tenant" && ( - <>•Tenant: {key.tenantId} - )} -
-
-
- {confirmRevokeId === key.keyId ? ( -
- Revoke? - - -
- ) : ( - - )} -
-
- ))} -
- )} -
- - {/* Rate limit info */} -
-

Rate limits: tenant-scoped keys 60 req/min · global-scoped keys 120 req/min

-
-
- ); -}