oauth for mcp server

This commit is contained in:
Oliver Kieselbach
2026-03-28 06:15:08 +01:00
parent 7cae2515c1
commit be5a37e7bb
10 changed files with 0 additions and 1348 deletions
@@ -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
{
/// <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,
};
}
}
}
@@ -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<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;
}
}
}
}
@@ -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<ApiKeyManagementFunction> _logger;
private readonly IAdminRepository _adminRepo;
private readonly GlobalAdminService _globalAdminService;
public ApiKeyManagementFunction(
ILogger<ApiKeyManagementFunction> logger,
IAdminRepository adminRepo,
GlobalAdminService globalAdminService)
{
_logger = logger;
_adminRepo = adminRepo;
_globalAdminService = globalAdminService;
}
/// <summary>GET /api/api-keys — list API keys for the calling tenant</summary>
[Function("ListApiKeys")]
public async Task<HttpResponseData> 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;
}
}
/// <summary>GET /api/global/api-keys — list all API keys (GlobalAdmin only)</summary>
[Function("ListApiKeysGlobal")]
public async Task<HttpResponseData> 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;
}
}
/// <summary>POST /api/api-keys — create a new API key</summary>
[Function("CreateApiKey")]
public async Task<HttpResponseData> 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<int?>();
// 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;
}
}
/// <summary>DELETE /api/api-keys/{keyId} — revoke an API key</summary>
[Function("DeleteApiKey")]
public async Task<HttpResponseData> 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,
};
}
}
@@ -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;
/// <summary>
/// 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.
/// </summary>
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 =
{
"/api/agent/",
"/api/bootstrap/",
"/api/health",
"/api/stats/",
"/api/realtime/",
"/api/progress/",
};
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)
{
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<Claim>
{
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
}
}
/// <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();
var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(key));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}
@@ -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
{
/// <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; }
}
}
}
@@ -1,60 +0,0 @@
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; }
}
}
@@ -1 +0,0 @@
export { default } from "../../settings/components/ApiKeysSection";
@@ -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 (
<>
<AdminNotifications />
<ApiKeysSection
getAccessToken={getAccessToken}
setError={setError}
setSuccessMessage={setSuccessMessage}
isGlobalAdmin={true}
tenants={tenants.map((t) => ({ tenantId: t.tenantId, displayName: t.domainName ?? t.tenantId }))}
/>
</>
);
}
@@ -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 (
<>
<TenantNotifications />
<ApiKeysSection
getAccessToken={getAccessToken}
setError={setError}
setSuccessMessage={setSuccessMessage}
isGlobalAdmin={false}
/>
</>
);
}
@@ -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<string | null>;
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<ApiKey[]>([]);
const [loading, setLoading] = useState(false);
const [creatingKey, setCreatingKey] = useState(false);
const [revokingKey, setRevokingKey] = useState<string | null>(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<string>("");
const [createdKey, setCreatedKey] = useState<{ rawKey: string; label: string } | null>(null);
const [copiedKey, setCopiedKey] = useState(false);
const [confirmRevokeId, setConfirmRevokeId] = useState<string | null>(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<string, unknown> = { 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 (
<div className="bg-white rounded-lg shadow">
<div className="p-6 border-b border-gray-200 bg-gradient-to-r from-indigo-50 to-blue-50">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<svg className="w-6 h-6 text-indigo-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<div>
<h2 className="text-lg font-medium text-gray-900">API Keys</h2>
<p className="text-sm text-gray-500">Manage API keys for AI agent and MCP server access</p>
</div>
</div>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
className="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700 transition-colors"
>
+ New API Key
</button>
</div>
</div>
{/* New key one-time display */}
{createdKey && (
<div className="p-4 mx-6 mt-4 bg-green-50 border border-green-200 rounded-lg">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-green-800">API key created: <strong>{createdKey.label}</strong></p>
<p className="text-xs text-green-600 mt-1">Copy this key now — it will not be shown again.</p>
<div className="mt-2 flex items-center space-x-2">
<code className="flex-1 px-3 py-2 bg-white border border-green-200 rounded text-xs font-mono text-gray-800 break-all">
{createdKey.rawKey}
</code>
<button
onClick={copyToClipboard}
className="px-3 py-2 bg-green-600 text-white text-xs rounded hover:bg-green-700 whitespace-nowrap"
>
{copiedKey ? "Copied!" : "Copy"}
</button>
</div>
</div>
<button onClick={() => setCreatedKey(null)} className="ml-2 text-green-400 hover:text-green-600">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
)}
{/* Create form */}
{showCreateForm && (
<div className="p-6 border-b border-gray-100 bg-gray-50">
<h3 className="text-sm font-medium text-gray-700 mb-4">New API Key</h3>
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Label</label>
<input
type="text"
value={newLabel}
onChange={(e) => 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"
/>
</div>
{isGlobalAdmin && (
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Scope</label>
<div className="flex space-x-4">
<label className="flex items-center space-x-2 cursor-pointer">
<input type="radio" value="tenant" checked={newScope === "tenant"} onChange={() => setNewScope("tenant")} className="text-indigo-600" />
<span className="text-sm text-gray-700">Tenant-scoped</span>
</label>
<label className="flex items-center space-x-2 cursor-pointer">
<input type="radio" value="global" checked={newScope === "global"} onChange={() => setNewScope("global")} className="text-indigo-600" />
<span className="text-sm text-gray-700">Global (cross-tenant)</span>
</label>
</div>
{newScope === "global" && (
<p className="text-xs text-amber-600 mt-1">Global keys have access to all tenants. Use with care.</p>
)}
</div>
)}
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Expiry (days, leave blank for unlimited)</label>
<input
type="number"
value={newExpiryDays}
onChange={(e) => 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"
/>
</div>
<div className="flex space-x-2">
<button
onClick={handleCreate}
disabled={creatingKey || !newLabel.trim()}
className="px-4 py-2 bg-indigo-600 text-white text-sm font-medium rounded-md hover:bg-indigo-700 disabled:opacity-50 transition-colors"
>
{creatingKey ? "Creating..." : "Create Key"}
</button>
<button
onClick={() => { setShowCreateForm(false); setNewLabel(""); setNewExpiryDays(""); }}
className="px-4 py-2 border border-gray-300 text-gray-700 text-sm font-medium rounded-md hover:bg-gray-50"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Key list */}
<div className="p-6">
{loading ? (
<p className="text-sm text-gray-500">Loading API keys...</p>
) : keys.length === 0 ? (
<p className="text-sm text-gray-500">No API keys yet. Create one to enable AI agent access.</p>
) : (
<div className="space-y-3">
{keys.map((key) => (
<div
key={key.keyId}
className={`flex items-center justify-between p-4 border rounded-lg ${!key.isActive || isExpired(key.expiresAt) ? "border-red-100 bg-red-50" : "border-gray-200 bg-white"}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center space-x-2 flex-wrap gap-y-1">
<span className="text-sm font-medium text-gray-900">{key.label}</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${key.scope === "global" ? "bg-purple-100 text-purple-800" : "bg-blue-100 text-blue-800"}`}>
{key.scope === "global" ? "Global" : "Tenant"}
</span>
{!key.isActive && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">Revoked</span>
)}
{key.isActive && isExpired(key.expiresAt) && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-orange-100 text-orange-800">Expired</span>
)}
</div>
<div className="mt-1 text-xs text-gray-500 space-x-3">
<span>Created by {key.createdBy || "unknown"}</span>
<span>•</span>
<span>Created {formatDate(key.createdAt)}</span>
<span>•</span>
<span>Expires {formatDate(key.expiresAt)}</span>
<span>•</span>
<span>{key.requestCount.toLocaleString()} requests</span>
{isGlobalAdmin && key.scope === "tenant" && (
<><span>•</span><span>Tenant: {key.tenantId}</span></>
)}
</div>
</div>
<div className="ml-4 flex-shrink-0">
{confirmRevokeId === key.keyId ? (
<div className="flex items-center space-x-2">
<span className="text-xs text-red-600">Revoke?</span>
<button
onClick={() => handleRevoke(key.keyId)}
disabled={revokingKey === key.keyId}
className="px-2 py-1 bg-red-600 text-white text-xs rounded hover:bg-red-700 disabled:opacity-50"
>
{revokingKey === key.keyId ? "..." : "Yes"}
</button>
<button
onClick={() => setConfirmRevokeId(null)}
className="px-2 py-1 border border-gray-300 text-gray-600 text-xs rounded hover:bg-gray-50"
>
No
</button>
</div>
) : (
<button
onClick={() => setConfirmRevokeId(key.keyId)}
className="px-3 py-1 border border-red-200 text-red-600 text-xs rounded hover:bg-red-50 transition-colors"
>
Revoke
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Rate limit info */}
<div className="px-6 pb-6">
<p className="text-xs text-gray-400">Rate limits: tenant-scoped keys 60 req/min · global-scoped keys 120 req/min</p>
</div>
</div>
);
}