feat(auth): cut MxGateway API keys over to ZB.MOM.WW.Auth.ApiKeys 0.1.2; keep constraint enforcement+gRPC+CLI on top (Task 1.3)

This commit is contained in:
Joseph Doherty
2026-06-02 02:08:38 -04:00
parent f4dc11bae4
commit 05009d7370
49 changed files with 515 additions and 1642 deletions
@@ -1,5 +1,7 @@
using System.Security.Claims;
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Admin;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
@@ -7,12 +9,13 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public sealed class DashboardApiKeyManagementService(
DashboardApiKeyAuthorization authorization,
ApiKeyAdminCommands adminCommands,
IApiKeyAdminStore adminStore,
IApiKeyAuditStore auditStore,
IApiKeySecretHasher hasher,
IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService
{
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
private const string PepperUnavailableMarker = "pepper unavailable";
/// <summary>Determines whether the user can manage API keys.</summary>
/// <param name="user">The authenticated user principal.</param>
@@ -42,28 +45,29 @@ public sealed class DashboardApiKeyManagementService(
}
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),
// 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; the dashboard layers a "dashboard-create-key" entry with the
// caller's remote address on top to preserve the dashboard audit vocabulary.
CreateKeyResult created = await adminCommands.CreateKeyAsync(
keyId,
request.DisplayName.Trim(),
request.Scopes,
ApiKeyConstraintSerializer.Serialize(request.Constraints),
RemoteAddress(),
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);
return DashboardApiKeyManagementResult.Success(
"API key created. Copy the key now; it will not be shown again.",
created.Token);
}
catch (ApiKeyPepperUnavailableException)
catch (InvalidOperationException exception) when (IsPepperUnavailable(exception))
{
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
}
@@ -94,18 +98,18 @@ public sealed class DashboardApiKeyManagementService(
}
string normalizedKeyId = keyId.Trim();
bool revoked = await adminStore
.RevokeAsync(normalizedKeyId, DateTimeOffset.UtcNow, cancellationToken)
KeyActionResult result = await adminCommands
.RevokeKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
.ConfigureAwait(false);
await AppendAuditAsync(
normalizedKeyId,
"dashboard-revoke-key",
revoked ? "revoked" : "not-found-or-already-revoked",
result.Succeeded ? "revoked" : "not-found-or-already-revoked",
cancellationToken)
.ConfigureAwait(false);
return revoked
return result.Succeeded
? DashboardApiKeyManagementResult.Success("API key revoked.")
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
}
@@ -131,27 +135,29 @@ public sealed class DashboardApiKeyManagementService(
}
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)
CreateKeyResult rotated = await adminCommands
.RotateKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
.ConfigureAwait(false);
bool succeeded = rotated.Token is not null;
await AppendAuditAsync(
normalizedKeyId,
"dashboard-rotate-key",
rotated ? "rotated" : "not-found",
succeeded ? "rotated" : "not-found",
cancellationToken)
.ConfigureAwait(false);
return rotated
? DashboardApiKeyManagementResult.Success("API key rotated. Copy the key now; it will not be shown again.", apiKey)
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 (ApiKeyPepperUnavailableException)
catch (InvalidOperationException exception) when (IsPepperUnavailable(exception))
{
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
}
@@ -194,6 +200,9 @@ public sealed class DashboardApiKeyManagementService(
: DashboardApiKeyManagementResult.Fail("API key was not found, or is still active. Revoke it before deleting.");
}
private string? RemoteAddress() =>
httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString();
private async Task AppendAuditAsync(
string? keyId,
string eventType,
@@ -204,12 +213,16 @@ public sealed class DashboardApiKeyManagementService(
new ApiKeyAuditEntry(
KeyId: keyId,
EventType: eventType,
RemoteAddress: httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(),
RemoteAddress: RemoteAddress(),
CreatedUtc: DateTimeOffset.UtcNow,
Details: details),
cancellationToken)
.ConfigureAwait(false);
}
private static bool IsPepperUnavailable(InvalidOperationException exception) =>
exception.Message.Contains(PepperUnavailableMarker, StringComparison.OrdinalIgnoreCase);
private static string? ValidateCreateRequest(DashboardApiKeyManagementRequest request)
{
string? keyIdValidation = ValidateKeyId(request.KeyId);
@@ -248,9 +261,4 @@ public sealed class DashboardApiKeyManagementService(
? null
: "API key id may contain only letters, numbers, periods, and hyphens.";
}
private static string FormatApiKey(string keyId, string secret)
{
return $"mxgw_{keyId}_{secret}";
}
}
@@ -2,6 +2,7 @@ using System.Runtime.CompilerServices;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Metrics;
@@ -242,7 +243,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
KeyId: key.KeyId,
DisplayName: key.DisplayName,
Scopes: key.Scopes,
Constraints: key.Constraints,
Constraints: ApiKeyConstraintSerializer.Deserialize(key.ConstraintsJson),
CreatedUtc: key.CreatedUtc,
LastUsedUtc: key.LastUsedUtc,
RevokedUtc: key.RevokedUtc))
@@ -1,6 +1,6 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
@@ -66,7 +66,7 @@ public static class GatewayApplication
builder.AddZbSerilog(o => o.ServiceName = "mxgateway");
builder.Services.AddGatewayConfiguration(builder.Configuration);
builder.Services.AddSqliteAuthStore();
builder.Services.AddSqliteAuthStore(builder.Configuration);
builder.Services.AddGatewayGrpcAuthorization();
builder.Services.AddHealthChecks()
.AddTypeActivatedCheck<AuthStoreHealthCheck>(
@@ -1,15 +1,19 @@
using System.Text.Json;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Admin;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
/// <summary>
/// Executes API key administration commands from the CLI.
/// </summary>
public sealed class ApiKeyAdminCliRunner(
IAuthStoreMigrator migrator,
IApiKeyAdminStore adminStore,
IApiKeyAuditStore auditStore,
IApiKeySecretHasher hasher)
/// <remarks>
/// The create/revoke/rotate/list/init-db verbs (secret generation, peppered hashing, token
/// assembly and per-action audit) are delegated to the shared
/// <see cref="ApiKeyAdminCommands"/>. This runner adapts the gateway's strongly-typed command and
/// output DTOs (which carry <see cref="ApiKeyConstraints"/>) onto the library's JSON-based contract.
/// </remarks>
public sealed class ApiKeyAdminCliRunner(ApiKeyAdminCommands commands)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
@@ -44,8 +48,7 @@ public sealed class ApiKeyAdminCliRunner(
private async Task<ApiKeyAdminOutput> InitDbAsync(CancellationToken cancellationToken)
{
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
await AppendAuditAsync(null, "init-db", null, cancellationToken).ConfigureAwait(false);
await commands.InitDbAsync(remoteAddress: null, cancellationToken).ConfigureAwait(false);
return new ApiKeyAdminOutput("init-db", "initialized", null, []);
}
@@ -54,33 +57,26 @@ public sealed class ApiKeyAdminCliRunner(
ApiKeyAdminCommand command,
CancellationToken cancellationToken)
{
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
// The shared command set requires the schema to exist; init-db is idempotent.
await commands.InitDbAsync(remoteAddress: null, cancellationToken).ConfigureAwait(false);
string keyId = Required(command.KeyId);
string secret = ApiKeySecretGenerator.Generate();
string apiKey = FormatApiKey(keyId, secret);
await adminStore.CreateAsync(
new ApiKeyCreateRequest(
KeyId: keyId,
KeyPrefix: $"mxgw_{keyId}",
SecretHash: hasher.HashSecret(secret),
DisplayName: Required(command.DisplayName),
Scopes: command.Scopes,
Constraints: command.Constraints,
CreatedUtc: DateTimeOffset.UtcNow),
CreateKeyResult created = await commands.CreateKeyAsync(
keyId,
Required(command.DisplayName),
command.Scopes,
ApiKeyConstraintSerializer.Serialize(command.Constraints),
remoteAddress: null,
cancellationToken)
.ConfigureAwait(false);
await AppendAuditAsync(keyId, "create-key", null, cancellationToken).ConfigureAwait(false);
return new ApiKeyAdminOutput("create-key", "created", apiKey, []);
return new ApiKeyAdminOutput("create-key", "created", created.Token, []);
}
private async Task<ApiKeyAdminOutput> ListKeysAsync(CancellationToken cancellationToken)
{
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
IReadOnlyList<ApiKeyRecord> keys = await adminStore.ListAsync(cancellationToken).ConfigureAwait(false);
await AppendAuditAsync(null, "list-keys", null, cancellationToken).ConfigureAwait(false);
await commands.InitDbAsync(remoteAddress: null, cancellationToken).ConfigureAwait(false);
IReadOnlyList<ApiKeyListItem> keys = await commands.ListKeysAsync(cancellationToken).ConfigureAwait(false);
return new ApiKeyAdminOutput(
"list-keys",
@@ -93,35 +89,28 @@ public sealed class ApiKeyAdminCliRunner(
ApiKeyAdminCommand command,
CancellationToken cancellationToken)
{
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
await commands.InitDbAsync(remoteAddress: null, cancellationToken).ConfigureAwait(false);
string keyId = Required(command.KeyId);
bool revoked = await adminStore.RevokeAsync(keyId, DateTimeOffset.UtcNow, cancellationToken)
KeyActionResult result = await commands.RevokeKeyAsync(keyId, remoteAddress: null, cancellationToken)
.ConfigureAwait(false);
await AppendAuditAsync(keyId, "revoke-key", revoked ? "revoked" : "not-found-or-already-revoked", cancellationToken)
.ConfigureAwait(false);
return new ApiKeyAdminOutput("revoke-key", revoked ? "revoked" : "not-found-or-already-revoked", null, []);
return new ApiKeyAdminOutput("revoke-key", result.Succeeded ? "revoked" : "not-found-or-already-revoked", null, []);
}
private async Task<ApiKeyAdminOutput> RotateKeyAsync(
ApiKeyAdminCommand command,
CancellationToken cancellationToken)
{
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
await commands.InitDbAsync(remoteAddress: null, cancellationToken).ConfigureAwait(false);
string keyId = Required(command.KeyId);
string secret = ApiKeySecretGenerator.Generate();
string apiKey = FormatApiKey(keyId, secret);
bool rotated = await adminStore.RotateAsync(keyId, hasher.HashSecret(secret), DateTimeOffset.UtcNow, cancellationToken)
CreateKeyResult rotated = await commands.RotateKeyAsync(keyId, remoteAddress: null, cancellationToken)
.ConfigureAwait(false);
await AppendAuditAsync(keyId, "rotate-key", rotated ? "rotated" : "not-found", cancellationToken)
.ConfigureAwait(false);
bool succeeded = rotated.Token is not null;
return new ApiKeyAdminOutput("rotate-key", rotated ? "rotated" : "not-found", rotated ? apiKey : null, []);
return new ApiKeyAdminOutput("rotate-key", succeeded ? "rotated" : "not-found", rotated.Token, []);
}
private static async Task WriteOutputAsync(
@@ -150,40 +139,19 @@ public sealed class ApiKeyAdminCliRunner(
}
}
private async Task AppendAuditAsync(
string? keyId,
string eventType,
string? details,
CancellationToken cancellationToken)
{
await auditStore.AppendAsync(
new ApiKeyAuditEntry(
KeyId: keyId,
EventType: eventType,
RemoteAddress: null,
Details: details),
cancellationToken)
.ConfigureAwait(false);
}
private static ApiKeyAdminListedKey ToListedKey(ApiKeyRecord key)
private static ApiKeyAdminListedKey ToListedKey(ApiKeyListItem key)
{
return new ApiKeyAdminListedKey(
KeyId: key.KeyId,
KeyPrefix: key.KeyPrefix,
DisplayName: key.DisplayName,
Scopes: key.Scopes,
Constraints: key.Constraints,
Constraints: ApiKeyConstraintSerializer.Deserialize(key.ConstraintsJson),
CreatedUtc: key.CreatedUtc,
LastUsedUtc: key.LastUsedUtc,
RevokedUtc: key.RevokedUtc);
}
private static string FormatApiKey(string keyId, string secret)
{
return $"mxgw_{keyId}_{secret}";
}
private static string Required(string? value)
{
return value ?? throw new InvalidOperationException("Required command value was not provided.");
@@ -1,7 +0,0 @@
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
public sealed record ApiKeyAuditEntry(
string? KeyId,
string EventType,
string? RemoteAddress,
string? Details);
@@ -1,9 +0,0 @@
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
public sealed record ApiKeyAuditRecord(
long AuditId,
string? KeyId,
string EventType,
string? RemoteAddress,
DateTimeOffset CreatedUtc,
string? Details);
@@ -1,10 +0,0 @@
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
public sealed record ApiKeyCreateRequest(
string KeyId,
string KeyPrefix,
byte[] SecretHash,
string DisplayName,
IReadOnlySet<string> Scopes,
ApiKeyConstraints Constraints,
DateTimeOffset CreatedUtc);
@@ -1,49 +0,0 @@
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
public sealed class ApiKeyParser : IApiKeyParser
{
private const string BearerPrefix = "Bearer ";
private const string TokenPrefix = "mxgw_";
/// <summary>Attempts to parse a Bearer token from an Authorization header and extract the API key ID and secret.</summary>
/// <param name="authorizationHeader">Authorization header value to parse.</param>
/// <param name="apiKey">Parsed API key with ID and secret, or null if parsing failed.</param>
/// <returns>True if the header was successfully parsed; otherwise, false.</returns>
public bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey)
{
apiKey = null;
if (string.IsNullOrWhiteSpace(authorizationHeader)
|| !authorizationHeader.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
string token = authorizationHeader[BearerPrefix.Length..].Trim();
if (!token.StartsWith(TokenPrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
string keyPayload = token[TokenPrefix.Length..];
int separatorIndex = keyPayload.IndexOf('_', StringComparison.Ordinal);
if (separatorIndex <= 0 || separatorIndex == keyPayload.Length - 1)
{
return false;
}
string keyId = keyPayload[..separatorIndex];
string secret = keyPayload[(separatorIndex + 1)..];
if (string.IsNullOrWhiteSpace(keyId) || string.IsNullOrWhiteSpace(secret))
{
return false;
}
apiKey = new ParsedApiKey(keyId, secret);
return true;
}
}
@@ -1,4 +0,0 @@
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
public sealed class ApiKeyPepperUnavailableException(string pepperSecretName)
: InvalidOperationException($"API key pepper secret '{pepperSecretName}' is not configured.");
@@ -1,12 +0,0 @@
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
public sealed record ApiKeyRecord(
string KeyId,
string KeyPrefix,
byte[] SecretHash,
string DisplayName,
IReadOnlySet<string> Scopes,
ApiKeyConstraints Constraints,
DateTimeOffset CreatedUtc,
DateTimeOffset? LastUsedUtc,
DateTimeOffset? RevokedUtc);
@@ -1,31 +0,0 @@
using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
/// <summary>Reads API key records from SQLite query results.</summary>
public static class ApiKeyRecordReader
{
/// <summary>Deserializes a row from the API key table into an ApiKeyRecord.</summary>
/// <param name="reader">The data reader positioned at the API key row.</param>
/// <returns>The deserialized API key record.</returns>
public static ApiKeyRecord Read(SqliteDataReader reader)
{
return new ApiKeyRecord(
KeyId: reader.GetString(0),
KeyPrefix: reader.GetString(1),
SecretHash: (byte[])reader["secret_hash"],
DisplayName: reader.GetString(3),
Scopes: ApiKeyScopeSerializer.Deserialize(reader.GetString(4)),
Constraints: ApiKeyConstraintSerializer.Deserialize(reader.IsDBNull(5) ? null : reader.GetString(5)),
CreatedUtc: DateTimeOffset.Parse(reader.GetString(6), System.Globalization.CultureInfo.InvariantCulture),
LastUsedUtc: ReadNullableDateTimeOffset(reader, 7),
RevokedUtc: ReadNullableDateTimeOffset(reader, 8));
}
private static DateTimeOffset? ReadNullableDateTimeOffset(SqliteDataReader reader, int ordinal)
{
return reader.IsDBNull(ordinal)
? null
: DateTimeOffset.Parse(reader.GetString(ordinal), System.Globalization.CultureInfo.InvariantCulture);
}
}
@@ -1,29 +0,0 @@
using System.Text.Json;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
public static class ApiKeyScopeSerializer
{
/// <summary>Serializes scopes to JSON string.</summary>
/// <param name="scopes">The scopes to serialize.</param>
/// <returns>JSON string representation.</returns>
public static string Serialize(IReadOnlySet<string> scopes)
{
return JsonSerializer.Serialize(scopes.Order(StringComparer.Ordinal));
}
/// <summary>Deserializes scopes from JSON string.</summary>
/// <param name="value">The JSON string to deserialize.</param>
/// <returns>Deserialized scopes set.</returns>
public static IReadOnlySet<string> Deserialize(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return new HashSet<string>(StringComparer.Ordinal);
}
string[]? scopes = JsonSerializer.Deserialize<string[]>(value);
return new HashSet<string>(scopes ?? [], StringComparer.Ordinal);
}
}
@@ -1,19 +0,0 @@
using System.Security.Cryptography;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
/// <summary>Generates cryptographically secure API key secrets.</summary>
public static class ApiKeySecretGenerator
{
/// <summary>Generates a new random API key secret string.</summary>
public static string Generate()
{
Span<byte> bytes = stackalloc byte[32];
RandomNumberGenerator.Fill(bytes);
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
}
@@ -1,38 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
public sealed class ApiKeySecretHasher(
IConfiguration configuration,
IOptions<GatewayOptions> options) : IApiKeySecretHasher
{
/// <summary>Hashes an API key secret with pepper using HMAC-SHA256.</summary>
/// <param name="secret">The secret to hash.</param>
/// <returns>The hashed secret.</returns>
public byte[] HashSecret(string secret)
{
string pepper = GetPepper();
byte[] pepperBytes = Encoding.UTF8.GetBytes(pepper);
byte[] secretBytes = Encoding.UTF8.GetBytes(secret);
using HMACSHA256 hmac = new(pepperBytes);
return hmac.ComputeHash(secretBytes);
}
private string GetPepper()
{
string pepperSecretName = options.Value.Authentication.PepperSecretName;
string? pepper = configuration[pepperSecretName];
if (string.IsNullOrWhiteSpace(pepper))
{
throw new ApiKeyPepperUnavailableException(pepperSecretName);
}
return pepper;
}
}
@@ -1,11 +0,0 @@
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
public enum ApiKeyVerificationFailure
{
None,
MissingOrMalformedCredentials,
PepperUnavailable,
KeyNotFound,
KeyRevoked,
SecretMismatch
}
@@ -1,33 +0,0 @@
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
public sealed record ApiKeyVerificationResult(
bool Succeeded,
ApiKeyIdentity? Identity,
ApiKeyVerificationFailure Failure)
{
/// <summary>
/// Creates a successful verification result.
/// </summary>
/// <param name="identity">API key identity.</param>
/// <returns>Success result.</returns>
public static ApiKeyVerificationResult Success(ApiKeyIdentity identity)
{
return new ApiKeyVerificationResult(
Succeeded: true,
Identity: identity,
Failure: ApiKeyVerificationFailure.None);
}
/// <summary>
/// Creates a failed verification result.
/// </summary>
/// <param name="failure">Verification failure reason.</param>
/// <returns>Failure result.</returns>
public static ApiKeyVerificationResult Fail(ApiKeyVerificationFailure failure)
{
return new ApiKeyVerificationResult(
Succeeded: false,
Identity: null,
Failure: failure);
}
}
@@ -1,64 +0,0 @@
using System.Security.Cryptography;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
public sealed class ApiKeyVerifier(
IApiKeyParser parser,
IApiKeySecretHasher hasher,
IApiKeyStore keyStore) : IApiKeyVerifier
{
/// <summary>
/// Verifies an API key from an authorization header asynchronously.
/// </summary>
/// <param name="authorizationHeader">Authorization header value.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Verification result.</returns>
public async Task<ApiKeyVerificationResult> VerifyAsync(
string? authorizationHeader,
CancellationToken cancellationToken)
{
if (!parser.TryParseAuthorizationHeader(authorizationHeader, out ParsedApiKey? parsedKey)
|| parsedKey is null)
{
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.MissingOrMalformedCredentials);
}
ApiKeyRecord? storedKey = await keyStore.FindByKeyIdAsync(parsedKey.KeyId, cancellationToken)
.ConfigureAwait(false);
if (storedKey is null)
{
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.KeyNotFound);
}
if (storedKey.RevokedUtc is not null)
{
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.KeyRevoked);
}
byte[] presentedHash;
try
{
presentedHash = hasher.HashSecret(parsedKey.Secret);
}
catch (ApiKeyPepperUnavailableException)
{
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.PepperUnavailable);
}
if (!CryptographicOperations.FixedTimeEquals(presentedHash, storedKey.SecretHash))
{
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch);
}
await keyStore.MarkKeyUsedAsync(storedKey.KeyId, DateTimeOffset.UtcNow, cancellationToken)
.ConfigureAwait(false);
return ApiKeyVerificationResult.Success(new ApiKeyIdentity(
KeyId: storedKey.KeyId,
KeyPrefix: storedKey.KeyPrefix,
DisplayName: storedKey.DisplayName,
Scopes: storedKey.Scopes,
Constraints: storedKey.Constraints));
}
}
@@ -1,80 +0,0 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
/// <summary>
/// Factory for creating SQLite connections to the authentication store.
/// </summary>
public sealed class AuthSqliteConnectionFactory(IOptions<GatewayOptions> options)
{
/// <summary>
/// Busy timeout applied to every auth-store connection. SQLite retries a busy
/// database for this long before surfacing <c>SQLITE_BUSY</c>, so the concurrent
/// <c>MarkKeyUsedAsync</c> / audit-append writers degrade gracefully under load
/// instead of failing the request path.
/// </summary>
private static readonly TimeSpan BusyTimeout = TimeSpan.FromSeconds(5);
/// <summary>
/// Creates an unopened SQLite connection to the auth database. Prefer
/// <see cref="OpenConnectionAsync"/>, which also applies WAL journaling and the
/// busy timeout.
/// </summary>
public SqliteConnection CreateConnection()
{
string sqlitePath = options.Value.Authentication.SqlitePath;
string? directory = Path.GetDirectoryName(sqlitePath);
if (!string.IsNullOrWhiteSpace(directory))
{
Directory.CreateDirectory(directory);
}
SqliteConnectionStringBuilder builder = new()
{
DataSource = sqlitePath,
Mode = SqliteOpenMode.ReadWriteCreate,
Pooling = true,
DefaultTimeout = (int)BusyTimeout.TotalSeconds,
};
return new SqliteConnection(builder.ToString());
}
/// <summary>
/// Creates a SQLite connection, opens it, and configures WAL journaling and a
/// non-zero busy timeout so concurrent readers and writers degrade gracefully
/// rather than surfacing <c>SQLITE_BUSY</c> as a hard failure.
/// </summary>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>An opened and configured SQLite connection.</returns>
public async Task<SqliteConnection> OpenConnectionAsync(CancellationToken cancellationToken)
{
SqliteConnection connection = CreateConnection();
try
{
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
await ConfigureConnectionAsync(connection, cancellationToken).ConfigureAwait(false);
return connection;
}
catch
{
await connection.DisposeAsync().ConfigureAwait(false);
throw;
}
}
private static async Task ConfigureConnectionAsync(
SqliteConnection connection,
CancellationToken cancellationToken)
{
// WAL is a persistent, database-level setting; re-applying it per connection
// is cheap and a no-op once set. busy_timeout is per-connection state.
await using SqliteCommand command = connection.CreateCommand();
command.CommandText =
$"PRAGMA journal_mode=WAL; PRAGMA busy_timeout={(int)BusyTimeout.TotalMilliseconds};";
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
@@ -1,3 +0,0 @@
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
public sealed class AuthStoreMigrationException(string message) : InvalidOperationException(message);
@@ -1,29 +0,0 @@
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
/// <summary>
/// Hosted service that runs authentication store migrations on startup.
/// </summary>
public sealed class AuthStoreMigrationHostedService(
IOptions<GatewayOptions> options,
IAuthStoreMigrator migrator) : IHostedService
{
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
AuthenticationOptions authentication = options.Value.Authentication;
if (authentication.Mode == AuthenticationMode.ApiKey && authentication.RunMigrationsOnStartup)
{
await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false);
}
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
@@ -1,27 +1,80 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Admin;
using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
/// <summary>
/// Extension methods for configuring the SQLite authentication store.
/// </summary>
/// <remarks>
/// The peppered-HMAC API-key pipeline (token format, hashing, constant-time compare, SQLite
/// schema, stores, verifier and migration) is provided by the shared
/// <c>ZB.MOM.WW.Auth.ApiKeys</c> library, of which this gateway is the donor. This wiring binds
/// the library's <see cref="ApiKeyOptions"/> from the gateway's <c>MxGateway:Authentication</c>
/// section and layers the gateway-specific constraint enforcement, gRPC interceptor, CLI and
/// dashboard on top.
/// </remarks>
public static class AuthStoreServiceCollectionExtensions
{
/// <summary>The configuration section the gateway binds API-key options from.</summary>
public const string AuthenticationSectionPath = "MxGateway:Authentication";
/// <summary>The gateway API-key token prefix (token format <c>mxgw_&lt;id&gt;_&lt;secret&gt;</c>).</summary>
public const string TokenPrefix = "mxgw";
/// <summary>The configuration key the API-key pepper is resolved from.</summary>
public const string PepperSecretName = "MxGateway:ApiKeyPepper";
/// <summary>
/// Adds the SQLite authentication store and related services to the dependency container.
/// </summary>
/// <param name="services">Service collection to configure.</param>
/// <param name="configuration">Application configuration carrying the API-key options.</param>
/// <returns>The service collection for chaining.</returns>
public static IServiceCollection AddSqliteAuthStore(this IServiceCollection services)
public static IServiceCollection AddSqliteAuthStore(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddSingleton<IApiKeyParser, ApiKeyParser>();
services.AddSingleton<IApiKeySecretHasher, ApiKeySecretHasher>();
services.AddSingleton<IApiKeyVerifier, ApiKeyVerifier>();
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
// Pin the gateway's API-key contract (token prefix "mxgw"; pepper resolved from
// MxGateway:ApiKeyPepper) by layering fallback defaults UNDER the supplied configuration:
// an in-memory source provides TokenPrefix/PepperSecretName only when the bound
// MxGateway:Authentication section omits them (the section has no TokenPrefix, and the pepper
// is intentionally not in appsettings — it is supplied at runtime). Explicit config wins
// because it is added last. ApiKeyOptions is an init-only record, so the values must be
// present at bind time rather than mutated post-configure.
IConfiguration effectiveConfig = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"{AuthenticationSectionPath}:TokenPrefix"] = TokenPrefix,
[$"{AuthenticationSectionPath}:PepperSecretName"] = PepperSecretName,
})
.AddConfiguration(configuration)
.Build();
// Register the shared API-key provider: binds ApiKeyOptions from MxGateway:Authentication,
// wires up the SQLite stores, the configuration-backed pepper provider, the verifier, the
// migrator and the migration hosted service.
services.AddZbApiKeyAuth(effectiveConfig, AuthenticationSectionPath);
// The shared admin command set (create/revoke/rotate/list/init-db with audit) is not
// auto-registered by AddZbApiKeyAuth; the gateway CLI and dashboard drive it, so register
// it here over the already-wired stores, pepper provider and migrator.
services.AddSingleton(sp => new ApiKeyAdminCommands(
sp.GetRequiredService<IOptions<ApiKeyOptions>>().Value,
sp.GetRequiredService<IApiKeyAdminStore>(),
sp.GetRequiredService<IApiKeyAuditStore>(),
sp.GetRequiredService<IApiKeyPepperProvider>(),
sp.GetRequiredService<SqliteAuthStoreMigrator>()));
services.AddSingleton<ApiKeyAdminCliRunner>();
services.AddSingleton<AuthSqliteConnectionFactory>();
services.AddSingleton<IAuthStoreMigrator, SqliteAuthStoreMigrator>();
services.AddSingleton<IApiKeyStore, SqliteApiKeyStore>();
services.AddSingleton<IApiKeyAdminStore, SqliteApiKeyAdminStore>();
services.AddSingleton<IApiKeyAuditStore, SqliteApiKeyAuditStore>();
services.AddHostedService<AuthStoreMigrationHostedService>();
return services;
}
@@ -0,0 +1,43 @@
using LibApiKeyIdentity = ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
/// <summary>
/// Maps the shared <see cref="ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity"/> (which
/// carries the key's scopes plus the opaque constraints JSON blob) onto the gateway's
/// <see cref="ApiKeyIdentity"/> (which exposes the deserialized
/// <see cref="ApiKeyConstraints"/> the downstream authorization code enforces).
/// </summary>
/// <remarks>
/// The shared verifier does not interpret the constraints column; it returns the stored
/// JSON verbatim in <see cref="ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity.Constraints"/>.
/// This mapper re-hydrates it via <see cref="ApiKeyConstraintSerializer"/> so the gateway's
/// constraint enforcement (<c>ConstraintEnforcer</c>) and request-identity accessor continue
/// to operate on the strongly-typed model unchanged.
/// </remarks>
public static class GatewayApiKeyIdentityMapper
{
/// <summary>
/// Converts a shared API-key identity into the gateway identity, deserializing the opaque
/// constraints JSON into <see cref="ApiKeyConstraints"/>.
/// </summary>
/// <param name="identity">The shared identity returned by the library verifier.</param>
/// <returns>The gateway identity carrying the effective constraints.</returns>
public static ApiKeyIdentity ToGatewayIdentity(LibApiKeyIdentity identity)
{
ArgumentNullException.ThrowIfNull(identity);
// The library stores the opaque constraints blob in Constraints as the ConstraintsJson
// string (or null when the key has no constraints).
string? constraintsJson = identity.Constraints as string;
return new ApiKeyIdentity(
KeyId: identity.KeyId,
// The gateway token prefix is fixed ("mxgw"); the key id is its own field. KeyPrefix
// is retained only for surface compatibility with the gateway identity record.
KeyPrefix: "mxgw",
DisplayName: identity.DisplayName,
Scopes: identity.Scopes,
Constraints: ApiKeyConstraintSerializer.Deserialize(constraintsJson));
}
}
@@ -1,53 +0,0 @@
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
public interface IApiKeyAdminStore
{
/// <summary>
/// Creates a new API key asynchronously.
/// </summary>
/// <param name="request">API key creation request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Completed task.</returns>
Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken);
/// <summary>
/// Lists all API keys asynchronously.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>List of API key records.</returns>
Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken);
/// <summary>
/// Revokes an API key asynchronously.
/// </summary>
/// <param name="keyId">Key identifier.</param>
/// <param name="revokedUtc">Revocation timestamp.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if revoked; otherwise false.</returns>
Task<bool> RevokeAsync(string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken);
/// <summary>
/// Rotates an API key secret asynchronously.
/// </summary>
/// <param name="keyId">Key identifier.</param>
/// <param name="secretHash">New secret hash.</param>
/// <param name="rotatedUtc">Rotation timestamp.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if rotated; otherwise false.</returns>
Task<bool> RotateAsync(
string keyId,
byte[] secretHash,
DateTimeOffset rotatedUtc,
CancellationToken cancellationToken);
/// <summary>
/// Permanently deletes an API key, but only if it is already revoked. Active keys are
/// untouched (returns false) so an admin cannot delete a working credential without
/// first revoking it — that preserves the audit trail and forces the revoke event to
/// land in the audit log before the row disappears.
/// </summary>
/// <param name="keyId">Key identifier.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if a revoked key was deleted; false if the key is missing or active.</returns>
Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken);
}
@@ -1,23 +0,0 @@
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
/// <summary>
/// Stores and retrieves audit events for API key operations.
/// </summary>
public interface IApiKeyAuditStore
{
/// <summary>
/// Appends an audit entry to the audit log.
/// </summary>
/// <param name="entry">Audit entry to record.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Asynchronous task representing the append operation.</returns>
Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken);
/// <summary>
/// Lists the most recent audit entries, up to the specified count.
/// </summary>
/// <param name="count">Maximum number of entries to return.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Asynchronous task returning the list of audit records.</returns>
Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(int count, CancellationToken cancellationToken);
}
@@ -1,9 +0,0 @@
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
public interface IApiKeyParser
{
/// <summary>Attempts to parse an authorization header and extract the API key.</summary>
/// <param name="authorizationHeader">Authorization header value to parse.</param>
/// <param name="apiKey">Parsed API key if successful.</param>
bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey);
}
@@ -1,8 +0,0 @@
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
public interface IApiKeySecretHasher
{
/// <summary>Hashes an API key secret and returns the hash bytes.</summary>
/// <param name="secret">API key secret to hash.</param>
byte[] HashSecret(string secret);
}
@@ -1,21 +0,0 @@
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
/// <summary>Persists API keys and audit records for authentication and accounting.</summary>
public interface IApiKeyStore
{
/// <summary>Retrieves an API key by ID regardless of revocation status.</summary>
/// <param name="keyId">Identifier of the API key.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken cancellationToken);
/// <summary>Retrieves an active (non-revoked) API key by ID.</summary>
/// <param name="keyId">Identifier of the API key.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken);
/// <summary>Records that an API key was used for auditing and tracking.</summary>
/// <param name="keyId">Identifier of the API key.</param>
/// <param name="usedUtc">Timestamp when the key was used in UTC.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken);
}
@@ -1,12 +0,0 @@
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
/// <summary>Verifies API key authorization headers and returns the authenticated identity.</summary>
public interface IApiKeyVerifier
{
/// <summary>Parses and verifies an authorization header, returning success with identity or a failure reason.</summary>
/// <param name="authorizationHeader">The authorization header value to verify.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
Task<ApiKeyVerificationResult> VerifyAsync(
string? authorizationHeader,
CancellationToken cancellationToken);
}
@@ -1,10 +0,0 @@
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
/// <summary>Migrates authentication storage between versions.</summary>
public interface IAuthStoreMigrator
{
/// <summary>Performs authentication store migration asynchronously.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>Asynchronous task representing the migration operation.</returns>
Task MigrateAsync(CancellationToken cancellationToken);
}
@@ -1,3 +0,0 @@
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
public sealed record ParsedApiKey(string KeyId, string Secret);
@@ -1,141 +0,0 @@
using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
/// <summary>
/// SQLite-backed storage for API key administration (create, list, revoke, rotate).
/// </summary>
public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAdminStore
{
/// <inheritdoc />
public async Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
{
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
INSERT INTO api_keys (
key_id,
key_prefix,
secret_hash,
display_name,
scopes,
constraints,
created_utc,
last_used_utc,
revoked_utc)
VALUES (
$key_id,
$key_prefix,
$secret_hash,
$display_name,
$scopes,
$constraints,
$created_utc,
NULL,
NULL);
""";
AddCreateParameters(command, request);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc
FROM api_keys
ORDER BY key_id;
""";
List<ApiKeyRecord> records = [];
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken)
.ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
records.Add(ApiKeyRecordReader.Read(reader));
}
return records;
}
/// <inheritdoc />
public async Task<bool> RevokeAsync(string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken)
{
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
UPDATE api_keys
SET revoked_utc = $revoked_utc
WHERE key_id = $key_id AND revoked_utc IS NULL;
""";
command.Parameters.AddWithValue("$key_id", keyId);
command.Parameters.AddWithValue("$revoked_utc", revokedUtc.ToString("O"));
int rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> RotateAsync(
string keyId,
byte[] secretHash,
DateTimeOffset rotatedUtc,
CancellationToken cancellationToken)
{
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
UPDATE api_keys
SET secret_hash = $secret_hash,
last_used_utc = NULL,
revoked_utc = NULL
WHERE key_id = $key_id;
""";
command.Parameters.AddWithValue("$key_id", keyId);
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = secretHash;
int rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return rows > 0;
}
/// <inheritdoc />
public async Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken)
{
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
DELETE FROM api_keys
WHERE key_id = $key_id AND revoked_utc IS NOT NULL;
""";
command.Parameters.AddWithValue("$key_id", keyId);
int rows = await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
return rows > 0;
}
private static void AddCreateParameters(SqliteCommand command, ApiKeyCreateRequest request)
{
command.Parameters.AddWithValue("$key_id", request.KeyId);
command.Parameters.AddWithValue("$key_prefix", request.KeyPrefix);
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = request.SecretHash;
command.Parameters.AddWithValue("$display_name", request.DisplayName);
command.Parameters.AddWithValue("$scopes", ApiKeyScopeSerializer.Serialize(request.Scopes));
command.Parameters.AddWithValue(
"$constraints",
(object?)ApiKeyConstraintSerializer.Serialize(request.Constraints) ?? DBNull.Value);
command.Parameters.AddWithValue("$created_utc", request.CreatedUtc.ToString("O"));
}
}
@@ -1,65 +0,0 @@
using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
public sealed class SqliteApiKeyAuditStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAuditStore
{
/// <inheritdoc />
public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
{
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
INSERT INTO api_key_audit (key_id, event_type, remote_address, created_utc, details)
VALUES ($key_id, $event_type, $remote_address, $created_utc, $details);
""";
command.Parameters.AddWithValue("$key_id", (object?)entry.KeyId ?? DBNull.Value);
command.Parameters.AddWithValue("$event_type", entry.EventType);
command.Parameters.AddWithValue("$remote_address", (object?)entry.RemoteAddress ?? DBNull.Value);
command.Parameters.AddWithValue("$created_utc", DateTimeOffset.UtcNow.ToString("O"));
command.Parameters.AddWithValue("$details", (object?)entry.Details ?? DBNull.Value);
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(int count, CancellationToken cancellationToken)
{
if (count <= 0)
{
return [];
}
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
SELECT audit_id, key_id, event_type, remote_address, created_utc, details
FROM api_key_audit
ORDER BY audit_id DESC
LIMIT $count;
""";
command.Parameters.AddWithValue("$count", count);
List<ApiKeyAuditRecord> records = [];
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken)
.ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
records.Add(new ApiKeyAuditRecord(
AuditId: reader.GetInt64(0),
KeyId: reader.IsDBNull(1) ? null : reader.GetString(1),
EventType: reader.GetString(2),
RemoteAddress: reader.IsDBNull(3) ? null : reader.GetString(3),
CreatedUtc: DateTimeOffset.Parse(
reader.GetString(4),
System.Globalization.CultureInfo.InvariantCulture),
Details: reader.IsDBNull(5) ? null : reader.GetString(5)));
}
return records;
}
}
@@ -1,68 +0,0 @@
using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
/// <summary>SQLite-based store for API key records.</summary>
public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyStore
{
/// <inheritdoc />
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken cancellationToken)
{
return FindByKeyIdAsync(keyId, requireActive: false, cancellationToken);
}
/// <inheritdoc />
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken)
{
return FindByKeyIdAsync(keyId, requireActive: true, cancellationToken);
}
/// <inheritdoc />
public async Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken)
{
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = """
UPDATE api_keys
SET last_used_utc = $last_used_utc
WHERE key_id = $key_id AND revoked_utc IS NULL;
""";
command.Parameters.AddWithValue("$key_id", keyId);
command.Parameters.AddWithValue("$last_used_utc", usedUtc.ToString("O"));
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<ApiKeyRecord?> FindByKeyIdAsync(
string keyId,
bool requireActive,
CancellationToken cancellationToken)
{
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using SqliteCommand command = connection.CreateCommand();
command.CommandText = requireActive
? """
SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc
FROM api_keys
WHERE key_id = $key_id AND revoked_utc IS NULL;
"""
: """
SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc
FROM api_keys
WHERE key_id = $key_id;
""";
command.Parameters.AddWithValue("$key_id", keyId);
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken)
.ConfigureAwait(false);
if (!await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
return null;
}
return ApiKeyRecordReader.Read(reader);
}
}
@@ -1,12 +0,0 @@
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
public static class SqliteAuthSchema
{
public const int CurrentVersion = 2;
public const string SchemaVersionTable = "schema_version";
public const string ApiKeysTable = "api_keys";
public const string ApiKeyAuditTable = "api_key_audit";
}
@@ -1,192 +0,0 @@
using Microsoft.Data.Sqlite;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication;
public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connectionFactory) : IAuthStoreMigrator
{
/// <summary>Applies database migrations to the authentication store.</summary>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task MigrateAsync(CancellationToken cancellationToken)
{
await using SqliteConnection connection = await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);
await using SqliteTransaction transaction =
(SqliteTransaction)await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
int existingVersion = await ReadExistingSchemaVersionAsync(connection, transaction, cancellationToken)
.ConfigureAwait(false);
if (existingVersion > SqliteAuthSchema.CurrentVersion)
{
throw new AuthStoreMigrationException(
$"Auth database schema version {existingVersion} is newer than supported version {SqliteAuthSchema.CurrentVersion}.");
}
await ApplyVersionOneAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
await ApplyVersionTwoAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
await WriteSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
private static async Task<int> ReadExistingSchemaVersionAsync(
SqliteConnection connection,
SqliteTransaction transaction,
CancellationToken cancellationToken)
{
await using SqliteCommand tableExistsCommand = connection.CreateCommand();
tableExistsCommand.Transaction = transaction;
tableExistsCommand.CommandText = """
SELECT COUNT(*)
FROM sqlite_master
WHERE type = 'table' AND name = $table_name;
""";
tableExistsCommand.Parameters.AddWithValue("$table_name", SqliteAuthSchema.SchemaVersionTable);
long tableCount = (long)(await tableExistsCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) ?? 0L);
if (tableCount == 0)
{
return 0;
}
await using SqliteCommand versionCommand = connection.CreateCommand();
versionCommand.Transaction = transaction;
versionCommand.CommandText = """
SELECT version
FROM schema_version
WHERE id = 1;
""";
object? version = await versionCommand.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
return version is null || version == DBNull.Value
? 0
: Convert.ToInt32(version, System.Globalization.CultureInfo.InvariantCulture);
}
private static async Task ApplyVersionOneAsync(
SqliteConnection connection,
SqliteTransaction transaction,
CancellationToken cancellationToken)
{
await ExecuteNonQueryAsync(
connection,
transaction,
"""
CREATE TABLE IF NOT EXISTS schema_version (
id INTEGER PRIMARY KEY CHECK (id = 1),
version INTEGER NOT NULL,
applied_utc TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS api_keys (
key_id TEXT PRIMARY KEY,
key_prefix TEXT NOT NULL,
secret_hash BLOB NOT NULL,
display_name TEXT NOT NULL,
scopes TEXT NOT NULL,
constraints TEXT NULL,
created_utc TEXT NOT NULL,
last_used_utc TEXT NULL,
revoked_utc TEXT NULL
);
CREATE TABLE IF NOT EXISTS api_key_audit (
audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
key_id TEXT NULL,
event_type TEXT NOT NULL,
remote_address TEXT NULL,
created_utc TEXT NOT NULL,
details TEXT NULL
);
CREATE INDEX IF NOT EXISTS ix_api_keys_revoked_utc
ON api_keys (revoked_utc);
CREATE INDEX IF NOT EXISTS ix_api_key_audit_key_id_created_utc
ON api_key_audit (key_id, created_utc);
""",
cancellationToken).ConfigureAwait(false);
}
private static async Task ApplyVersionTwoAsync(
SqliteConnection connection,
SqliteTransaction transaction,
CancellationToken cancellationToken)
{
if (await ColumnExistsAsync(connection, transaction, SqliteAuthSchema.ApiKeysTable, "constraints", cancellationToken)
.ConfigureAwait(false))
{
return;
}
await ExecuteNonQueryAsync(
connection,
transaction,
"""
ALTER TABLE api_keys
ADD COLUMN constraints TEXT NULL;
""",
cancellationToken).ConfigureAwait(false);
}
private static async Task WriteSchemaVersionAsync(
SqliteConnection connection,
SqliteTransaction transaction,
CancellationToken cancellationToken)
{
await using SqliteCommand versionCommand = connection.CreateCommand();
versionCommand.Transaction = transaction;
versionCommand.CommandText = """
INSERT INTO schema_version (id, version, applied_utc)
VALUES (1, $version, $applied_utc)
ON CONFLICT(id) DO UPDATE SET
version = excluded.version,
applied_utc = excluded.applied_utc;
""";
versionCommand.Parameters.AddWithValue("$version", SqliteAuthSchema.CurrentVersion);
versionCommand.Parameters.AddWithValue("$applied_utc", DateTimeOffset.UtcNow.ToString("O"));
await versionCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
private static async Task<bool> ColumnExistsAsync(
SqliteConnection connection,
SqliteTransaction transaction,
string tableName,
string columnName,
CancellationToken cancellationToken)
{
await using SqliteCommand command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = $"PRAGMA table_info({tableName});";
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken)
.ConfigureAwait(false);
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
{
if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static async Task ExecuteNonQueryAsync(
SqliteConnection connection,
SqliteTransaction transaction,
string commandText,
CancellationToken cancellationToken)
{
await using SqliteCommand command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = commandText;
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
}
}
@@ -1,8 +1,13 @@
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Sessions;
// The gateway carries its own constraint-bearing identity downstream; the shared library also
// defines an ApiKeyIdentity (scopes + opaque constraints JSON), so disambiguate to the gateway type.
using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
public sealed class ConstraintEnforcer(
@@ -126,6 +131,7 @@ public sealed class ConstraintEnforcer(
KeyId: identity?.KeyId,
EventType: "constraint-denied",
RemoteAddress: null,
CreatedUtc: DateTimeOffset.UtcNow,
Details: $"{commandKind}: {target}: {failure.ConstraintName}: {failure.Message}"),
cancellationToken)
.ConfigureAwait(false);
@@ -1,9 +1,14 @@
using Grpc.Core;
using Grpc.Core.Interceptors;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
// The handler pushes the gateway's constraint-bearing identity; alias away the shared library's
// ApiKeyIdentity so the unqualified name resolves to the gateway type.
using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;
namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization;
public sealed class GatewayGrpcAuthorizationInterceptor(
@@ -57,25 +62,31 @@ public sealed class GatewayGrpcAuthorizationInterceptor(
}
string? authorizationHeader = context.RequestHeaders.GetValue("authorization");
ApiKeyVerificationResult verificationResult = await apiKeyVerifier
.VerifyAsync(authorizationHeader, context.CancellationToken)
// The shared verifier owns parse + pepper + lookup + revocation + constant-time compare,
// returning a discriminated failure rather than throwing. Every authentication failure maps
// to Unauthenticated with an opaque message; the client never learns which stage failed.
ApiKeyVerification verification = await apiKeyVerifier
.VerifyAsync(authorizationHeader ?? string.Empty, context.CancellationToken)
.ConfigureAwait(false);
if (!verificationResult.Succeeded || verificationResult.Identity is null)
if (!verification.Succeeded || verification.Identity is null)
{
throw new RpcException(new Status(
StatusCode.Unauthenticated,
"Missing or invalid API key."));
}
ApiKeyIdentity identity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity);
string requiredScope = scopeResolver.ResolveRequiredScope(request);
if (!verificationResult.Identity.Scopes.Contains(requiredScope))
if (!identity.Scopes.Contains(requiredScope))
{
throw new RpcException(new Status(
StatusCode.PermissionDenied,
$"API key is missing required scope '{requiredScope}'."));
}
return verificationResult.Identity;
return identity;
}
}
@@ -6,9 +6,10 @@
<ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.1" />
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.1" />
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.1" />
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.2" />
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.2" />
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.2" />
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.2" />
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
<PackageReference Include="ZB.MOM.WW.Health" Version="0.1.0" />
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
@@ -1,8 +1,6 @@
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
using ZB.MOM.WW.MxGateway.Server.Diagnostics;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
namespace ZB.MOM.WW.MxGateway.Tests.Diagnostics;
@@ -10,13 +8,8 @@ public sealed class AuthStoreHealthCheckTests
{
private static AuthSqliteConnectionFactory FactoryFor(string sqlitePath)
{
// GatewayOptions.Authentication and AuthenticationOptions.SqlitePath are both
// init-only, so populate them through object initializers.
var options = new GatewayOptions
{
Authentication = new AuthenticationOptions { SqlitePath = sqlitePath },
};
return new AuthSqliteConnectionFactory(Options.Create(options));
// The shared connection factory targets a database path directly.
return new AuthSqliteConnectionFactory(sqlitePath);
}
[Fact]
@@ -1,21 +1,37 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Admin;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
// The mapped identity is the gateway's constraint-bearing type; disambiguate from the library's.
using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardApiKeyManagementServiceTests
/// <summary>
/// Tests the gateway dashboard API-key management surface over the shared
/// <c>ZB.MOM.WW.Auth.ApiKeys</c> admin commands and stores (the gateway is the donor). The service
/// is exercised against a real temporary SQLite store so the create/revoke/rotate/delete flow,
/// dashboard audit vocabulary, mxgw token format, duplicate-id rejection and revoke-before-delete
/// rule are all proven end-to-end.
/// </summary>
public sealed class DashboardApiKeyManagementServiceTests : IDisposable
{
private readonly List<TempDatabaseDirectory> _tempDirectories = [];
/// <summary>Verifies that unauthorized users cannot create API keys.</summary>
[Fact]
public async Task CreateAsync_UnauthorizedUser_DoesNotCallStore()
public async Task CreateAsync_UnauthorizedUser_DoesNotCreate()
{
FakeApiKeyAdminStore adminStore = new();
DashboardApiKeyManagementService service = CreateService(adminStore);
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
DashboardApiKeyManagementResult result = await service.CreateAsync(
new ClaimsPrincipal(new ClaimsIdentity()),
@@ -23,17 +39,15 @@ public sealed class DashboardApiKeyManagementServiceTests
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(0, adminStore.CreateCount);
Assert.Empty(await ListAsync(services));
}
/// <summary>Verifies that authorized users can create keys with secret hashing and audit trail.</summary>
/// <summary>Verifies that authorized users create a verifiable, constrained key and audit it.</summary>
[Fact]
public async Task CreateAsync_AuthorizedUser_StoresHashOfSecretAndAudits()
public async Task CreateAsync_AuthorizedUser_CreatesVerifiableKeyAndAudits()
{
FakeApiKeyAdminStore adminStore = new();
FakeApiKeyAuditStore auditStore = new();
FakeApiKeySecretHasher hasher = new();
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore, hasher);
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
DashboardApiKeyManagementResult result = await service.CreateAsync(
CreateAuthorizedUser(),
@@ -43,42 +57,46 @@ public sealed class DashboardApiKeyManagementServiceTests
Assert.True(result.Succeeded);
Assert.NotNull(result.ApiKey);
Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal);
string secret = result.ApiKey["mxgw_operator01_".Length..];
Assert.Equal(secret, hasher.LastSecret);
Assert.DoesNotContain("mxgw_operator01_", hasher.LastSecret, StringComparison.Ordinal);
ApiKeyCreateRequest stored = Assert.Single(adminStore.CreatedRequests);
Assert.Equal("operator01", stored.KeyId);
Assert.Equal("Operator", stored.DisplayName);
Assert.Contains(GatewayScopes.SessionOpen, stored.Scopes);
Assert.Equal(["Area1/*"], stored.Constraints.BrowseSubtrees);
Assert.Contains(auditStore.Entries, entry =>
entry.EventType == "dashboard-create-key"
&& entry.KeyId == "operator01");
// The freshly minted token authenticates against the same store and surfaces its scopes.
ApiKeyVerification verification = await services
.GetRequiredService<IApiKeyVerifier>()
.VerifyAsync($"Bearer {result.ApiKey}", CancellationToken.None);
Assert.True(verification.Succeeded);
Assert.Contains(GatewayScopes.SessionOpen, verification.Identity!.Scopes);
// Constraints round-trip through the opaque JSON blob.
ApiKeyIdentity gatewayIdentity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity);
Assert.Equal(["Area1/*"], gatewayIdentity.EffectiveConstraints.BrowseSubtrees);
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
Assert.Contains(audit, entry => entry.EventType == "dashboard-create-key" && entry.KeyId == "operator01");
}
/// <summary>Verifies that unauthorized users cannot revoke API keys.</summary>
/// <summary>Verifies that creating a key whose id already exists is rejected.</summary>
[Fact]
public async Task RevokeAsync_UnauthorizedUser_DoesNotCallStore()
public async Task CreateAsync_DuplicateKeyId_ReportsConflict()
{
FakeApiKeyAdminStore adminStore = new();
DashboardApiKeyManagementService service = CreateService(adminStore);
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
DashboardApiKeyManagementResult result = await service.RevokeAsync(
new ClaimsPrincipal(new ClaimsIdentity()),
"operator01",
await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
DashboardApiKeyManagementResult duplicate = await service.CreateAsync(
CreateAuthorizedUser(),
CreateRequest(),
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(0, adminStore.RevokeCount);
Assert.False(duplicate.Succeeded);
Assert.Contains("already exists", duplicate.Message, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Verifies that authorized users can revoke keys with audit trail.</summary>
[Fact]
public async Task RevokeAsync_AuthorizedUser_RevokesAndAudits()
{
FakeApiKeyAdminStore adminStore = new() { RevokeResult = true };
FakeApiKeyAuditStore auditStore = new();
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore);
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
DashboardApiKeyManagementResult result = await service.RevokeAsync(
CreateAuthorizedUser(),
@@ -86,21 +104,23 @@ public sealed class DashboardApiKeyManagementServiceTests
CancellationToken.None);
Assert.True(result.Succeeded);
Assert.Equal("operator01", adminStore.LastRevokedKeyId);
Assert.Contains(auditStore.Entries, entry =>
ApiKeyListItem key = Assert.Single(await ListAsync(services));
Assert.NotNull(key.RevokedUtc);
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
Assert.Contains(audit, entry =>
entry.EventType == "dashboard-revoke-key"
&& entry.KeyId == "operator01"
&& entry.Details == "revoked");
}
/// <summary>Verifies that authorized users can rotate secret hashes with audit trail.</summary>
/// <summary>Verifies that authorized users can rotate a key's secret with audit trail.</summary>
[Fact]
public async Task RotateAsync_AuthorizedUser_RotatesHashAndAudits()
public async Task RotateAsync_AuthorizedUser_RotatesAndAudits()
{
FakeApiKeyAdminStore adminStore = new() { RotateResult = true };
FakeApiKeyAuditStore auditStore = new();
FakeApiKeySecretHasher hasher = new();
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore, hasher);
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
DashboardApiKeyManagementResult created = await service.CreateAsync(
CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
DashboardApiKeyManagementResult result = await service.RotateAsync(
CreateAuthorizedUser(),
@@ -110,36 +130,28 @@ public sealed class DashboardApiKeyManagementServiceTests
Assert.True(result.Succeeded);
Assert.NotNull(result.ApiKey);
Assert.StartsWith("mxgw_operator01_", result.ApiKey, StringComparison.Ordinal);
Assert.Equal(hasher.HashSecret(hasher.LastSecret!), adminStore.LastRotatedSecretHash);
Assert.Contains(auditStore.Entries, entry =>
Assert.NotEqual(created.ApiKey, result.ApiKey);
// Old token no longer authenticates; new one does.
IApiKeyVerifier verifier = services.GetRequiredService<IApiKeyVerifier>();
Assert.False((await verifier.VerifyAsync($"Bearer {created.ApiKey}", CancellationToken.None)).Succeeded);
Assert.True((await verifier.VerifyAsync($"Bearer {result.ApiKey}", CancellationToken.None)).Succeeded);
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
Assert.Contains(audit, entry =>
entry.EventType == "dashboard-rotate-key"
&& entry.KeyId == "operator01"
&& entry.Details == "rotated");
}
/// <summary>Verifies that unauthorized users cannot delete API keys.</summary>
[Fact]
public async Task DeleteAsync_UnauthorizedUser_DoesNotCallStore()
{
FakeApiKeyAdminStore adminStore = new();
DashboardApiKeyManagementService service = CreateService(adminStore);
DashboardApiKeyManagementResult result = await service.DeleteAsync(
new ClaimsPrincipal(new ClaimsIdentity()),
"operator01",
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(0, adminStore.DeleteCount);
}
/// <summary>Verifies that authorized users can delete revoked keys with audit trail.</summary>
[Fact]
public async Task DeleteAsync_AuthorizedUser_DeletesRevokedKeyAndAudits()
{
FakeApiKeyAdminStore adminStore = new() { DeleteResult = true };
FakeApiKeyAuditStore auditStore = new();
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore);
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
await service.RevokeAsync(CreateAuthorizedUser(), "operator01", CancellationToken.None);
DashboardApiKeyManagementResult result = await service.DeleteAsync(
CreateAuthorizedUser(),
@@ -147,27 +159,25 @@ public sealed class DashboardApiKeyManagementServiceTests
CancellationToken.None);
Assert.True(result.Succeeded);
Assert.Equal("operator01", adminStore.LastDeletedKeyId);
Assert.Contains(auditStore.Entries, entry =>
Assert.Empty(await ListAsync(services));
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
Assert.Contains(audit, entry =>
entry.EventType == "dashboard-delete-key"
&& entry.KeyId == "operator01"
&& entry.Details == "deleted");
}
/// <summary>
/// Tests-030: when the admin store refuses the delete (returns <c>false</c>), the service
/// still emits a <c>dashboard-delete-key</c> audit entry with <c>Details = "not-found-or-active"</c>
/// because <c>AppendAuditAsync</c> runs unconditionally after the store call. A regression that
/// moved the audit-append call inside the <c>if (deleted)</c> branch would silently drop the
/// audit trail for refused deletes — a real audit-completeness gap. This test pins both the
/// friendly-error response AND the unconditional audit entry.
/// When the key is still active (not revoked), the delete is refused but a
/// <c>dashboard-delete-key</c> audit entry with <c>Details = "not-found-or-active"</c> is still
/// written — audit completeness for refused deletes.
/// </summary>
[Fact]
public async Task DeleteAsync_WhenStoreRefuses_ReportsFriendlyErrorAndAudits()
public async Task DeleteAsync_ActiveKey_ReportsFriendlyErrorAndAudits()
{
FakeApiKeyAdminStore adminStore = new() { DeleteResult = false };
FakeApiKeyAuditStore auditStore = new();
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore);
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
await service.CreateAsync(CreateAuthorizedUser(), CreateRequest(), CancellationToken.None);
DashboardApiKeyManagementResult result = await service.DeleteAsync(
CreateAuthorizedUser(),
@@ -177,17 +187,14 @@ public sealed class DashboardApiKeyManagementServiceTests
Assert.False(result.Succeeded);
Assert.Contains("Revoke", result.Message, StringComparison.Ordinal);
ApiKeyAuditEntry auditEntry = Assert.Single(auditStore.Entries);
Assert.Equal("dashboard-delete-key", auditEntry.EventType);
Assert.Equal("operator01", auditEntry.KeyId);
Assert.Equal("not-found-or-active", auditEntry.Details);
IReadOnlyList<ApiKeyAuditEntry> audit = await ListAuditAsync(services);
ApiKeyAuditEntry deleteEntry = Assert.Single(
audit, entry => entry.EventType == "dashboard-delete-key");
Assert.Equal("operator01", deleteEntry.KeyId);
Assert.Equal("not-found-or-active", deleteEntry.Details);
}
/// <summary>
/// Tests-030: <see cref="DashboardApiKeyManagementService.DeleteAsync"/> calls
/// <c>ValidateKeyId</c> after the authorisation check. A blank key id must fail with the
/// shared "API key id is required." message before any store or audit call runs.
/// </summary>
/// <summary>A blank key id fails validation before any store or audit call runs.</summary>
/// <param name="blankKeyId">A blank or whitespace key identifier.</param>
[Theory]
[InlineData("")]
@@ -195,9 +202,8 @@ public sealed class DashboardApiKeyManagementServiceTests
[InlineData("\t")]
public async Task DeleteAsync_BlankKeyId_ReturnsFailure(string blankKeyId)
{
FakeApiKeyAdminStore adminStore = new();
FakeApiKeyAuditStore auditStore = new();
DashboardApiKeyManagementService service = CreateService(adminStore, auditStore);
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
DashboardApiKeyManagementResult result = await service.DeleteAsync(
CreateAuthorizedUser(),
@@ -205,20 +211,19 @@ public sealed class DashboardApiKeyManagementServiceTests
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(0, adminStore.DeleteCount);
Assert.Empty(auditStore.Entries);
Assert.Empty(await ListAuditAsync(services));
}
/// <summary>
/// Server-004 regression: the dashboard create path must reject a request
/// carrying a non-canonical scope string rather than persisting a key whose
/// scope the authorization resolver never matches.
/// Server-004 regression: the dashboard create path must reject a request carrying a
/// non-canonical scope string rather than persisting a key whose scope the authorization
/// resolver never matches.
/// </summary>
[Fact]
public async Task CreateAsync_UnknownScope_DoesNotCallStore()
public async Task CreateAsync_UnknownScope_DoesNotCreate()
{
FakeApiKeyAdminStore adminStore = new();
DashboardApiKeyManagementService service = CreateService(adminStore);
await using ServiceProvider services = BuildServices();
DashboardApiKeyManagementService service = CreateService(services);
DashboardApiKeyManagementRequest request = CreateRequest() with
{
@@ -233,25 +238,61 @@ public sealed class DashboardApiKeyManagementServiceTests
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(0, adminStore.CreateCount);
Assert.Empty(await ListAsync(services));
}
private static DashboardApiKeyManagementService CreateService(
FakeApiKeyAdminStore? adminStore = null,
FakeApiKeyAuditStore? auditStore = null,
FakeApiKeySecretHasher? hasher = null)
private DashboardApiKeyManagementService CreateService(ServiceProvider services)
{
DefaultHttpContext httpContext = new();
httpContext.Connection.RemoteIpAddress = System.Net.IPAddress.Loopback;
return new DashboardApiKeyManagementService(
new DashboardApiKeyAuthorization(),
adminStore ?? new FakeApiKeyAdminStore(),
auditStore ?? new FakeApiKeyAuditStore(),
hasher ?? new FakeApiKeySecretHasher(),
services.GetRequiredService<ApiKeyAdminCommands>(),
services.GetRequiredService<IApiKeyAdminStore>(),
services.GetRequiredService<IApiKeyAuditStore>(),
new HttpContextAccessor { HttpContext = httpContext });
}
private ServiceProvider BuildServices()
{
TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-dashboard-apikey-tests");
_tempDirectories.Add(directory);
IConfigurationRoot configuration = new ConfigurationBuilder()
.AddInMemoryCollection(
new Dictionary<string, string?>
{
["MxGateway:Authentication:SqlitePath"] = directory.DatabasePath(),
["MxGateway:ApiKeyPepper"] = "test-pepper"
})
.Build();
ServiceCollection services = new();
services.AddSingleton<IConfiguration>(configuration);
services.AddGatewayConfiguration(configuration);
services.AddSqliteAuthStore(configuration);
ServiceProvider provider = services.BuildServiceProvider(validateScopes: true);
// Production migrates the schema via the migration hosted service at startup; in these
// DI-only tests no host runs, so apply the (idempotent) migration up front.
provider.GetRequiredService<ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteAuthStoreMigrator>()
.MigrateAsync(CancellationToken.None).GetAwaiter().GetResult();
return provider;
}
private static Task<IReadOnlyList<ApiKeyListItem>> ListAsync(ServiceProvider services)
{
return services.GetRequiredService<IApiKeyAdminStore>().ListAsync(CancellationToken.None);
}
private static Task<IReadOnlyList<ApiKeyAuditEntry>> ListAuditAsync(ServiceProvider services)
{
return services.GetRequiredService<IApiKeyAuditStore>().ListRecentAsync(50, CancellationToken.None);
}
private static DashboardApiKeyManagementRequest CreateRequest()
{
return new DashboardApiKeyManagementRequest(
@@ -275,114 +316,14 @@ public sealed class DashboardApiKeyManagementServiceTests
return new ClaimsPrincipal(identity);
}
private sealed class FakeApiKeyAdminStore : IApiKeyAdminStore
/// <summary>Clears SQLite pools and deletes every temporary directory created by this test.</summary>
public void Dispose()
{
/// <summary>Gets the count of create operations performed.</summary>
public int CreateCount { get; private set; }
/// <summary>Gets the count of revoke operations performed.</summary>
public int RevokeCount { get; private set; }
/// <summary>Gets the count of delete operations performed.</summary>
public int DeleteCount { get; private set; }
/// <summary>Gets or sets the result value returned by revoke operations.</summary>
public bool RevokeResult { get; init; }
/// <summary>Gets or sets the result value returned by rotate operations.</summary>
public bool RotateResult { get; init; }
/// <summary>Gets or sets the result value returned by delete operations.</summary>
public bool DeleteResult { get; init; }
/// <summary>Gets the last key ID revoked.</summary>
public string? LastRevokedKeyId { get; private set; }
/// <summary>Gets the last key ID deleted.</summary>
public string? LastDeletedKeyId { get; private set; }
/// <summary>Gets the last secret hash rotated.</summary>
public byte[]? LastRotatedSecretHash { get; private set; }
/// <summary>Gets the list of create requests received.</summary>
public List<ApiKeyCreateRequest> CreatedRequests { get; } = [];
/// <inheritdoc />
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
foreach (TempDatabaseDirectory directory in _tempDirectories)
{
CreateCount++;
CreatedRequests.Add(request);
return Task.CompletedTask;
directory.Dispose();
}
/// <inheritdoc />
public Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
}
/// <inheritdoc />
public Task<bool> RevokeAsync(
string keyId,
DateTimeOffset revokedUtc,
CancellationToken cancellationToken)
{
RevokeCount++;
LastRevokedKeyId = keyId;
return Task.FromResult(RevokeResult);
}
/// <inheritdoc />
public Task<bool> RotateAsync(
string keyId,
byte[] secretHash,
DateTimeOffset rotatedUtc,
CancellationToken cancellationToken)
{
LastRotatedSecretHash = secretHash;
return Task.FromResult(RotateResult);
}
/// <inheritdoc />
public Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken)
{
DeleteCount++;
LastDeletedKeyId = keyId;
return Task.FromResult(DeleteResult);
}
}
private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore
{
/// <summary>Gets the list of audit entries appended.</summary>
public List<ApiKeyAuditEntry> Entries { get; } = [];
/// <inheritdoc />
public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken)
{
Entries.Add(entry);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(
int count,
CancellationToken cancellationToken)
{
return Task.FromResult<IReadOnlyList<ApiKeyAuditRecord>>([]);
}
}
private sealed class FakeApiKeySecretHasher : IApiKeySecretHasher
{
/// <summary>Gets the last secret hashed.</summary>
public string? LastSecret { get; private set; }
/// <inheritdoc />
public byte[] HashSecret(string secret)
{
LastSecret = secret;
return System.Text.Encoding.UTF8.GetBytes($"hash:{secret}");
}
_tempDirectories.Clear();
}
}
@@ -1,5 +1,6 @@
using System.Globalization;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
@@ -273,16 +274,15 @@ public sealed class DashboardSnapshotServiceTests
{
using GatewayMetrics metrics = new();
CountingApiKeyAdminStore apiKeyAdminStore = new(
new ApiKeyRecord(
new ApiKeyListItem(
KeyId: "operator01",
KeyPrefix: "mxgw_operator01",
SecretHash: [1, 2, 3],
KeyPrefix: "mxgw",
DisplayName: "Operator",
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
Constraints: ApiKeyConstraints.Empty with
ConstraintsJson: ApiKeyConstraintSerializer.Serialize(ApiKeyConstraints.Empty with
{
BrowseSubtrees = ["Area1/*"],
},
}),
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
LastUsedUtc: null,
RevokedUtc: null));
@@ -310,13 +310,12 @@ public sealed class DashboardSnapshotServiceTests
{
using GatewayMetrics metrics = new();
SequencedApiKeyAdminStore apiKeyAdminStore = new(
new ApiKeyRecord(
new ApiKeyListItem(
KeyId: "operator01",
KeyPrefix: "mxgw_operator01",
SecretHash: [1, 2, 3],
KeyPrefix: "mxgw",
DisplayName: "Operator",
Scopes: new HashSet<string>([GatewayScopes.MetadataRead], StringComparer.Ordinal),
Constraints: ApiKeyConstraints.Empty,
ConstraintsJson: null,
CreatedUtc: DateTimeOffset.Parse("2026-04-28T12:00:00Z", CultureInfo.InvariantCulture),
LastUsedUtc: null,
RevokedUtc: null));
@@ -425,63 +424,56 @@ public sealed class DashboardSnapshotServiceTests
private class FakeApiKeyAdminStore : IApiKeyAdminStore
{
/// <inheritdoc />
public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken)
public Task CreateAsync(ApiKeyRecord record, CancellationToken ct)
{
return Task.CompletedTask;
}
/// <inheritdoc />
public virtual Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
public virtual Task<IReadOnlyList<ApiKeyListItem>> ListAsync(CancellationToken ct)
{
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>([]);
return Task.FromResult<IReadOnlyList<ApiKeyListItem>>([]);
}
/// <inheritdoc />
public Task<bool> RevokeAsync(
string keyId,
DateTimeOffset revokedUtc,
CancellationToken cancellationToken)
public Task<bool> RevokeAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
{
return Task.FromResult(false);
}
/// <inheritdoc />
public Task<bool> RotateAsync(
string keyId,
byte[] secretHash,
DateTimeOffset rotatedUtc,
CancellationToken cancellationToken)
public Task<bool> RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct)
{
return Task.FromResult(false);
}
/// <inheritdoc />
public Task<bool> DeleteAsync(string keyId, CancellationToken cancellationToken)
public Task<bool> DeleteAsync(string keyId, CancellationToken ct)
{
return Task.FromResult(false);
}
}
private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore
private class CountingApiKeyAdminStore(params ApiKeyListItem[] records) : FakeApiKeyAdminStore
{
/// <summary>Gets the count of list operations performed.</summary>
public int ListCount { get; protected set; }
/// <inheritdoc />
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
public override Task<IReadOnlyList<ApiKeyListItem>> ListAsync(CancellationToken ct)
{
ListCount++;
return Task.FromResult<IReadOnlyList<ApiKeyRecord>>(records);
return Task.FromResult<IReadOnlyList<ApiKeyListItem>>(records);
}
}
private sealed class SequencedApiKeyAdminStore(ApiKeyRecord record) : CountingApiKeyAdminStore(record)
private sealed class SequencedApiKeyAdminStore(ApiKeyListItem record) : CountingApiKeyAdminStore(record)
{
/// <summary>Gets or sets a value indicating whether the next list operation should fail.</summary>
public bool FailNext { get; set; }
/// <inheritdoc />
public override Task<IReadOnlyList<ApiKeyRecord>> ListAsync(CancellationToken cancellationToken)
public override Task<IReadOnlyList<ApiKeyListItem>> ListAsync(CancellationToken ct)
{
if (FailNext)
{
@@ -490,7 +482,7 @@ public sealed class DashboardSnapshotServiceTests
throw new InvalidOperationException("Simulated SQLite failure.");
}
return base.ListAsync(cancellationToken);
return base.ListAsync(ct);
}
}
@@ -1,9 +1,13 @@
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
// The mapped identity is the gateway's constraint-bearing type; disambiguate from the library's.
using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
public sealed class ApiKeyAdminCliRunnerTests : IDisposable
@@ -33,14 +37,14 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
string apiKey = ReadApiKey(output.ToString());
IApiKeyVerifier verifier = services.GetRequiredService<IApiKeyVerifier>();
ApiKeyVerificationResult verification = await verifier.VerifyAsync($"Bearer {apiKey}", CancellationToken.None);
ApiKeyVerification verification = await verifier.VerifyAsync($"Bearer {apiKey}", CancellationToken.None);
Assert.True(verification.Succeeded);
Assert.NotNull(verification.Identity);
Assert.Equal("operator01", verification.Identity.KeyId);
Assert.Contains("session:open", verification.Identity.Scopes);
IReadOnlyList<ApiKeyAuditRecord> auditRecords = await services
IReadOnlyList<ApiKeyAuditEntry> auditRecords = await services
.GetRequiredService<IApiKeyAuditStore>()
.ListRecentAsync(10, CancellationToken.None);
@@ -98,14 +102,14 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
TextWriter.Null,
CancellationToken.None);
ApiKeyVerificationResult verification = await services
ApiKeyVerification verification = await services
.GetRequiredService<IApiKeyVerifier>()
.VerifyAsync($"Bearer {apiKey}", CancellationToken.None);
Assert.False(verification.Succeeded);
Assert.Equal(ApiKeyVerificationFailure.KeyRevoked, verification.Failure);
Assert.Equal(ApiKeyFailure.KeyRevoked, verification.Failure);
IReadOnlyList<ApiKeyAuditRecord> auditRecords = await services
IReadOnlyList<ApiKeyAuditEntry> auditRecords = await services
.GetRequiredService<IApiKeyAuditStore>()
.ListRecentAsync(10, CancellationToken.None);
@@ -141,11 +145,11 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
Assert.Equal(1, CountOccurrences(rotateJson, newApiKey));
IApiKeyVerifier verifier = services.GetRequiredService<IApiKeyVerifier>();
ApiKeyVerificationResult oldVerification = await verifier.VerifyAsync($"Bearer {oldApiKey}", CancellationToken.None);
ApiKeyVerificationResult newVerification = await verifier.VerifyAsync($"Bearer {newApiKey}", CancellationToken.None);
ApiKeyVerification oldVerification = await verifier.VerifyAsync($"Bearer {oldApiKey}", CancellationToken.None);
ApiKeyVerification newVerification = await verifier.VerifyAsync($"Bearer {newApiKey}", CancellationToken.None);
Assert.False(oldVerification.Succeeded);
Assert.Equal(ApiKeyVerificationFailure.SecretMismatch, oldVerification.Failure);
Assert.Equal(ApiKeyFailure.SecretMismatch, oldVerification.Failure);
Assert.True(newVerification.Succeeded);
}
@@ -203,13 +207,16 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
CancellationToken.None);
string apiKey = ReadApiKey(output.ToString());
ApiKeyVerificationResult verification = await services
ApiKeyVerification verification = await services
.GetRequiredService<IApiKeyVerifier>()
.VerifyAsync($"Bearer {apiKey}", CancellationToken.None);
Assert.True(verification.Succeeded);
Assert.Equal(["Area1/*"], verification.Identity!.EffectiveConstraints.BrowseSubtrees);
Assert.True(verification.Identity.EffectiveConstraints.ReadAlarmOnly);
// The shared verifier returns the opaque constraints JSON; map it to the gateway identity so
// the strongly-typed effective constraints round-trip can be asserted.
ApiKeyIdentity gatewayIdentity = GatewayApiKeyIdentityMapper.ToGatewayIdentity(verification.Identity!);
Assert.Equal(["Area1/*"], gatewayIdentity.EffectiveConstraints.BrowseSubtrees);
Assert.True(gatewayIdentity.EffectiveConstraints.ReadAlarmOnly);
}
@@ -246,7 +253,7 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable
ServiceCollection services = new();
services.AddSingleton<IConfiguration>(configuration);
services.AddGatewayConfiguration(configuration);
services.AddSqliteAuthStore();
services.AddSqliteAuthStore(configuration);
return services.BuildServiceProvider(validateScopes: true);
}
@@ -1,41 +0,0 @@
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
public sealed class ApiKeyParserTests
{
/// <summary>Verifies that TryParseAuthorizationHeader parses a valid Bearer token and returns the key ID and secret.</summary>
[Fact]
public void TryParseAuthorizationHeader_ValidBearerToken_ReturnsKeyIdAndSecret()
{
ApiKeyParser parser = new();
bool parsed = parser.TryParseAuthorizationHeader(
"Bearer mxgw_operator01_secret_value",
out ParsedApiKey? apiKey);
Assert.True(parsed);
Assert.NotNull(apiKey);
Assert.Equal("operator01", apiKey.KeyId);
Assert.Equal("secret_value", apiKey.Secret);
}
/// <summary>Verifies that TryParseAuthorizationHeader returns false for malformed tokens.</summary>
/// <param name="authorizationHeader">Malformed authorization header value.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("mxgw_operator01_secret")]
[InlineData("Bearer not-a-gateway-key")]
[InlineData("Bearer mxgw__secret")]
[InlineData("Bearer mxgw_operator01_")]
public void TryParseAuthorizationHeader_MalformedToken_ReturnsFalse(string? authorizationHeader)
{
ApiKeyParser parser = new();
bool parsed = parser.TryParseAuthorizationHeader(authorizationHeader, out ParsedApiKey? apiKey);
Assert.False(parsed);
Assert.Null(apiKey);
}
}
@@ -1,71 +0,0 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
public sealed class ApiKeySecretHasherTests
{
/// <summary>
/// Verifies identical pepper and secret produce identical hashes.
/// </summary>
[Fact]
public void HashSecret_SamePepperAndSecret_ReturnsSameHash()
{
ApiKeySecretHasher hasher = CreateHasher("pepper-one");
byte[] firstHash = hasher.HashSecret("raw-secret");
byte[] secondHash = hasher.HashSecret("raw-secret");
Assert.Equal(firstHash, secondHash);
Assert.NotEqual("raw-secret"u8.ToArray(), firstHash);
}
/// <summary>
/// Verifies different pepper values produce different hashes.
/// </summary>
[Fact]
public void HashSecret_DifferentPepper_ReturnsDifferentHash()
{
byte[] firstHash = CreateHasher("pepper-one").HashSecret("raw-secret");
byte[] secondHash = CreateHasher("pepper-two").HashSecret("raw-secret");
Assert.NotEqual(firstHash, secondHash);
}
/// <summary>
/// Verifies missing pepper throws an exception.
/// </summary>
[Fact]
public void HashSecret_MissingPepper_Throws()
{
ApiKeySecretHasher hasher = CreateHasher(pepper: null);
Assert.Throws<ApiKeyPepperUnavailableException>(() => hasher.HashSecret("raw-secret"));
}
private static ApiKeySecretHasher CreateHasher(string? pepper)
{
Dictionary<string, string?> values = [];
if (pepper is not null)
{
values["TestPepper"] = pepper;
}
IConfigurationRoot configuration = new ConfigurationBuilder()
.AddInMemoryCollection(values)
.Build();
GatewayOptions options = new()
{
Authentication = new AuthenticationOptions
{
PepperSecretName = "TestPepper"
}
};
return new ApiKeySecretHasher(configuration, Options.Create(options));
}
}
@@ -1,22 +1,30 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys;
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
/// <summary>
/// Parity tests for the shared <c>ZB.MOM.WW.Auth.ApiKeys</c> verifier as the gateway relies on it:
/// the <c>mxgw</c> token format, peppered HMAC-SHA256 secret hashing, constant-time comparison,
/// fail-closed discrimination (missing/unknown/revoked/wrong-secret/missing-pepper), and that the
/// raw secret never leaks into the result. The expected hash is computed here independently to keep
/// the test honest against the library's internal hasher.
/// </summary>
public sealed class ApiKeyVerifierTests
{
private static readonly ApiKeyOptions Options = new() { TokenPrefix = "mxgw" };
/// <summary>Verifies that VerifyAsync returns identity and scopes for a valid key.</summary>
[Fact]
public async Task VerifyAsync_ValidKey_ReturnsIdentityAndScopes()
{
ApiKeySecretHasher hasher = CreateHasher("pepper");
FakeApiKeyStore store = new(CreateRecord(hasher, revokedUtc: null));
ApiKeyVerifier verifier = new(new ApiKeyParser(), hasher, store);
FakeApiKeyStore store = new(CreateRecord("pepper", revokedUtc: null));
ApiKeyVerifier verifier = new(Options, store, new FakePepperProvider("pepper"));
ApiKeyVerificationResult result = await verifier.VerifyAsync(
ApiKeyVerification result = await verifier.VerifyAsync(
"Bearer mxgw_operator01_correct-secret",
CancellationToken.None);
@@ -33,11 +41,10 @@ public sealed class ApiKeyVerifierTests
[Fact]
public async Task VerifyAsync_ValidKey_DoesNotExposeRawSecretInResult()
{
ApiKeySecretHasher hasher = CreateHasher("pepper");
FakeApiKeyStore store = new(CreateRecord(hasher, revokedUtc: null));
ApiKeyVerifier verifier = new(new ApiKeyParser(), hasher, store);
FakeApiKeyStore store = new(CreateRecord("pepper", revokedUtc: null));
ApiKeyVerifier verifier = new(Options, store, new FakePepperProvider("pepper"));
ApiKeyVerificationResult result = await verifier.VerifyAsync(
ApiKeyVerification result = await verifier.VerifyAsync(
"Bearer mxgw_operator01_correct-secret",
CancellationToken.None);
@@ -46,58 +53,51 @@ public sealed class ApiKeyVerifierTests
Assert.DoesNotContain("correct-secret", serialized, StringComparison.Ordinal);
}
/// <summary>Verifies that VerifyAsync fails with unauthenticated status for a malformed key.</summary>
/// <summary>Verifies that VerifyAsync fails as missing/malformed for a malformed key.</summary>
/// <param name="authorizationHeader">Authorization header value to test.</param>
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("Bearer mxgw_operator01")]
[InlineData("Bearer wrong")]
public async Task VerifyAsync_MalformedKey_FailsUnauthenticated(string? authorizationHeader)
public async Task VerifyAsync_MalformedKey_FailsMissingOrMalformed(string authorizationHeader)
{
ApiKeyVerifier verifier = new(
new ApiKeyParser(),
CreateHasher("pepper"),
new FakeApiKeyStore(storedKey: null));
ApiKeyVerifier verifier = new(Options, new FakeApiKeyStore(storedKey: null), new FakePepperProvider("pepper"));
ApiKeyVerificationResult result = await verifier.VerifyAsync(
ApiKeyVerification result = await verifier.VerifyAsync(
authorizationHeader,
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(ApiKeyVerificationFailure.MissingOrMalformedCredentials, result.Failure);
Assert.Equal(ApiKeyFailure.MissingOrMalformed, result.Failure);
}
/// <summary>Verifies that VerifyAsync fails for an unknown key.</summary>
[Fact]
public async Task VerifyAsync_UnknownKey_Fails()
{
ApiKeyVerifier verifier = new(
new ApiKeyParser(),
CreateHasher("pepper"),
new FakeApiKeyStore(storedKey: null));
ApiKeyVerifier verifier = new(Options, new FakeApiKeyStore(storedKey: null), new FakePepperProvider("pepper"));
ApiKeyVerificationResult result = await verifier.VerifyAsync(
ApiKeyVerification result = await verifier.VerifyAsync(
"Bearer mxgw_missing_secret",
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(ApiKeyVerificationFailure.KeyNotFound, result.Failure);
Assert.Equal(ApiKeyFailure.KeyNotFound, result.Failure);
}
/// <summary>Verifies that VerifyAsync fails for a wrong secret.</summary>
/// <summary>Verifies that VerifyAsync fails for a wrong secret (constant-time compare rejects it).</summary>
[Fact]
public async Task VerifyAsync_WrongSecret_Fails()
{
ApiKeySecretHasher hasher = CreateHasher("pepper");
FakeApiKeyStore store = new(CreateRecord(hasher, revokedUtc: null));
ApiKeyVerifier verifier = new(new ApiKeyParser(), hasher, store);
FakeApiKeyStore store = new(CreateRecord("pepper", revokedUtc: null));
ApiKeyVerifier verifier = new(Options, store, new FakePepperProvider("pepper"));
ApiKeyVerificationResult result = await verifier.VerifyAsync(
ApiKeyVerification result = await verifier.VerifyAsync(
"Bearer mxgw_operator01_wrong-secret",
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(ApiKeyVerificationFailure.SecretMismatch, result.Failure);
Assert.Equal(ApiKeyFailure.SecretMismatch, result.Failure);
Assert.False(store.MarkedUsed);
}
@@ -105,74 +105,62 @@ public sealed class ApiKeyVerifierTests
[Fact]
public async Task VerifyAsync_RevokedKey_Fails()
{
ApiKeySecretHasher hasher = CreateHasher("pepper");
FakeApiKeyStore store = new(CreateRecord(hasher, DateTimeOffset.UtcNow));
ApiKeyVerifier verifier = new(new ApiKeyParser(), hasher, store);
FakeApiKeyStore store = new(CreateRecord("pepper", DateTimeOffset.UtcNow));
ApiKeyVerifier verifier = new(Options, store, new FakePepperProvider("pepper"));
ApiKeyVerificationResult result = await verifier.VerifyAsync(
ApiKeyVerification result = await verifier.VerifyAsync(
"Bearer mxgw_operator01_correct-secret",
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(ApiKeyVerificationFailure.KeyRevoked, result.Failure);
Assert.Equal(ApiKeyFailure.KeyRevoked, result.Failure);
Assert.False(store.MarkedUsed);
}
/// <summary>Verifies that VerifyAsync fails when the pepper is missing.</summary>
/// <summary>Verifies that VerifyAsync fails closed when the pepper is missing.</summary>
[Fact]
public async Task VerifyAsync_MissingPepper_Fails()
{
FakeApiKeyStore store = new(CreateRecord(CreateHasher("pepper"), revokedUtc: null));
ApiKeyVerifier verifier = new(new ApiKeyParser(), CreateHasher(pepper: null), store);
FakeApiKeyStore store = new(CreateRecord("pepper", revokedUtc: null));
ApiKeyVerifier verifier = new(Options, store, new FakePepperProvider(pepper: null));
ApiKeyVerificationResult result = await verifier.VerifyAsync(
ApiKeyVerification result = await verifier.VerifyAsync(
"Bearer mxgw_operator01_correct-secret",
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(ApiKeyVerificationFailure.PepperUnavailable, result.Failure);
Assert.Equal(ApiKeyFailure.PepperUnavailable, result.Failure);
}
private static ApiKeyRecord CreateRecord(ApiKeySecretHasher hasher, DateTimeOffset? revokedUtc)
/// <summary>Computes HMAC-SHA256(pepper, secret) — the documented peppered-hash format.</summary>
private static byte[] PepperedHash(string secret, string pepper)
{
using HMACSHA256 hmac = new(Encoding.UTF8.GetBytes(pepper));
return hmac.ComputeHash(Encoding.UTF8.GetBytes(secret));
}
private static ApiKeyRecord CreateRecord(string pepper, DateTimeOffset? revokedUtc)
{
return new ApiKeyRecord(
KeyId: "operator01",
KeyPrefix: "mxgw_operator01",
SecretHash: hasher.HashSecret("correct-secret"),
KeyPrefix: "mxgw",
SecretHash: PepperedHash("correct-secret", pepper),
DisplayName: "Operator Key",
Scopes: new HashSet<string>(StringComparer.Ordinal)
{
"session:open",
"events:read"
},
Constraints: ApiKeyConstraints.Empty,
ConstraintsJson: null,
CreatedUtc: DateTimeOffset.UtcNow,
LastUsedUtc: null,
RevokedUtc: revokedUtc);
}
private static ApiKeySecretHasher CreateHasher(string? pepper)
private sealed class FakePepperProvider(string? pepper) : IApiKeyPepperProvider
{
Dictionary<string, string?> values = [];
if (pepper is not null)
{
values["TestPepper"] = pepper;
}
IConfigurationRoot configuration = new ConfigurationBuilder()
.AddInMemoryCollection(values)
.Build();
GatewayOptions options = new()
{
Authentication = new AuthenticationOptions
{
PepperSecretName = "TestPepper"
}
};
return new ApiKeySecretHasher(configuration, Options.Create(options));
/// <summary>Returns the configured pepper (or null to simulate an unavailable pepper).</summary>
public string? GetPepper() => pepper;
}
/// <summary>Fake in-memory API key store for testing.</summary>
@@ -181,18 +169,14 @@ public sealed class ApiKeyVerifierTests
/// <summary>Gets whether the key was marked as used.</summary>
public bool MarkedUsed { get; private set; }
/// <summary>Finds an API key record by its ID.</summary>
/// <param name="keyId">Identifier of the API key.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken cancellationToken)
/// <inheritdoc />
public Task<ApiKeyRecord?> FindByKeyIdAsync(string keyId, CancellationToken ct)
{
return Task.FromResult(storedKey?.KeyId == keyId ? storedKey : null);
}
/// <summary>Finds an active (non-revoked) API key record by its ID.</summary>
/// <param name="keyId">Identifier of the API key.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken)
/// <inheritdoc />
public Task<ApiKeyRecord?> FindActiveByKeyIdAsync(string keyId, CancellationToken ct)
{
return Task.FromResult(
storedKey?.KeyId == keyId && storedKey.RevokedUtc is null
@@ -200,11 +184,8 @@ public sealed class ApiKeyVerifierTests
: null);
}
/// <summary>Marks an API key as used at the specified time.</summary>
/// <param name="keyId">Identifier of the API key.</param>
/// <param name="usedUtc">Timestamp when the key was used.</param>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
public Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken)
/// <inheritdoc />
public Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct)
{
MarkedUsed = storedKey?.KeyId == keyId;
@@ -2,6 +2,8 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
using ZB.MOM.WW.MxGateway.Server;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
@@ -9,13 +11,17 @@ using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;
/// <summary>
/// Tests for <see cref="SqliteAuthStore"/>.
/// Parity tests for the shared <c>ZB.MOM.WW.Auth.ApiKeys</c> SQLite store as wired by the gateway.
/// The gateway is the donor this store was extracted from; these tests pin that existing deployed
/// <c>gateway-auth.db</c> databases (schema version 2, same tables/columns/scopes encoding) remain
/// readable and that migration is idempotent and refuses a newer on-disk schema.
/// </summary>
public sealed class SqliteAuthStoreTests : IDisposable
{
private readonly List<TempDatabaseDirectory> _tempDirectories = [];
/// <summary>
/// Verifies that MigrateAsync initializes the database schema.
/// Verifies that MigrateAsync initializes the database schema at the donor's version (2).
/// </summary>
[Fact]
public async Task MigrateAsync_EmptyDatabase_InitializesCurrentSchema()
@@ -23,7 +29,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
string databasePath = CreateTempDatabasePath();
await using ServiceProvider services = BuildAuthServices(databasePath);
IAuthStoreMigrator migrator = services.GetRequiredService<IAuthStoreMigrator>();
SqliteAuthStoreMigrator migrator = services.GetRequiredService<SqliteAuthStoreMigrator>();
await migrator.MigrateAsync(CancellationToken.None);
@@ -42,7 +48,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
await CreateVersionZeroDatabaseAsync(databasePath);
await using ServiceProvider services = BuildAuthServices(databasePath);
IAuthStoreMigrator migrator = services.GetRequiredService<IAuthStoreMigrator>();
SqliteAuthStoreMigrator migrator = services.GetRequiredService<SqliteAuthStoreMigrator>();
await migrator.MigrateAsync(CancellationToken.None);
await migrator.MigrateAsync(CancellationToken.None);
@@ -74,14 +80,15 @@ public sealed class SqliteAuthStoreTests : IDisposable
}
/// <summary>
/// Verifies that FindActiveByKeyIdAsync returns an active key.
/// Verifies that FindActiveByKeyIdAsync returns an active key, reading a row whose columns match
/// the donor schema (peppered secret_hash BLOB, ordinal-sorted scopes JSON).
/// </summary>
[Fact]
public async Task FindActiveByKeyIdAsync_ExistingActiveKey_ReturnsKey()
{
string databasePath = CreateTempDatabasePath();
await using ServiceProvider services = BuildAuthServices(databasePath);
await services.GetRequiredService<IAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
await services.GetRequiredService<SqliteAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
await InsertApiKeyAsync(databasePath, revokedUtc: null);
IApiKeyStore store = services.GetRequiredService<IApiKeyStore>();
@@ -104,7 +111,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
{
string databasePath = CreateTempDatabasePath();
await using ServiceProvider services = BuildAuthServices(databasePath);
await services.GetRequiredService<IAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
await services.GetRequiredService<SqliteAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
await InsertApiKeyAsync(databasePath, DateTimeOffset.UtcNow);
IApiKeyStore store = services.GetRequiredService<IApiKeyStore>();
@@ -127,7 +134,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
{
string databasePath = CreateTempDatabasePath();
await using ServiceProvider services = BuildAuthServices(databasePath);
await services.GetRequiredService<IAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
await services.GetRequiredService<SqliteAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
IApiKeyAuditStore auditStore = services.GetRequiredService<IApiKeyAuditStore>();
@@ -136,14 +143,15 @@ public sealed class SqliteAuthStoreTests : IDisposable
KeyId: "test-key",
EventType: "lookup",
RemoteAddress: "127.0.0.1",
CreatedUtc: DateTimeOffset.UtcNow,
Details: "matched active key"),
CancellationToken.None);
IReadOnlyList<ApiKeyAuditRecord> records = await auditStore.ListRecentAsync(
IReadOnlyList<ApiKeyAuditEntry> records = await auditStore.ListRecentAsync(
10,
CancellationToken.None);
ApiKeyAuditRecord record = Assert.Single(records);
ApiKeyAuditEntry record = Assert.Single(records);
Assert.Equal("test-key", record.KeyId);
Assert.Equal("lookup", record.EventType);
Assert.Equal("127.0.0.1", record.RemoteAddress);
@@ -189,7 +197,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
ServiceCollection services = new();
services.AddSingleton<IConfiguration>(configuration);
services.AddGatewayConfiguration(configuration);
services.AddSqliteAuthStore();
services.AddSqliteAuthStore(configuration);
return services.BuildServiceProvider(validateScopes: true);
}
@@ -288,7 +296,7 @@ public sealed class SqliteAuthStoreTests : IDisposable
command.Parameters.AddWithValue("$display_name", "Test Key");
command.Parameters.AddWithValue(
"$scopes",
ApiKeyScopeSerializer.Serialize(new HashSet<string>(StringComparer.Ordinal) { "session:open", "events:read" }));
ScopeSerializer.Serialize(new HashSet<string>(StringComparer.Ordinal) { "session:open", "events:read" }));
command.Parameters.AddWithValue("$created_utc", DateTimeOffset.UtcNow.ToString("O"));
command.Parameters.AddWithValue("$revoked_utc", revokedUtc?.ToString("O") ?? (object)DBNull.Value);
@@ -1,3 +1,4 @@
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
@@ -6,6 +7,10 @@ using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ZB.MOM.WW.MxGateway.Server.Sessions;
// ConstraintEnforcer enforces against the gateway's constraint-bearing identity; the shared library
// also defines an ApiKeyIdentity, so disambiguate to the gateway type.
using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization;
public sealed class ConstraintEnforcerTests
@@ -250,9 +255,9 @@ public sealed class ConstraintEnforcerTests
}
/// <inheritdoc />
public Task<IReadOnlyList<ApiKeyAuditRecord>> ListRecentAsync(int count, CancellationToken cancellationToken)
public Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
{
return Task.FromResult<IReadOnlyList<ApiKeyAuditRecord>>([]);
return Task.FromResult<IReadOnlyList<ApiKeyAuditEntry>>([]);
}
}
}
@@ -2,6 +2,7 @@ using System.Runtime.CompilerServices;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
@@ -12,6 +13,11 @@ using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
// The handler exposes the gateway's constraint-bearing identity; alias the shared library identity
// (returned by the verifier) so the two can be referenced unambiguously.
using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity;
using LibApiKeyIdentity = ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity;
namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization;
public sealed class GatewayGrpcAuthorizationInterceptorTests
@@ -21,8 +27,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
public async Task UnaryServerHandler_MissingApiKey_ReturnsUnauthenticated()
{
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
new FakeApiKeyVerifier(ApiKeyVerificationResult.Fail(
ApiKeyVerificationFailure.MissingOrMalformedCredentials)),
new FakeApiKeyVerifier(Failure(ApiKeyFailure.MissingOrMalformed)),
new GatewayRequestIdentityAccessor());
RpcException exception = await Assert.ThrowsAsync<RpcException>(
@@ -40,7 +45,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
public async Task UnaryServerHandler_InvalidApiKey_DoesNotExposeRawCredentialInStatus()
{
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
new FakeApiKeyVerifier(ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch)),
new FakeApiKeyVerifier(Failure(ApiKeyFailure.SecretMismatch)),
new GatewayRequestIdentityAccessor());
RpcException exception = await Assert.ThrowsAsync<RpcException>(
@@ -146,8 +151,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
public async Task UnaryServerHandler_AuthenticationDisabled_SkipsApiKeyVerification()
{
GatewayRequestIdentityAccessor identityAccessor = new();
FakeApiKeyVerifier verifier = new(ApiKeyVerificationResult.Fail(
ApiKeyVerificationFailure.MissingOrMalformedCredentials));
FakeApiKeyVerifier verifier = new(Failure(ApiKeyFailure.MissingOrMalformed));
GatewayGrpcAuthorizationInterceptor interceptor = CreateInterceptor(
verifier,
identityAccessor,
@@ -374,13 +378,21 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
}));
}
private static ApiKeyVerificationResult SuccessWithScopes(params string[] scopes)
private static ApiKeyVerification SuccessWithScopes(params string[] scopes)
{
return ApiKeyVerificationResult.Success(new ApiKeyIdentity(
KeyId: "operator01",
KeyPrefix: "mxgw_operator01",
DisplayName: "Operator Key",
Scopes: new HashSet<string>(scopes, StringComparer.Ordinal)));
return new ApiKeyVerification(
Succeeded: true,
Identity: new LibApiKeyIdentity(
KeyId: "operator01",
DisplayName: "Operator Key",
Scopes: new HashSet<string>(scopes, StringComparer.Ordinal),
Constraints: null),
Failure: null);
}
private static ApiKeyVerification Failure(ApiKeyFailure failure)
{
return new ApiKeyVerification(Succeeded: false, Identity: null, Failure: failure);
}
private static TestServerCallContext ContextWithAuthorization(string authorizationHeader)
@@ -495,7 +507,7 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
}
}
private sealed class FakeApiKeyVerifier(ApiKeyVerificationResult result) : IApiKeyVerifier
private sealed class FakeApiKeyVerifier(ApiKeyVerification result) : IApiKeyVerifier
{
/// <summary>Gets whether the verifier was called.</summary>
public bool WasCalled { get; private set; }
@@ -505,11 +517,11 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests
/// <summary>Verifies the authorization header against stored result.</summary>
/// <param name="authorizationHeader">The authorization header to verify.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Configured verification result.</returns>
public Task<ApiKeyVerificationResult> VerifyAsync(
string? authorizationHeader,
CancellationToken cancellationToken)
public Task<ApiKeyVerification> VerifyAsync(
string authorizationHeader,
CancellationToken ct)
{
WasCalled = true;
LastAuthorizationHeader = authorizationHeader;