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:
@@ -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;
|
||||
}
|
||||
}
|
||||
-4
@@ -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);
|
||||
-29
@@ -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;
|
||||
}
|
||||
}
|
||||
+63
-10
@@ -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_<id>_<secret></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);
|
||||
|
||||
+16
-5
@@ -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]
|
||||
|
||||
+150
-209
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-12
@@ -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>>([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+28
-16
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user