mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
oauth for mcp server
This commit is contained in:
-171
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user