b86c6bb47f
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
330 lines
12 KiB
C#
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.";
|
|
}
|
|
}
|