feat(backend): Implement MCP API Key Management System (Story 5.2)
Implemented comprehensive API Key authentication and management system
for MCP Server to ensure only authorized AI agents can access ColaFlow.
## Domain Layer
- Created McpApiKey aggregate root with BCrypt password hashing
- Implemented ApiKeyPermissions value object (read/write, resource/tool filtering)
- Added ApiKeyStatus enum (Active, Revoked)
- Created domain events (ApiKeyCreatedEvent, ApiKeyRevokedEvent)
- API key format: cola_<36 random chars> (cryptographically secure)
- Default expiration: 90 days
## Application Layer
- Implemented McpApiKeyService with full CRUD operations
- Created DTOs for API key creation, validation, and updates
- Validation logic: hash verification, expiration check, IP whitelist
- Usage tracking: last_used_at, usage_count
## Infrastructure Layer
- Created McpDbContext with PostgreSQL configuration
- EF Core entity configuration with JSONB for permissions/IP whitelist
- Implemented McpApiKeyRepository with prefix-based lookup
- Database migration: mcp_api_keys table with indexes
- Created McpApiKeyAuthenticationMiddleware for API key validation
- Middleware validates Authorization: Bearer <api_key> header
## API Layer
- Created McpApiKeysController with REST endpoints:
- POST /api/mcp/keys - Create API Key (returns plain key once!)
- GET /api/mcp/keys - List tenant's API Keys
- GET /api/mcp/keys/{id} - Get API Key details
- PATCH /api/mcp/keys/{id}/metadata - Update name/description
- PATCH /api/mcp/keys/{id}/permissions - Update permissions
- DELETE /api/mcp/keys/{id} - Revoke API Key
- Requires JWT authentication (not API key auth)
## Testing
- Created 17 unit tests for McpApiKey entity
- Created 7 unit tests for ApiKeyPermissions value object
- All 49 tests passing (including existing MCP tests)
- Test coverage > 80% for Domain layer
## Security Features
- BCrypt hashing with work factor 12
- API key shown only once at creation (never logged)
- Key prefix lookup for fast validation (indexed)
- Multi-tenant isolation (tenant_id filter)
- IP whitelist support
- Permission scopes (read/write, resources, tools)
- Automatic expiration after 90 days
## Database Schema
Table: mcp.mcp_api_keys
- Indexes: key_prefix (unique), tenant_id, tenant_user, expires_at, status
- JSONB columns for permissions and IP whitelist
- Soft delete via revoked_at
## Integration
- Updated Program.cs to register MCP module with configuration
- Added MCP DbContext migration in development mode
- Authentication middleware runs before MCP protocol handler
Changes:
- Created 31 new files (2321+ lines)
- Domain: 6 files (McpApiKey, events, repository, value objects)
- Application: 9 files (service, DTOs)
- Infrastructure: 8 files (DbContext, repository, middleware, migration)
- API: 1 file (McpApiKeysController)
- Tests: 2 files (17 + 7 unit tests)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
using ColaFlow.Modules.Mcp.Application.DTOs;
|
||||
|
||||
namespace ColaFlow.Modules.Mcp.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service interface for MCP API Key management
|
||||
/// </summary>
|
||||
public interface IMcpApiKeyService
|
||||
{
|
||||
/// <summary>
|
||||
/// Create a new API Key
|
||||
/// </summary>
|
||||
Task<CreateApiKeyResponse> CreateAsync(CreateApiKeyRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Validate an API Key
|
||||
/// </summary>
|
||||
Task<ApiKeyValidationResult> ValidateAsync(string plainKey, string? ipAddress = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get API Key by ID
|
||||
/// </summary>
|
||||
Task<ApiKeyResponse?> GetByIdAsync(Guid id, Guid tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all API Keys for a tenant
|
||||
/// </summary>
|
||||
Task<List<ApiKeyResponse>> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Get all API Keys for a user
|
||||
/// </summary>
|
||||
Task<List<ApiKeyResponse>> GetByUserIdAsync(Guid userId, Guid tenantId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Update API Key metadata
|
||||
/// </summary>
|
||||
Task<ApiKeyResponse> UpdateMetadataAsync(Guid id, Guid tenantId, UpdateApiKeyMetadataRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Update API Key permissions
|
||||
/// </summary>
|
||||
Task<ApiKeyResponse> UpdatePermissionsAsync(Guid id, Guid tenantId, UpdateApiKeyPermissionsRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Revoke an API Key
|
||||
/// </summary>
|
||||
Task RevokeAsync(Guid id, Guid tenantId, Guid revokedBy, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
using ColaFlow.Modules.Mcp.Application.DTOs;
|
||||
using ColaFlow.Modules.Mcp.Domain.Entities;
|
||||
using ColaFlow.Modules.Mcp.Domain.Repositories;
|
||||
using ColaFlow.Modules.Mcp.Domain.ValueObjects;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ColaFlow.Modules.Mcp.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service implementation for MCP API Key management
|
||||
/// </summary>
|
||||
public class McpApiKeyService : IMcpApiKeyService
|
||||
{
|
||||
private readonly IMcpApiKeyRepository _repository;
|
||||
private readonly ILogger<McpApiKeyService> _logger;
|
||||
|
||||
public McpApiKeyService(
|
||||
IMcpApiKeyRepository repository,
|
||||
ILogger<McpApiKeyService> logger)
|
||||
{
|
||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public async Task<CreateApiKeyResponse> CreateAsync(CreateApiKeyRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation("Creating API Key '{Name}' for Tenant {TenantId} User {UserId}",
|
||||
request.Name, request.TenantId, request.UserId);
|
||||
|
||||
// Create permissions
|
||||
var permissions = ApiKeyPermissions.Custom(
|
||||
request.Read,
|
||||
request.Write,
|
||||
request.AllowedResources,
|
||||
request.AllowedTools);
|
||||
|
||||
// Create API Key entity
|
||||
var (apiKey, plainKey) = McpApiKey.Create(
|
||||
request.Name,
|
||||
request.TenantId,
|
||||
request.UserId,
|
||||
permissions,
|
||||
request.ExpirationDays,
|
||||
request.IpWhitelist);
|
||||
|
||||
// Add description if provided
|
||||
if (!string.IsNullOrWhiteSpace(request.Description))
|
||||
{
|
||||
apiKey.UpdateMetadata(description: request.Description);
|
||||
}
|
||||
|
||||
// Save to database
|
||||
await _repository.AddAsync(apiKey, cancellationToken);
|
||||
|
||||
_logger.LogInformation("API Key created successfully: {ApiKeyId}", apiKey.Id);
|
||||
|
||||
return new CreateApiKeyResponse
|
||||
{
|
||||
Id = apiKey.Id,
|
||||
Name = apiKey.Name,
|
||||
PlainKey = plainKey, // IMPORTANT: Only returned once!
|
||||
KeyPrefix = apiKey.KeyPrefix,
|
||||
ExpiresAt = apiKey.ExpiresAt,
|
||||
Permissions = new ApiKeyPermissionsDto
|
||||
{
|
||||
Read = apiKey.Permissions.Read,
|
||||
Write = apiKey.Permissions.Write,
|
||||
AllowedResources = apiKey.Permissions.AllowedResources,
|
||||
AllowedTools = apiKey.Permissions.AllowedTools
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ApiKeyValidationResult> ValidateAsync(string plainKey, string? ipAddress = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(plainKey))
|
||||
{
|
||||
return ApiKeyValidationResult.Invalid("API Key is empty");
|
||||
}
|
||||
|
||||
// Extract prefix for fast lookup
|
||||
if (plainKey.Length < 12)
|
||||
{
|
||||
return ApiKeyValidationResult.Invalid("Invalid API Key format");
|
||||
}
|
||||
|
||||
var keyPrefix = plainKey.Substring(0, 12);
|
||||
|
||||
// Lookup by prefix
|
||||
var apiKey = await _repository.GetByPrefixAsync(keyPrefix, cancellationToken);
|
||||
if (apiKey == null)
|
||||
{
|
||||
_logger.LogWarning("API Key not found for prefix: {KeyPrefix}", keyPrefix);
|
||||
return ApiKeyValidationResult.Invalid("Invalid API Key");
|
||||
}
|
||||
|
||||
// Verify hash
|
||||
if (!apiKey.VerifyKey(plainKey))
|
||||
{
|
||||
_logger.LogWarning("API Key hash verification failed for {ApiKeyId}", apiKey.Id);
|
||||
return ApiKeyValidationResult.Invalid("Invalid API Key");
|
||||
}
|
||||
|
||||
// Check status
|
||||
if (apiKey.Status != ApiKeyStatus.Active)
|
||||
{
|
||||
_logger.LogWarning("API Key {ApiKeyId} is revoked", apiKey.Id);
|
||||
return ApiKeyValidationResult.Invalid("API Key has been revoked");
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (apiKey.IsExpired())
|
||||
{
|
||||
_logger.LogWarning("API Key {ApiKeyId} is expired", apiKey.Id);
|
||||
return ApiKeyValidationResult.Invalid("API Key has expired");
|
||||
}
|
||||
|
||||
// Check IP whitelist
|
||||
if (!string.IsNullOrWhiteSpace(ipAddress) && !apiKey.IsIpAllowed(ipAddress))
|
||||
{
|
||||
_logger.LogWarning("API Key {ApiKeyId} rejected - IP {IpAddress} not whitelisted", apiKey.Id, ipAddress);
|
||||
return ApiKeyValidationResult.Invalid("IP address not allowed");
|
||||
}
|
||||
|
||||
// Record usage (async, don't block)
|
||||
try
|
||||
{
|
||||
apiKey.RecordUsage();
|
||||
await _repository.UpdateAsync(apiKey, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to record API Key usage for {ApiKeyId}", apiKey.Id);
|
||||
// Continue - don't block auth for usage tracking failures
|
||||
}
|
||||
|
||||
_logger.LogInformation("API Key {ApiKeyId} validated successfully for Tenant {TenantId}",
|
||||
apiKey.Id, apiKey.TenantId);
|
||||
|
||||
return ApiKeyValidationResult.Valid(
|
||||
apiKey.Id,
|
||||
apiKey.TenantId,
|
||||
apiKey.UserId,
|
||||
apiKey.Permissions);
|
||||
}
|
||||
|
||||
public async Task<ApiKeyResponse?> GetByIdAsync(Guid id, Guid tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var apiKey = await _repository.GetByIdAsync(id, cancellationToken);
|
||||
if (apiKey == null || apiKey.TenantId != tenantId)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return MapToResponse(apiKey);
|
||||
}
|
||||
|
||||
public async Task<List<ApiKeyResponse>> GetByTenantIdAsync(Guid tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var apiKeys = await _repository.GetByTenantIdAsync(tenantId, cancellationToken);
|
||||
return apiKeys.Select(MapToResponse).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<ApiKeyResponse>> GetByUserIdAsync(Guid userId, Guid tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var apiKeys = await _repository.GetByUserIdAsync(userId, tenantId, cancellationToken);
|
||||
return apiKeys.Select(MapToResponse).ToList();
|
||||
}
|
||||
|
||||
public async Task<ApiKeyResponse> UpdateMetadataAsync(Guid id, Guid tenantId, UpdateApiKeyMetadataRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var apiKey = await _repository.GetByIdAsync(id, cancellationToken);
|
||||
if (apiKey == null || apiKey.TenantId != tenantId)
|
||||
{
|
||||
throw new InvalidOperationException("API Key not found");
|
||||
}
|
||||
|
||||
apiKey.UpdateMetadata(request.Name, request.Description);
|
||||
await _repository.UpdateAsync(apiKey, cancellationToken);
|
||||
|
||||
_logger.LogInformation("API Key {ApiKeyId} metadata updated", apiKey.Id);
|
||||
|
||||
return MapToResponse(apiKey);
|
||||
}
|
||||
|
||||
public async Task<ApiKeyResponse> UpdatePermissionsAsync(Guid id, Guid tenantId, UpdateApiKeyPermissionsRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var apiKey = await _repository.GetByIdAsync(id, cancellationToken);
|
||||
if (apiKey == null || apiKey.TenantId != tenantId)
|
||||
{
|
||||
throw new InvalidOperationException("API Key not found");
|
||||
}
|
||||
|
||||
var permissions = ApiKeyPermissions.Custom(
|
||||
request.Read,
|
||||
request.Write,
|
||||
request.AllowedResources,
|
||||
request.AllowedTools);
|
||||
|
||||
apiKey.UpdatePermissions(permissions);
|
||||
|
||||
if (request.IpWhitelist != null)
|
||||
{
|
||||
apiKey.UpdateIpWhitelist(request.IpWhitelist);
|
||||
}
|
||||
|
||||
await _repository.UpdateAsync(apiKey, cancellationToken);
|
||||
|
||||
_logger.LogInformation("API Key {ApiKeyId} permissions updated", apiKey.Id);
|
||||
|
||||
return MapToResponse(apiKey);
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(Guid id, Guid tenantId, Guid revokedBy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var apiKey = await _repository.GetByIdAsync(id, cancellationToken);
|
||||
if (apiKey == null || apiKey.TenantId != tenantId)
|
||||
{
|
||||
throw new InvalidOperationException("API Key not found");
|
||||
}
|
||||
|
||||
apiKey.Revoke(revokedBy);
|
||||
await _repository.UpdateAsync(apiKey, cancellationToken);
|
||||
|
||||
_logger.LogInformation("API Key {ApiKeyId} revoked by {RevokedBy}", apiKey.Id, revokedBy);
|
||||
}
|
||||
|
||||
private static ApiKeyResponse MapToResponse(McpApiKey apiKey)
|
||||
{
|
||||
return new ApiKeyResponse
|
||||
{
|
||||
Id = apiKey.Id,
|
||||
TenantId = apiKey.TenantId,
|
||||
UserId = apiKey.UserId,
|
||||
Name = apiKey.Name,
|
||||
Description = apiKey.Description,
|
||||
KeyPrefix = apiKey.KeyPrefix,
|
||||
Status = apiKey.Status.ToString(),
|
||||
Permissions = new ApiKeyPermissionsDto
|
||||
{
|
||||
Read = apiKey.Permissions.Read,
|
||||
Write = apiKey.Permissions.Write,
|
||||
AllowedResources = apiKey.Permissions.AllowedResources,
|
||||
AllowedTools = apiKey.Permissions.AllowedTools
|
||||
},
|
||||
IpWhitelist = apiKey.IpWhitelist,
|
||||
LastUsedAt = apiKey.LastUsedAt,
|
||||
UsageCount = apiKey.UsageCount,
|
||||
CreatedAt = apiKey.CreatedAt,
|
||||
ExpiresAt = apiKey.ExpiresAt,
|
||||
RevokedAt = apiKey.RevokedAt,
|
||||
RevokedBy = apiKey.RevokedBy
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user