using System.Security.Claims; using Microsoft.Data.Sqlite; using ZB.MOM.WW.MxGateway.Server.Security.Authentication; using ZB.MOM.WW.MxGateway.Server.Security.Authorization; namespace ZB.MOM.WW.MxGateway.Server.Dashboard; public sealed class DashboardApiKeyManagementService( DashboardApiKeyAuthorization authorization, IApiKeyAdminStore adminStore, IApiKeyAuditStore auditStore, IApiKeySecretHasher hasher, IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService { private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys."; /// Determines whether the user can manage API keys. /// The authenticated user principal. public bool CanManage(ClaimsPrincipal user) { return authorization.CanManage(user); } /// Creates an API key asynchronously. /// The authenticated user principal. /// The request payload. /// Token to observe for cancellation. public async Task CreateAsync( ClaimsPrincipal user, DashboardApiKeyManagementRequest request, CancellationToken cancellationToken) { if (!CanManage(user)) { return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage); } string? validation = ValidateCreateRequest(request); if (validation is not null) { return DashboardApiKeyManagementResult.Fail(validation); } string keyId = request.KeyId.Trim(); string secret = ApiKeySecretGenerator.Generate(); string apiKey = FormatApiKey(keyId, secret); try { await adminStore.CreateAsync( new ApiKeyCreateRequest( KeyId: keyId, KeyPrefix: $"mxgw_{keyId}", SecretHash: hasher.HashSecret(secret), DisplayName: request.DisplayName.Trim(), Scopes: request.Scopes, Constraints: request.Constraints, CreatedUtc: DateTimeOffset.UtcNow), cancellationToken) .ConfigureAwait(false); await AppendAuditAsync(keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false); return DashboardApiKeyManagementResult.Success("API key created. Copy the key now; it will not be shown again.", apiKey); } catch (ApiKeyPepperUnavailableException) { return DashboardApiKeyManagementResult.Fail("API key pepper is not configured."); } catch (SqliteException exception) when (exception.SqliteErrorCode == 19) { return DashboardApiKeyManagementResult.Fail("An API key with that id already exists."); } } /// Revokes an API key asynchronously. /// The authenticated user principal. /// The API key identifier. /// Token to observe for cancellation. public async Task RevokeAsync( ClaimsPrincipal user, string keyId, CancellationToken cancellationToken) { if (!CanManage(user)) { return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage); } string? validation = ValidateKeyId(keyId); if (validation is not null) { return DashboardApiKeyManagementResult.Fail(validation); } string normalizedKeyId = keyId.Trim(); bool revoked = await adminStore .RevokeAsync(normalizedKeyId, DateTimeOffset.UtcNow, cancellationToken) .ConfigureAwait(false); await AppendAuditAsync( normalizedKeyId, "dashboard-revoke-key", revoked ? "revoked" : "not-found-or-already-revoked", cancellationToken) .ConfigureAwait(false); return revoked ? DashboardApiKeyManagementResult.Success("API key revoked.") : DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked."); } /// Rotates an API key secret asynchronously. /// The authenticated user principal. /// The API key identifier. /// Token to observe for cancellation. public async Task RotateAsync( ClaimsPrincipal user, string keyId, CancellationToken cancellationToken) { if (!CanManage(user)) { return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage); } string? validation = ValidateKeyId(keyId); if (validation is not null) { return DashboardApiKeyManagementResult.Fail(validation); } string normalizedKeyId = keyId.Trim(); string secret = ApiKeySecretGenerator.Generate(); string apiKey = FormatApiKey(normalizedKeyId, secret); try { bool rotated = await adminStore .RotateAsync(normalizedKeyId, hasher.HashSecret(secret), DateTimeOffset.UtcNow, cancellationToken) .ConfigureAwait(false); await AppendAuditAsync( normalizedKeyId, "dashboard-rotate-key", rotated ? "rotated" : "not-found", cancellationToken) .ConfigureAwait(false); return rotated ? DashboardApiKeyManagementResult.Success("API key rotated. Copy the key now; it will not be shown again.", apiKey) : DashboardApiKeyManagementResult.Fail("API key was not found."); } catch (ApiKeyPepperUnavailableException) { return DashboardApiKeyManagementResult.Fail("API key pepper is not configured."); } } /// Deletes a revoked API key asynchronously. /// The authenticated user principal. /// The API key identifier. /// Token to observe for cancellation. public async Task DeleteAsync( ClaimsPrincipal user, string keyId, CancellationToken cancellationToken) { if (!CanManage(user)) { return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage); } string? validation = ValidateKeyId(keyId); if (validation is not null) { return DashboardApiKeyManagementResult.Fail(validation); } string normalizedKeyId = keyId.Trim(); bool deleted = await adminStore .DeleteAsync(normalizedKeyId, cancellationToken) .ConfigureAwait(false); await AppendAuditAsync( normalizedKeyId, "dashboard-delete-key", deleted ? "deleted" : "not-found-or-active", cancellationToken) .ConfigureAwait(false); return deleted ? DashboardApiKeyManagementResult.Success("API key deleted.") : DashboardApiKeyManagementResult.Fail("API key was not found, or is still active. Revoke it before deleting."); } private async Task AppendAuditAsync( string? keyId, string eventType, string? details, CancellationToken cancellationToken) { await auditStore.AppendAsync( new ApiKeyAuditEntry( KeyId: keyId, EventType: eventType, RemoteAddress: httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(), Details: details), cancellationToken) .ConfigureAwait(false); } private static string? ValidateCreateRequest(DashboardApiKeyManagementRequest request) { string? keyIdValidation = ValidateKeyId(request.KeyId); if (keyIdValidation is not null) { return keyIdValidation; } if (string.IsNullOrWhiteSpace(request.DisplayName)) { return "Display name is required."; } string[] unknownScopes = request.Scopes .Where(scope => !GatewayScopes.IsKnown(scope)) .ToArray(); if (unknownScopes.Length > 0) { return $"Unknown scope(s): {string.Join(", ", unknownScopes)}. " + $"Valid scopes are: {string.Join(", ", GatewayScopes.All)}."; } return null; } private static string? ValidateKeyId(string keyId) { if (string.IsNullOrWhiteSpace(keyId)) { return "API key id is required."; } return keyId.Trim().All(character => char.IsAsciiLetterOrDigit(character) || character is '.' or '-') ? null : "API key id may contain only letters, numbers, periods, and hyphens."; } private static string FormatApiKey(string keyId, string secret) { return $"mxgw_{keyId}_{secret}"; } }