using System.Security.Claims; using System.Text.Json; using Microsoft.Data.Sqlite; using ZB.MOM.WW.Audit; using ZB.MOM.WW.Auth.Abstractions.ApiKeys; using ZB.MOM.WW.Auth.ApiKeys.Admin; using ZB.MOM.WW.MxGateway.Server.Security.Audit; 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, ApiKeyAdminCommands adminCommands, IApiKeyAdminStore adminStore, IAuditWriter auditWriter, IHttpContextAccessor httpContextAccessor, IApiKeyCacheInvalidator? cacheInvalidator = null) : IDashboardApiKeyManagementService { private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys."; private const string PepperUnavailableMarker = "pepper unavailable"; /// public bool CanManage(ClaimsPrincipal user) { return authorization.CanManage(user); } /// 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(); try { // The shared command set generates the secret, hashes it with the pepper, persists the // record and assembles the mxgw__ token (shown once). It also appends its own // "create-key" audit entry (now canonicalized through the IApiKeyAuditStore->IAuditWriter // adapter); the dashboard layers a richer "dashboard-create-key" canonical AuditEvent // (Target + CorrelationId + remote address) on top via IAuditWriter to preserve the // dashboard audit vocabulary — both rows land in the canonical audit_event store. CreateKeyResult created = await adminCommands.CreateKeyAsync( keyId, request.DisplayName.Trim(), request.Scopes, ApiKeyConstraintSerializer.Serialize(request.Constraints), RemoteAddress(), cancellationToken) .ConfigureAwait(false); await WriteDashboardAuditAsync(user, keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false); return DashboardApiKeyManagementResult.Success( "API key created. Copy the key now; it will not be shown again.", created.Token); } catch (InvalidOperationException exception) when (IsPepperUnavailable(exception)) { 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."); } } /// 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(); KeyActionResult result = await adminCommands .RevokeKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken) .ConfigureAwait(false); cacheInvalidator?.Invalidate(normalizedKeyId); await WriteDashboardAuditAsync( user, normalizedKeyId, "dashboard-revoke-key", result.Succeeded ? "revoked" : "not-found-or-already-revoked", cancellationToken) .ConfigureAwait(false); return result.Succeeded ? DashboardApiKeyManagementResult.Success("API key revoked.") : DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked."); } /// 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(); try { CreateKeyResult rotated = await adminCommands .RotateKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken) .ConfigureAwait(false); cacheInvalidator?.Invalidate(normalizedKeyId); bool succeeded = rotated.Token is not null; await WriteDashboardAuditAsync( user, normalizedKeyId, "dashboard-rotate-key", succeeded ? "rotated" : "not-found", cancellationToken) .ConfigureAwait(false); return succeeded ? DashboardApiKeyManagementResult.Success( "API key rotated. Copy the key now; it will not be shown again.", rotated.Token) : DashboardApiKeyManagementResult.Fail("API key was not found."); } catch (InvalidOperationException exception) when (IsPepperUnavailable(exception)) { return DashboardApiKeyManagementResult.Fail("API key pepper is not configured."); } } /// 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); cacheInvalidator?.Invalidate(normalizedKeyId); await WriteDashboardAuditAsync( user, 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 string? RemoteAddress() => httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(); /// /// Resolves the operator's username from the authenticated dashboard principal. /// /// /// The passed is preferred over the ambient HTTP context because it /// is already in scope at every call site (the callers gate on using /// it) and is unambiguous. Falls back to for /// defensive coverage, then to "unknown" when neither is available. /// private static string ResolveOperatorActor(ClaimsPrincipal user) { // ZbClaimTypes.Username = "zb:username" — the canonical LDAP login name. string? username = user.FindFirstValue(ZB.MOM.WW.Auth.AspNetCore.ZbClaimTypes.Username); if (!string.IsNullOrWhiteSpace(username)) { return username; } // Framework fallback: Identity.Name is driven by the nameClaimType on the ClaimsIdentity // (set to ZbClaimTypes.Name = ClaimTypes.Name by DashboardAuthenticator → display name). string? identityName = user.Identity?.Name; if (!string.IsNullOrWhiteSpace(identityName)) { return identityName; } return "unknown"; } /// /// Emits the dashboard's own canonical for a dashboard-* op /// directly through the best-effort . This is in /// addition to the create/revoke/rotate-key event that /// emits via the canonical-forwarding IApiKeyAuditStore adapter — the doubled-audit /// behaviour is preserved, both rows now land in the canonical audit_event store. /// /// /// Actor is the LDAP operator who performed the /// action (resolved from the principal); Target is the managed /// API key id. This fixes an earlier semantic gap where both fields held the keyId. /// private async Task WriteDashboardAuditAsync( ClaimsPrincipal user, string keyId, string action, string? detail, CancellationToken cancellationToken) { AuditEvent auditEvent = new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow, Actor = ResolveOperatorActor(user), Action = action, Outcome = AuditOutcome.Success, Category = CanonicalForwardingApiKeyAuditStore.ApiKeyCategory, Target = keyId, SourceNode = RemoteAddress(), CorrelationId = ParseCorrelationId(), DetailsJson = WrapDetail(detail), }; await auditWriter.WriteAsync(auditEvent, cancellationToken).ConfigureAwait(false); } /// /// Derives a correlation id from the ASP.NET Core request trace identifier when it is a /// well-formed GUID; otherwise null (the default HttpContext.TraceIdentifier is the /// connection:request form, not a GUID, so it correlates to null rather than fabricating one). /// private Guid? ParseCorrelationId() => Guid.TryParse(httpContextAccessor.HttpContext?.TraceIdentifier, out Guid correlationId) ? correlationId : null; private static string? WrapDetail(string? detail) => detail is null ? null : JsonSerializer.Serialize(new Dictionary { ["detail"] = detail }); private static bool IsPepperUnavailable(InvalidOperationException exception) => exception.Message.Contains(PepperUnavailableMarker, StringComparison.OrdinalIgnoreCase); 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."; } }