diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs index 2567e97..a58f3d4 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardApiKeyManagementService.cs @@ -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"; /// Determines whether the user can manage API keys. /// The authenticated user principal. @@ -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__ 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}"; - } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs index 35b10bc..290879c 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs @@ -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)) diff --git a/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/AuthStoreHealthCheck.cs b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/AuthStoreHealthCheck.cs index 59f1544..30766a5 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/AuthStoreHealthCheck.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Diagnostics/AuthStoreHealthCheck.cs @@ -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; diff --git a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs index 62eb5cc..82a68db 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs @@ -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( diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs index f2c99af..24d01e6 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAdminCliRunner.cs @@ -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; /// /// Executes API key administration commands from the CLI. /// -public sealed class ApiKeyAdminCliRunner( - IAuthStoreMigrator migrator, - IApiKeyAdminStore adminStore, - IApiKeyAuditStore auditStore, - IApiKeySecretHasher hasher) +/// +/// The create/revoke/rotate/list/init-db verbs (secret generation, peppered hashing, token +/// assembly and per-action audit) are delegated to the shared +/// . This runner adapts the gateway's strongly-typed command and +/// output DTOs (which carry ) onto the library's JSON-based contract. +/// +public sealed class ApiKeyAdminCliRunner(ApiKeyAdminCommands commands) { private static readonly JsonSerializerOptions JsonOptions = new() { @@ -44,8 +48,7 @@ public sealed class ApiKeyAdminCliRunner( private async Task 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 ListKeysAsync(CancellationToken cancellationToken) { - await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false); - IReadOnlyList 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 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 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."); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAuditEntry.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAuditEntry.cs deleted file mode 100644 index 543fe30..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAuditEntry.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; - -public sealed record ApiKeyAuditEntry( - string? KeyId, - string EventType, - string? RemoteAddress, - string? Details); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAuditRecord.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAuditRecord.cs deleted file mode 100644 index 44d3240..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyAuditRecord.cs +++ /dev/null @@ -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); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyCreateRequest.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyCreateRequest.cs deleted file mode 100644 index 2092fb5..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyCreateRequest.cs +++ /dev/null @@ -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 Scopes, - ApiKeyConstraints Constraints, - DateTimeOffset CreatedUtc); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyParser.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyParser.cs deleted file mode 100644 index 9a60da4..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyParser.cs +++ /dev/null @@ -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_"; - - /// Attempts to parse a Bearer token from an Authorization header and extract the API key ID and secret. - /// Authorization header value to parse. - /// Parsed API key with ID and secret, or null if parsing failed. - /// True if the header was successfully parsed; otherwise, false. - 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; - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyPepperUnavailableException.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyPepperUnavailableException.cs deleted file mode 100644 index 3bf9888..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyPepperUnavailableException.cs +++ /dev/null @@ -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."); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyRecord.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyRecord.cs deleted file mode 100644 index abe5bff..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyRecord.cs +++ /dev/null @@ -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 Scopes, - ApiKeyConstraints Constraints, - DateTimeOffset CreatedUtc, - DateTimeOffset? LastUsedUtc, - DateTimeOffset? RevokedUtc); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs deleted file mode 100644 index 297aeec..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyRecordReader.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Microsoft.Data.Sqlite; - -namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; - -/// Reads API key records from SQLite query results. -public static class ApiKeyRecordReader -{ - /// Deserializes a row from the API key table into an ApiKeyRecord. - /// The data reader positioned at the API key row. - /// The deserialized API key record. - 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); - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyScopeSerializer.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyScopeSerializer.cs deleted file mode 100644 index d098833..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyScopeSerializer.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Text.Json; - -namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; - -public static class ApiKeyScopeSerializer -{ - /// Serializes scopes to JSON string. - /// The scopes to serialize. - /// JSON string representation. - public static string Serialize(IReadOnlySet scopes) - { - return JsonSerializer.Serialize(scopes.Order(StringComparer.Ordinal)); - } - - /// Deserializes scopes from JSON string. - /// The JSON string to deserialize. - /// Deserialized scopes set. - public static IReadOnlySet Deserialize(string value) - { - if (string.IsNullOrWhiteSpace(value)) - { - return new HashSet(StringComparer.Ordinal); - } - - string[]? scopes = JsonSerializer.Deserialize(value); - - return new HashSet(scopes ?? [], StringComparer.Ordinal); - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeySecretGenerator.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeySecretGenerator.cs deleted file mode 100644 index 70ac7d6..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeySecretGenerator.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Security.Cryptography; - -namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; - -/// Generates cryptographically secure API key secrets. -public static class ApiKeySecretGenerator -{ - /// Generates a new random API key secret string. - public static string Generate() - { - Span bytes = stackalloc byte[32]; - RandomNumberGenerator.Fill(bytes); - - return Convert.ToBase64String(bytes) - .TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeySecretHasher.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeySecretHasher.cs deleted file mode 100644 index a04cf62..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeySecretHasher.cs +++ /dev/null @@ -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 options) : IApiKeySecretHasher -{ - /// Hashes an API key secret with pepper using HMAC-SHA256. - /// The secret to hash. - /// The hashed secret. - 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; - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerificationFailure.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerificationFailure.cs deleted file mode 100644 index 27b12d4..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerificationFailure.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; - -public enum ApiKeyVerificationFailure -{ - None, - MissingOrMalformedCredentials, - PepperUnavailable, - KeyNotFound, - KeyRevoked, - SecretMismatch -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerificationResult.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerificationResult.cs deleted file mode 100644 index 4c1d469..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerificationResult.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; - -public sealed record ApiKeyVerificationResult( - bool Succeeded, - ApiKeyIdentity? Identity, - ApiKeyVerificationFailure Failure) -{ - /// - /// Creates a successful verification result. - /// - /// API key identity. - /// Success result. - public static ApiKeyVerificationResult Success(ApiKeyIdentity identity) - { - return new ApiKeyVerificationResult( - Succeeded: true, - Identity: identity, - Failure: ApiKeyVerificationFailure.None); - } - - /// - /// Creates a failed verification result. - /// - /// Verification failure reason. - /// Failure result. - public static ApiKeyVerificationResult Fail(ApiKeyVerificationFailure failure) - { - return new ApiKeyVerificationResult( - Succeeded: false, - Identity: null, - Failure: failure); - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs deleted file mode 100644 index 3344e3d..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ApiKeyVerifier.cs +++ /dev/null @@ -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 -{ - /// - /// Verifies an API key from an authorization header asynchronously. - /// - /// Authorization header value. - /// Cancellation token. - /// Verification result. - public async Task 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)); - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs deleted file mode 100644 index 1ba5867..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthSqliteConnectionFactory.cs +++ /dev/null @@ -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; - -/// -/// Factory for creating SQLite connections to the authentication store. -/// -public sealed class AuthSqliteConnectionFactory(IOptions options) -{ - /// - /// Busy timeout applied to every auth-store connection. SQLite retries a busy - /// database for this long before surfacing SQLITE_BUSY, so the concurrent - /// MarkKeyUsedAsync / audit-append writers degrade gracefully under load - /// instead of failing the request path. - /// - private static readonly TimeSpan BusyTimeout = TimeSpan.FromSeconds(5); - - /// - /// Creates an unopened SQLite connection to the auth database. Prefer - /// , which also applies WAL journaling and the - /// busy timeout. - /// - 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()); - } - - /// - /// 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 SQLITE_BUSY as a hard failure. - /// - /// Cancellation token for the operation. - /// An opened and configured SQLite connection. - public async Task 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); - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreMigrationException.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreMigrationException.cs deleted file mode 100644 index 303cdc2..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreMigrationException.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; - -public sealed class AuthStoreMigrationException(string message) : InvalidOperationException(message); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreMigrationHostedService.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreMigrationHostedService.cs deleted file mode 100644 index abc0dd4..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreMigrationHostedService.cs +++ /dev/null @@ -1,29 +0,0 @@ -using Microsoft.Extensions.Options; -using ZB.MOM.WW.MxGateway.Server.Configuration; - -namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; - -/// -/// Hosted service that runs authentication store migrations on startup. -/// -public sealed class AuthStoreMigrationHostedService( - IOptions options, - IAuthStoreMigrator migrator) : IHostedService -{ - /// - public async Task StartAsync(CancellationToken cancellationToken) - { - AuthenticationOptions authentication = options.Value.Authentication; - - if (authentication.Mode == AuthenticationMode.ApiKey && authentication.RunMigrationsOnStartup) - { - await migrator.MigrateAsync(cancellationToken).ConfigureAwait(false); - } - } - - /// - public Task StopAsync(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs index 959caa5..4c99ff2 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/AuthStoreServiceCollectionExtensions.cs @@ -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; /// /// Extension methods for configuring the SQLite authentication store. /// +/// +/// The peppered-HMAC API-key pipeline (token format, hashing, constant-time compare, SQLite +/// schema, stores, verifier and migration) is provided by the shared +/// ZB.MOM.WW.Auth.ApiKeys library, of which this gateway is the donor. This wiring binds +/// the library's from the gateway's MxGateway:Authentication +/// section and layers the gateway-specific constraint enforcement, gRPC interceptor, CLI and +/// dashboard on top. +/// public static class AuthStoreServiceCollectionExtensions { + /// The configuration section the gateway binds API-key options from. + public const string AuthenticationSectionPath = "MxGateway:Authentication"; + + /// The gateway API-key token prefix (token format mxgw_<id>_<secret>). + public const string TokenPrefix = "mxgw"; + + /// The configuration key the API-key pepper is resolved from. + public const string PepperSecretName = "MxGateway:ApiKeyPepper"; + /// /// Adds the SQLite authentication store and related services to the dependency container. /// /// Service collection to configure. + /// Application configuration carrying the API-key options. /// The service collection for chaining. - public static IServiceCollection AddSqliteAuthStore(this IServiceCollection services) + public static IServiceCollection AddSqliteAuthStore( + this IServiceCollection services, + IConfiguration configuration) { - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); + 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 + { + [$"{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>().Value, + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddHostedService(); return services; } diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/GatewayApiKeyIdentityMapper.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/GatewayApiKeyIdentityMapper.cs new file mode 100644 index 0000000..ed55248 --- /dev/null +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/GatewayApiKeyIdentityMapper.cs @@ -0,0 +1,43 @@ +using LibApiKeyIdentity = ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyIdentity; + +namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; + +/// +/// Maps the shared (which +/// carries the key's scopes plus the opaque constraints JSON blob) onto the gateway's +/// (which exposes the deserialized +/// the downstream authorization code enforces). +/// +/// +/// The shared verifier does not interpret the constraints column; it returns the stored +/// JSON verbatim in . +/// This mapper re-hydrates it via so the gateway's +/// constraint enforcement (ConstraintEnforcer) and request-identity accessor continue +/// to operate on the strongly-typed model unchanged. +/// +public static class GatewayApiKeyIdentityMapper +{ + /// + /// Converts a shared API-key identity into the gateway identity, deserializing the opaque + /// constraints JSON into . + /// + /// The shared identity returned by the library verifier. + /// The gateway identity carrying the effective constraints. + 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)); + } +} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs deleted file mode 100644 index a5d7c10..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAdminStore.cs +++ /dev/null @@ -1,53 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; - -public interface IApiKeyAdminStore -{ - /// - /// Creates a new API key asynchronously. - /// - /// API key creation request. - /// Cancellation token. - /// Completed task. - Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken); - - /// - /// Lists all API keys asynchronously. - /// - /// Cancellation token. - /// List of API key records. - Task> ListAsync(CancellationToken cancellationToken); - - /// - /// Revokes an API key asynchronously. - /// - /// Key identifier. - /// Revocation timestamp. - /// Cancellation token. - /// True if revoked; otherwise false. - Task RevokeAsync(string keyId, DateTimeOffset revokedUtc, CancellationToken cancellationToken); - - /// - /// Rotates an API key secret asynchronously. - /// - /// Key identifier. - /// New secret hash. - /// Rotation timestamp. - /// Cancellation token. - /// True if rotated; otherwise false. - Task RotateAsync( - string keyId, - byte[] secretHash, - DateTimeOffset rotatedUtc, - CancellationToken cancellationToken); - - /// - /// 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. - /// - /// Key identifier. - /// Cancellation token. - /// True if a revoked key was deleted; false if the key is missing or active. - Task DeleteAsync(string keyId, CancellationToken cancellationToken); -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAuditStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAuditStore.cs deleted file mode 100644 index c672a39..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyAuditStore.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; - -/// -/// Stores and retrieves audit events for API key operations. -/// -public interface IApiKeyAuditStore -{ - /// - /// Appends an audit entry to the audit log. - /// - /// Audit entry to record. - /// Token to cancel the asynchronous operation. - /// Asynchronous task representing the append operation. - Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken); - - /// - /// Lists the most recent audit entries, up to the specified count. - /// - /// Maximum number of entries to return. - /// Token to cancel the asynchronous operation. - /// Asynchronous task returning the list of audit records. - Task> ListRecentAsync(int count, CancellationToken cancellationToken); -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyParser.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyParser.cs deleted file mode 100644 index ed78417..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyParser.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; - -public interface IApiKeyParser -{ - /// Attempts to parse an authorization header and extract the API key. - /// Authorization header value to parse. - /// Parsed API key if successful. - bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey); -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeySecretHasher.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeySecretHasher.cs deleted file mode 100644 index 3359caa..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeySecretHasher.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; - -public interface IApiKeySecretHasher -{ - /// Hashes an API key secret and returns the hash bytes. - /// API key secret to hash. - byte[] HashSecret(string secret); -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyStore.cs deleted file mode 100644 index 5ffdd56..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyStore.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; - -/// Persists API keys and audit records for authentication and accounting. -public interface IApiKeyStore -{ - /// Retrieves an API key by ID regardless of revocation status. - /// Identifier of the API key. - /// Token to cancel the asynchronous operation. - Task FindByKeyIdAsync(string keyId, CancellationToken cancellationToken); - - /// Retrieves an active (non-revoked) API key by ID. - /// Identifier of the API key. - /// Token to cancel the asynchronous operation. - Task FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken); - - /// Records that an API key was used for auditing and tracking. - /// Identifier of the API key. - /// Timestamp when the key was used in UTC. - /// Token to cancel the asynchronous operation. - Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken); -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyVerifier.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyVerifier.cs deleted file mode 100644 index 977ca9e..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IApiKeyVerifier.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; - -/// Verifies API key authorization headers and returns the authenticated identity. -public interface IApiKeyVerifier -{ - /// Parses and verifies an authorization header, returning success with identity or a failure reason. - /// The authorization header value to verify. - /// Token to cancel the asynchronous operation. - Task VerifyAsync( - string? authorizationHeader, - CancellationToken cancellationToken); -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IAuthStoreMigrator.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IAuthStoreMigrator.cs deleted file mode 100644 index b147175..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/IAuthStoreMigrator.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; - -/// Migrates authentication storage between versions. -public interface IAuthStoreMigrator -{ - /// Performs authentication store migration asynchronously. - /// Token to cancel the asynchronous operation. - /// Asynchronous task representing the migration operation. - Task MigrateAsync(CancellationToken cancellationToken); -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ParsedApiKey.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ParsedApiKey.cs deleted file mode 100644 index a32f043..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/ParsedApiKey.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; - -public sealed record ParsedApiKey(string KeyId, string Secret); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs deleted file mode 100644 index 38cf68a..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAdminStore.cs +++ /dev/null @@ -1,141 +0,0 @@ -using Microsoft.Data.Sqlite; - -namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; - -/// -/// SQLite-backed storage for API key administration (create, list, revoke, rotate). -/// -public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAdminStore -{ - /// - 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); - } - - /// - public async Task> 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 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; - } - - /// - public async Task 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; - } - - /// - public async Task 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; - } - - /// - public async Task 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")); - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAuditStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAuditStore.cs deleted file mode 100644 index ee2fef4..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyAuditStore.cs +++ /dev/null @@ -1,65 +0,0 @@ -using Microsoft.Data.Sqlite; - -namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; - -public sealed class SqliteApiKeyAuditStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyAuditStore -{ - /// - 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); - } - - /// - public async Task> 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 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; - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs deleted file mode 100644 index a8ecf50..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteApiKeyStore.cs +++ /dev/null @@ -1,68 +0,0 @@ -using Microsoft.Data.Sqlite; - -namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; - -/// SQLite-based store for API key records. -public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFactory) : IApiKeyStore -{ - /// - public Task FindByKeyIdAsync(string keyId, CancellationToken cancellationToken) - { - return FindByKeyIdAsync(keyId, requireActive: false, cancellationToken); - } - - /// - public Task FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken) - { - return FindByKeyIdAsync(keyId, requireActive: true, cancellationToken); - } - - /// - 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 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); - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteAuthSchema.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteAuthSchema.cs deleted file mode 100644 index 299441a..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteAuthSchema.cs +++ /dev/null @@ -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"; -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteAuthStoreMigrator.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteAuthStoreMigrator.cs deleted file mode 100644 index f5a0e77..0000000 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authentication/SqliteAuthStoreMigrator.cs +++ /dev/null @@ -1,192 +0,0 @@ -using Microsoft.Data.Sqlite; - -namespace ZB.MOM.WW.MxGateway.Server.Security.Authentication; - -public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connectionFactory) : IAuthStoreMigrator -{ - /// Applies database migrations to the authentication store. - /// Cancellation token. - 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 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 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); - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs index 17c9e10..ef85a5a 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/ConstraintEnforcer.cs @@ -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); diff --git a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs index ad98a30..97533d5 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Security/Authorization/GatewayGrpcAuthorizationInterceptor.cs @@ -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; } } diff --git a/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj b/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj index 7fc4ca6..961f650 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj +++ b/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj @@ -6,9 +6,10 @@ - - - + + + + diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/AuthStoreHealthCheckTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/AuthStoreHealthCheckTests.cs index 3a9aa1f..df87ed3 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/AuthStoreHealthCheckTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Diagnostics/AuthStoreHealthCheckTests.cs @@ -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] diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs index 917d877..c8e4faf 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardApiKeyManagementServiceTests.cs @@ -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 +/// +/// Tests the gateway dashboard API-key management surface over the shared +/// ZB.MOM.WW.Auth.ApiKeys 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. +/// +public sealed class DashboardApiKeyManagementServiceTests : IDisposable { + private readonly List _tempDirectories = []; + /// Verifies that unauthorized users cannot create API keys. [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)); } - /// Verifies that authorized users can create keys with secret hashing and audit trail. + /// Verifies that authorized users create a verifiable, constrained key and audit it. [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() + .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 audit = await ListAuditAsync(services); + Assert.Contains(audit, entry => entry.EventType == "dashboard-create-key" && entry.KeyId == "operator01"); } - /// Verifies that unauthorized users cannot revoke API keys. + /// Verifies that creating a key whose id already exists is rejected. [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); } /// Verifies that authorized users can revoke keys with audit trail. [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 audit = await ListAuditAsync(services); + Assert.Contains(audit, entry => entry.EventType == "dashboard-revoke-key" && entry.KeyId == "operator01" && entry.Details == "revoked"); } - /// Verifies that authorized users can rotate secret hashes with audit trail. + /// Verifies that authorized users can rotate a key's secret with audit trail. [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(); + Assert.False((await verifier.VerifyAsync($"Bearer {created.ApiKey}", CancellationToken.None)).Succeeded); + Assert.True((await verifier.VerifyAsync($"Bearer {result.ApiKey}", CancellationToken.None)).Succeeded); + + IReadOnlyList audit = await ListAuditAsync(services); + Assert.Contains(audit, entry => entry.EventType == "dashboard-rotate-key" && entry.KeyId == "operator01" && entry.Details == "rotated"); } - /// Verifies that unauthorized users cannot delete API keys. - [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); - } - /// Verifies that authorized users can delete revoked keys with audit trail. [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 audit = await ListAuditAsync(services); + Assert.Contains(audit, entry => entry.EventType == "dashboard-delete-key" && entry.KeyId == "operator01" && entry.Details == "deleted"); } /// - /// Tests-030: when the admin store refuses the delete (returns false), the service - /// still emits a dashboard-delete-key audit entry with Details = "not-found-or-active" - /// because AppendAuditAsync runs unconditionally after the store call. A regression that - /// moved the audit-append call inside the if (deleted) 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 + /// dashboard-delete-key audit entry with Details = "not-found-or-active" is still + /// written — audit completeness for refused deletes. /// [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 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); } - /// - /// Tests-030: calls - /// ValidateKeyId 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. - /// + /// A blank key id fails validation before any store or audit call runs. /// A blank or whitespace key identifier. [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)); } /// - /// 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. /// [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(), + services.GetRequiredService(), + services.GetRequiredService(), 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 + { + ["MxGateway:Authentication:SqlitePath"] = directory.DatabasePath(), + ["MxGateway:ApiKeyPepper"] = "test-pepper" + }) + .Build(); + + ServiceCollection services = new(); + services.AddSingleton(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() + .MigrateAsync(CancellationToken.None).GetAwaiter().GetResult(); + + return provider; + } + + private static Task> ListAsync(ServiceProvider services) + { + return services.GetRequiredService().ListAsync(CancellationToken.None); + } + + private static Task> ListAuditAsync(ServiceProvider services) + { + return services.GetRequiredService().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 + /// Clears SQLite pools and deletes every temporary directory created by this test. + public void Dispose() { - /// Gets the count of create operations performed. - public int CreateCount { get; private set; } - - /// Gets the count of revoke operations performed. - public int RevokeCount { get; private set; } - - /// Gets the count of delete operations performed. - public int DeleteCount { get; private set; } - - /// Gets or sets the result value returned by revoke operations. - public bool RevokeResult { get; init; } - - /// Gets or sets the result value returned by rotate operations. - public bool RotateResult { get; init; } - - /// Gets or sets the result value returned by delete operations. - public bool DeleteResult { get; init; } - - /// Gets the last key ID revoked. - public string? LastRevokedKeyId { get; private set; } - - /// Gets the last key ID deleted. - public string? LastDeletedKeyId { get; private set; } - - /// Gets the last secret hash rotated. - public byte[]? LastRotatedSecretHash { get; private set; } - - /// Gets the list of create requests received. - public List CreatedRequests { get; } = []; - - /// - public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken) + foreach (TempDatabaseDirectory directory in _tempDirectories) { - CreateCount++; - CreatedRequests.Add(request); - return Task.CompletedTask; + directory.Dispose(); } - /// - public Task> ListAsync(CancellationToken cancellationToken) - { - return Task.FromResult>([]); - } - - /// - public Task RevokeAsync( - string keyId, - DateTimeOffset revokedUtc, - CancellationToken cancellationToken) - { - RevokeCount++; - LastRevokedKeyId = keyId; - return Task.FromResult(RevokeResult); - } - - /// - public Task RotateAsync( - string keyId, - byte[] secretHash, - DateTimeOffset rotatedUtc, - CancellationToken cancellationToken) - { - LastRotatedSecretHash = secretHash; - return Task.FromResult(RotateResult); - } - - /// - public Task DeleteAsync(string keyId, CancellationToken cancellationToken) - { - DeleteCount++; - LastDeletedKeyId = keyId; - return Task.FromResult(DeleteResult); - } - } - - private sealed class FakeApiKeyAuditStore : IApiKeyAuditStore - { - /// Gets the list of audit entries appended. - public List Entries { get; } = []; - - /// - public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken) - { - Entries.Add(entry); - return Task.CompletedTask; - } - - /// - public Task> ListRecentAsync( - int count, - CancellationToken cancellationToken) - { - return Task.FromResult>([]); - } - } - - private sealed class FakeApiKeySecretHasher : IApiKeySecretHasher - { - /// Gets the last secret hashed. - public string? LastSecret { get; private set; } - - /// - public byte[] HashSecret(string secret) - { - LastSecret = secret; - return System.Text.Encoding.UTF8.GetBytes($"hash:{secret}"); - } + _tempDirectories.Clear(); } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs index c189d99..a50b374 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardSnapshotServiceTests.cs @@ -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([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([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 { /// - public Task CreateAsync(ApiKeyCreateRequest request, CancellationToken cancellationToken) + public Task CreateAsync(ApiKeyRecord record, CancellationToken ct) { return Task.CompletedTask; } /// - public virtual Task> ListAsync(CancellationToken cancellationToken) + public virtual Task> ListAsync(CancellationToken ct) { - return Task.FromResult>([]); + return Task.FromResult>([]); } /// - public Task RevokeAsync( - string keyId, - DateTimeOffset revokedUtc, - CancellationToken cancellationToken) + public Task RevokeAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct) { return Task.FromResult(false); } /// - public Task RotateAsync( - string keyId, - byte[] secretHash, - DateTimeOffset rotatedUtc, - CancellationToken cancellationToken) + public Task RotateAsync(string keyId, byte[] newSecretHash, CancellationToken ct) { return Task.FromResult(false); } /// - public Task DeleteAsync(string keyId, CancellationToken cancellationToken) + public Task DeleteAsync(string keyId, CancellationToken ct) { return Task.FromResult(false); } } - private class CountingApiKeyAdminStore(params ApiKeyRecord[] records) : FakeApiKeyAdminStore + private class CountingApiKeyAdminStore(params ApiKeyListItem[] records) : FakeApiKeyAdminStore { /// Gets the count of list operations performed. public int ListCount { get; protected set; } /// - public override Task> ListAsync(CancellationToken cancellationToken) + public override Task> ListAsync(CancellationToken ct) { ListCount++; - return Task.FromResult>(records); + return Task.FromResult>(records); } } - private sealed class SequencedApiKeyAdminStore(ApiKeyRecord record) : CountingApiKeyAdminStore(record) + private sealed class SequencedApiKeyAdminStore(ApiKeyListItem record) : CountingApiKeyAdminStore(record) { /// Gets or sets a value indicating whether the next list operation should fail. public bool FailNext { get; set; } /// - public override Task> ListAsync(CancellationToken cancellationToken) + public override Task> 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); } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs index 173d4b4..c6cce0f 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyAdminCliRunnerTests.cs @@ -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(); - 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 auditRecords = await services + IReadOnlyList auditRecords = await services .GetRequiredService() .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() .VerifyAsync($"Bearer {apiKey}", CancellationToken.None); Assert.False(verification.Succeeded); - Assert.Equal(ApiKeyVerificationFailure.KeyRevoked, verification.Failure); + Assert.Equal(ApiKeyFailure.KeyRevoked, verification.Failure); - IReadOnlyList auditRecords = await services + IReadOnlyList auditRecords = await services .GetRequiredService() .ListRecentAsync(10, CancellationToken.None); @@ -141,11 +145,11 @@ public sealed class ApiKeyAdminCliRunnerTests : IDisposable Assert.Equal(1, CountOccurrences(rotateJson, newApiKey)); IApiKeyVerifier verifier = services.GetRequiredService(); - 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() .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(configuration); services.AddGatewayConfiguration(configuration); - services.AddSqliteAuthStore(); + services.AddSqliteAuthStore(configuration); return services.BuildServiceProvider(validateScopes: true); } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyParserTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyParserTests.cs deleted file mode 100644 index 385de26..0000000 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyParserTests.cs +++ /dev/null @@ -1,41 +0,0 @@ -using ZB.MOM.WW.MxGateway.Server.Security.Authentication; - -namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication; - -public sealed class ApiKeyParserTests -{ - /// Verifies that TryParseAuthorizationHeader parses a valid Bearer token and returns the key ID and secret. - [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); - } - - /// Verifies that TryParseAuthorizationHeader returns false for malformed tokens. - /// Malformed authorization header value. - [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); - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeySecretHasherTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeySecretHasherTests.cs deleted file mode 100644 index 0a4136a..0000000 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeySecretHasherTests.cs +++ /dev/null @@ -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 -{ - /// - /// Verifies identical pepper and secret produce identical hashes. - /// - [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); - } - - /// - /// Verifies different pepper values produce different hashes. - /// - [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); - } - - /// - /// Verifies missing pepper throws an exception. - /// - [Fact] - public void HashSecret_MissingPepper_Throws() - { - ApiKeySecretHasher hasher = CreateHasher(pepper: null); - - Assert.Throws(() => hasher.HashSecret("raw-secret")); - } - - private static ApiKeySecretHasher CreateHasher(string? pepper) - { - Dictionary 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)); - } -} diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs index 6def040..5fa6f0e 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/ApiKeyVerifierTests.cs @@ -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; +/// +/// Parity tests for the shared ZB.MOM.WW.Auth.ApiKeys verifier as the gateway relies on it: +/// the mxgw 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. +/// public sealed class ApiKeyVerifierTests { + private static readonly ApiKeyOptions Options = new() { TokenPrefix = "mxgw" }; + /// Verifies that VerifyAsync returns identity and scopes for a valid key. [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); } - /// Verifies that VerifyAsync fails with unauthenticated status for a malformed key. + /// Verifies that VerifyAsync fails as missing/malformed for a malformed key. /// Authorization header value to test. [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); } /// Verifies that VerifyAsync fails for an unknown key. [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); } - /// Verifies that VerifyAsync fails for a wrong secret. + /// Verifies that VerifyAsync fails for a wrong secret (constant-time compare rejects it). [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); } - /// Verifies that VerifyAsync fails when the pepper is missing. + /// Verifies that VerifyAsync fails closed when the pepper is missing. [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) + /// Computes HMAC-SHA256(pepper, secret) — the documented peppered-hash format. + 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(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 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)); + /// Returns the configured pepper (or null to simulate an unavailable pepper). + public string? GetPepper() => pepper; } /// Fake in-memory API key store for testing. @@ -181,18 +169,14 @@ public sealed class ApiKeyVerifierTests /// Gets whether the key was marked as used. public bool MarkedUsed { get; private set; } - /// Finds an API key record by its ID. - /// Identifier of the API key. - /// Token to cancel the asynchronous operation. - public Task FindByKeyIdAsync(string keyId, CancellationToken cancellationToken) + /// + public Task FindByKeyIdAsync(string keyId, CancellationToken ct) { return Task.FromResult(storedKey?.KeyId == keyId ? storedKey : null); } - /// Finds an active (non-revoked) API key record by its ID. - /// Identifier of the API key. - /// Token to cancel the asynchronous operation. - public Task FindActiveByKeyIdAsync(string keyId, CancellationToken cancellationToken) + /// + public Task FindActiveByKeyIdAsync(string keyId, CancellationToken ct) { return Task.FromResult( storedKey?.KeyId == keyId && storedKey.RevokedUtc is null @@ -200,11 +184,8 @@ public sealed class ApiKeyVerifierTests : null); } - /// Marks an API key as used at the specified time. - /// Identifier of the API key. - /// Timestamp when the key was used. - /// Token to cancel the asynchronous operation. - public Task MarkKeyUsedAsync(string keyId, DateTimeOffset usedUtc, CancellationToken cancellationToken) + /// + public Task MarkUsedAsync(string keyId, DateTimeOffset whenUtc, CancellationToken ct) { MarkedUsed = storedKey?.KeyId == keyId; diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs index 985ef00..8b6b633 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authentication/SqliteAuthStoreTests.cs @@ -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; /// -/// Tests for . +/// Parity tests for the shared ZB.MOM.WW.Auth.ApiKeys SQLite store as wired by the gateway. +/// The gateway is the donor this store was extracted from; these tests pin that existing deployed +/// gateway-auth.db databases (schema version 2, same tables/columns/scopes encoding) remain +/// readable and that migration is idempotent and refuses a newer on-disk schema. /// public sealed class SqliteAuthStoreTests : IDisposable { private readonly List _tempDirectories = []; + /// - /// Verifies that MigrateAsync initializes the database schema. + /// Verifies that MigrateAsync initializes the database schema at the donor's version (2). /// [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(); + SqliteAuthStoreMigrator migrator = services.GetRequiredService(); 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(); + SqliteAuthStoreMigrator migrator = services.GetRequiredService(); await migrator.MigrateAsync(CancellationToken.None); await migrator.MigrateAsync(CancellationToken.None); @@ -74,14 +80,15 @@ public sealed class SqliteAuthStoreTests : IDisposable } /// - /// 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). /// [Fact] public async Task FindActiveByKeyIdAsync_ExistingActiveKey_ReturnsKey() { string databasePath = CreateTempDatabasePath(); await using ServiceProvider services = BuildAuthServices(databasePath); - await services.GetRequiredService().MigrateAsync(CancellationToken.None); + await services.GetRequiredService().MigrateAsync(CancellationToken.None); await InsertApiKeyAsync(databasePath, revokedUtc: null); IApiKeyStore store = services.GetRequiredService(); @@ -104,7 +111,7 @@ public sealed class SqliteAuthStoreTests : IDisposable { string databasePath = CreateTempDatabasePath(); await using ServiceProvider services = BuildAuthServices(databasePath); - await services.GetRequiredService().MigrateAsync(CancellationToken.None); + await services.GetRequiredService().MigrateAsync(CancellationToken.None); await InsertApiKeyAsync(databasePath, DateTimeOffset.UtcNow); IApiKeyStore store = services.GetRequiredService(); @@ -127,7 +134,7 @@ public sealed class SqliteAuthStoreTests : IDisposable { string databasePath = CreateTempDatabasePath(); await using ServiceProvider services = BuildAuthServices(databasePath); - await services.GetRequiredService().MigrateAsync(CancellationToken.None); + await services.GetRequiredService().MigrateAsync(CancellationToken.None); IApiKeyAuditStore auditStore = services.GetRequiredService(); @@ -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 records = await auditStore.ListRecentAsync( + IReadOnlyList 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(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(StringComparer.Ordinal) { "session:open", "events:read" })); + ScopeSerializer.Serialize(new HashSet(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); diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs index 3b776b7..f2ae363 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/ConstraintEnforcerTests.cs @@ -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 } /// - public Task> ListRecentAsync(int count, CancellationToken cancellationToken) + public Task> ListRecentAsync(int limit, CancellationToken ct) { - return Task.FromResult>([]); + return Task.FromResult>([]); } } } diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs index c171b3a..0636d9a 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Security/Authorization/GatewayGrpcAuthorizationInterceptorTests.cs @@ -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( @@ -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( @@ -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(scopes, StringComparer.Ordinal))); + return new ApiKeyVerification( + Succeeded: true, + Identity: new LibApiKeyIdentity( + KeyId: "operator01", + DisplayName: "Operator Key", + Scopes: new HashSet(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 { /// Gets whether the verifier was called. public bool WasCalled { get; private set; } @@ -505,11 +517,11 @@ public sealed class GatewayGrpcAuthorizationInterceptorTests /// Verifies the authorization header against stored result. /// The authorization header to verify. - /// Cancellation token. + /// Cancellation token. /// Configured verification result. - public Task VerifyAsync( - string? authorizationHeader, - CancellationToken cancellationToken) + public Task VerifyAsync( + string authorizationHeader, + CancellationToken ct) { WasCalled = true; LastAuthorizationHeader = authorizationHeader;