Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs
T
Joseph Doherty b86c6bb47f
ci / java (push) Successful in 5m51s
ci / portable (push) Successful in 6m43s
docs: complete XML-doc coverage and strip internal tracking IDs from code comments
Resolve all CommentChecker findings across the gateway server, worker, tests,
and .NET client (314 -> 0 real issues): add missing <returns>/<summary>/<param>
on public and test members, convert Stream/interface overrides to <inheritdoc/>,
and remove internal task/issue tracking IDs (SEC-*, IPC-*, WRK-*, GWC-*, TST-*,
Client.Dotnet-*) from shipped code documentation while preserving the design
rationale prose. Shipped comments should not carry internal bookkeeping, and
complete XML docs keep the analyzer/TreatWarningsAsErrors gate and generated API
docs clean. The 6 remaining flags are heuristic false positives (MD5, UTC-4,
capacity-1, near-1601) left intact so real documentation is not corrupted.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-10 06:15:47 -04:00

330 lines
12 KiB
C#

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";
/// <inheritdoc />
public bool CanManage(ClaimsPrincipal user)
{
return authorization.CanManage(user);
}
/// <inheritdoc />
public async Task<DashboardApiKeyManagementResult> 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_<id>_<secret> 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.");
}
}
/// <inheritdoc />
public async Task<DashboardApiKeyManagementResult> 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.");
}
/// <inheritdoc />
public async Task<DashboardApiKeyManagementResult> 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.");
}
}
/// <inheritdoc />
public async Task<DashboardApiKeyManagementResult> 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();
/// <summary>
/// Resolves the operator's username from the authenticated dashboard principal.
/// </summary>
/// <remarks>
/// The passed <paramref name="user"/> is preferred over the ambient HTTP context because it
/// is already in scope at every call site (the callers gate on <see cref="CanManage"/> using
/// it) and is unambiguous. Falls back to <see cref="IAuditActorAccessor.CurrentActor"/> for
/// defensive coverage, then to <c>"unknown"</c> when neither is available.
/// </remarks>
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";
}
/// <summary>
/// Emits the dashboard's own canonical <see cref="AuditEvent"/> for a <c>dashboard-*</c> op
/// directly through the best-effort <see cref="IAuditWriter"/>. This is in
/// addition to the <c>create/revoke/rotate-key</c> event that <see cref="ApiKeyAdminCommands"/>
/// emits via the canonical-forwarding <c>IApiKeyAuditStore</c> adapter — the doubled-audit
/// behaviour is preserved, both rows now land in the canonical <c>audit_event</c> store.
/// </summary>
/// <remarks>
/// <c>Actor</c> is the LDAP operator who performed the
/// action (resolved from the <paramref name="user"/> principal); <c>Target</c> is the managed
/// API key id. This fixes an earlier semantic gap where both fields held the keyId.
/// </remarks>
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);
}
/// <summary>
/// Derives a correlation id from the ASP.NET Core request trace identifier when it is a
/// well-formed GUID; otherwise null (the default <c>HttpContext.TraceIdentifier</c> is the
/// connection:request form, not a GUID, so it correlates to null rather than fabricating one).
/// </summary>
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<string, string> { ["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.";
}
}