feat(auth): ScadaBridge ManagementActor + CLI + Commons messages onto IInboundApiKeyAdmin seam (re-arch C2; int->string keyId, +Methods, +SetApiKeyMethods)
This commit is contained in:
@@ -4,33 +4,32 @@ using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.ManagementService;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// ConfigurationDatabase-012: creating an API key must generate a random key,
|
||||
/// persist only its peppered hash, and return the plaintext to the caller exactly
|
||||
/// once. The plaintext must never reach the stored entity.
|
||||
/// Inbound-API key re-arch (C2): the ManagementActor API-key management path now drives the
|
||||
/// Commons <see cref="IInboundApiKeyAdmin"/> seam (added in C1) instead of the SQL Server
|
||||
/// <c>IInboundApiRepository</c>. These tests exercise the actor against an in-memory fake of
|
||||
/// the seam (the real <c>LibraryInboundApiKeyAdmin</c> + SQLite mapping is covered end-to-end
|
||||
/// by the Security project's <c>LibraryInboundApiKeyAdminTests</c>). They verify the actor's
|
||||
/// dispatch, response shapes (string keyId, one-time token, methods), the preserved ScadaBridge
|
||||
/// management-audit calls, and that the "Admin" role gate still applies to all five commands.
|
||||
/// </summary>
|
||||
public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
{
|
||||
private const string Pepper = "a-sufficiently-long-server-side-pepper-value";
|
||||
|
||||
private readonly ServiceCollection _services = new();
|
||||
private readonly IInboundApiRepository _apiRepo = Substitute.For<IInboundApiRepository>();
|
||||
private readonly FakeInboundApiKeyAdmin _admin = new();
|
||||
private readonly IAuditService _auditService = Substitute.For<IAuditService>();
|
||||
|
||||
public ApiKeyCreationTests()
|
||||
{
|
||||
_services.AddScoped(_ => _apiRepo);
|
||||
_services.AddScoped<IInboundApiKeyAdmin>(_ => _admin);
|
||||
_services.AddScoped(_ => _auditService);
|
||||
_services.AddSingleton<IApiKeyHasher>(new ApiKeyHasher(Pepper));
|
||||
}
|
||||
|
||||
private IActorRef CreateActor()
|
||||
@@ -46,76 +45,218 @@ public class ApiKeyCreationTests : TestKit, IDisposable
|
||||
void IDisposable.Dispose() => Shutdown();
|
||||
|
||||
[Fact]
|
||||
public void CreateApiKey_PersistsOnlyHash_NeverPlaintext()
|
||||
public void CreateApiKey_ReturnsKeyIdAndOneTimeToken()
|
||||
{
|
||||
ApiKey? persisted = null;
|
||||
_apiRepo.AddApiKeyAsync(Arg.Do<ApiKey>(k => persisted = k))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production"), "Admin"));
|
||||
actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", new[] { "MethodA", "MethodB" }), "Admin"));
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
|
||||
// The response must carry the one-time plaintext key shown to the operator.
|
||||
var plaintext = ExtractPlaintextKey(response.JsonData);
|
||||
Assert.False(string.IsNullOrWhiteSpace(plaintext));
|
||||
using var doc = JsonDocument.Parse(response.JsonData);
|
||||
var root = doc.RootElement;
|
||||
var keyId = root.GetProperty("keyId").GetString();
|
||||
var token = root.GetProperty("token").GetString();
|
||||
var name = root.GetProperty("name").GetString();
|
||||
|
||||
// The stored entity must carry a hash, never the plaintext.
|
||||
Assert.NotNull(persisted);
|
||||
Assert.NotEqual(plaintext, persisted!.KeyHash);
|
||||
Assert.False(string.IsNullOrWhiteSpace(keyId));
|
||||
Assert.False(string.IsNullOrWhiteSpace(token));
|
||||
Assert.StartsWith($"sbk_{keyId}_", token);
|
||||
Assert.Equal("MES-Production", name);
|
||||
|
||||
// The persisted hash must equal the peppered hash of the returned plaintext.
|
||||
var hasher = new ApiKeyHasher(Pepper);
|
||||
Assert.Equal(hasher.Hash(plaintext!), persisted.KeyHash);
|
||||
// The seam was driven with the supplied name + methods.
|
||||
var created = Assert.Single(_admin.Keys.Values);
|
||||
Assert.Equal("MES-Production", created.Name);
|
||||
Assert.Equal(new[] { "MethodA", "MethodB" }, created.Methods);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateApiKey_ResponseDoesNotEchoTheHash()
|
||||
public void CreateApiKey_AuditsTheCreate()
|
||||
{
|
||||
ApiKey? persisted = null;
|
||||
_apiRepo.AddApiKeyAsync(Arg.Do<ApiKey>(k => persisted = k))
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production"), "Admin"));
|
||||
actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", new[] { "MethodA" }), "Admin"));
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
|
||||
_auditService.Received(1).LogAsync(
|
||||
"admin", "Create", "ApiKey", Arg.Any<string>(), "MES-Production", Arg.Any<object?>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateApiKey_ResponseDoesNotEchoAHash()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", new[] { "MethodA" }), "Admin"));
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.NotNull(persisted);
|
||||
// The serialized response must not leak the stored hash as a usable artifact.
|
||||
Assert.DoesNotContain(persisted!.KeyHash, response.JsonData);
|
||||
// The hash-free seam never surfaces a stored hash; the only secret in the body
|
||||
// is the one-time token (sbk_<keyId>_<secret>).
|
||||
Assert.DoesNotContain("hash", response.JsonData, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateApiKey_TwoKeys_GenerateDistinctRandomValues()
|
||||
public void ListApiKeys_ReturnsKeysWithMethods()
|
||||
{
|
||||
var hashes = new List<string>();
|
||||
_apiRepo.AddApiKeyAsync(Arg.Do<ApiKey>(k => hashes.Add(k.KeyHash)))
|
||||
.Returns(Task.CompletedTask);
|
||||
_admin.Seed("key-1", "Service A", enabled: true, "M1", "M2");
|
||||
_admin.Seed("key-2", "Service B", enabled: false, "M3");
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new CreateApiKeyCommand("KeyA"), "Admin"));
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
actor.Tell(Envelope(new CreateApiKeyCommand("KeyB"), "Admin"));
|
||||
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
actor.Tell(Envelope(new ListApiKeysCommand(), "Admin"));
|
||||
|
||||
Assert.Equal(2, hashes.Count);
|
||||
Assert.NotEqual(hashes[0], hashes[1]);
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
|
||||
using var doc = JsonDocument.Parse(response.JsonData);
|
||||
var items = doc.RootElement.EnumerateArray().ToList();
|
||||
Assert.Equal(2, items.Count);
|
||||
|
||||
var first = items.Single(i => i.GetProperty("keyId").GetString() == "key-1");
|
||||
Assert.Equal("Service A", first.GetProperty("name").GetString());
|
||||
Assert.True(first.GetProperty("enabled").GetBoolean());
|
||||
Assert.Equal(new[] { "M1", "M2" },
|
||||
first.GetProperty("methods").EnumerateArray().Select(m => m.GetString()).ToArray());
|
||||
|
||||
// The list path must not surface a hash / token credential artifact.
|
||||
Assert.DoesNotContain("token", response.JsonData, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.DoesNotContain("hash", response.JsonData, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateApiKey_TogglesEnabled_AndAudits()
|
||||
{
|
||||
_admin.Seed("key-1", "Service A", enabled: true, "M1");
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new UpdateApiKeyCommand("key-1", false), "Admin"));
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.False(_admin.Keys["key-1"].Enabled);
|
||||
|
||||
using var doc = JsonDocument.Parse(response.JsonData);
|
||||
Assert.Equal("key-1", doc.RootElement.GetProperty("keyId").GetString());
|
||||
Assert.False(doc.RootElement.GetProperty("isEnabled").GetBoolean());
|
||||
|
||||
_auditService.Received(1).LogAsync(
|
||||
"admin", "Update", "ApiKey", "key-1", Arg.Any<string>(), Arg.Any<object?>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteApiKey_RemovesKey_AndAudits()
|
||||
{
|
||||
_admin.Seed("key-1", "Service A", enabled: true, "M1");
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new DeleteApiKeyCommand("key-1"), "Admin"));
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.DoesNotContain("key-1", _admin.Keys.Keys);
|
||||
Assert.Equal("true", response.JsonData);
|
||||
|
||||
_auditService.Received(1).LogAsync(
|
||||
"admin", "Delete", "ApiKey", "key-1", "key-1", null);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetApiKeyMethods_ReplacesScopes_AndAudits()
|
||||
{
|
||||
_admin.Seed("key-1", "Service A", enabled: true, "Old1", "Old2");
|
||||
|
||||
var actor = CreateActor();
|
||||
actor.Tell(Envelope(new SetApiKeyMethodsCommand("key-1", new[] { "New1", "New2", "New3" }), "Admin"));
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
|
||||
Assert.Equal(new[] { "New1", "New2", "New3" }, _admin.Keys["key-1"].Methods);
|
||||
Assert.Equal("true", response.JsonData);
|
||||
|
||||
_auditService.Received(1).LogAsync(
|
||||
"admin", "Update", "ApiKey", "key-1", Arg.Any<string>(), Arg.Any<object?>());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(AllApiKeyCommands))]
|
||||
public void EveryApiKeyCommand_RequiresAdminRole(object command)
|
||||
{
|
||||
var actor = CreateActor();
|
||||
// A Design-role caller (not Admin) must be rejected for every API-key command.
|
||||
actor.Tell(Envelope(command, "Design"));
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("Admin", response.Message);
|
||||
}
|
||||
|
||||
public static IEnumerable<object[]> AllApiKeyCommands() => new[]
|
||||
{
|
||||
new object[] { new ListApiKeysCommand() },
|
||||
new object[] { new CreateApiKeyCommand("X", new[] { "M1" }) },
|
||||
new object[] { new DeleteApiKeyCommand("key-1") },
|
||||
new object[] { new UpdateApiKeyCommand("key-1", true) },
|
||||
new object[] { new SetApiKeyMethodsCommand("key-1", new[] { "M1" }) },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The create response is JSON carrying the one-time plaintext key under a
|
||||
/// <c>PlaintextKey</c> (or <c>Key</c>) property.
|
||||
/// Minimal in-memory fake of the C1 management seam. Models only what the
|
||||
/// ManagementActor handlers exercise: create (assigns a keyId + assembles a
|
||||
/// <c>sbk_<keyId>_<secret></c> token), list, toggle-enabled, replace-methods,
|
||||
/// and delete. Mirrors the real seam's bool/return semantics (false when a key is absent).
|
||||
/// </summary>
|
||||
private static string? ExtractPlaintextKey(string json)
|
||||
private sealed class FakeInboundApiKeyAdmin : IInboundApiKeyAdmin
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
if (root.TryGetProperty("plaintextKey", out var p) || root.TryGetProperty("PlaintextKey", out p))
|
||||
return p.GetString();
|
||||
if (root.TryGetProperty("key", out var k) || root.TryGetProperty("Key", out k))
|
||||
return k.GetString();
|
||||
return null;
|
||||
public sealed record Entry(string KeyId, string Name, bool Enabled, IReadOnlyList<string> Methods);
|
||||
|
||||
public Dictionary<string, Entry> Keys { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public void Seed(string keyId, string name, bool enabled, params string[] methods) =>
|
||||
Keys[keyId] = new Entry(keyId, name, enabled, methods.ToList());
|
||||
|
||||
public Task<InboundApiKeyCreated> CreateAsync(
|
||||
string name, IReadOnlyCollection<string> methods, CancellationToken ct = default)
|
||||
{
|
||||
var keyId = Guid.NewGuid().ToString("N");
|
||||
Keys[keyId] = new Entry(keyId, name, Enabled: true, methods.ToList());
|
||||
return Task.FromResult(new InboundApiKeyCreated(keyId, $"sbk_{keyId}_secret"));
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<InboundApiKeyInfo>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
IReadOnlyList<InboundApiKeyInfo> result = Keys.Values
|
||||
.Select(e => new InboundApiKeyInfo(
|
||||
e.KeyId, e.Name, e.Enabled, e.Methods,
|
||||
DateTimeOffset.UnixEpoch, LastUsedUtc: null))
|
||||
.ToList();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public Task<bool> SetEnabledAsync(string keyId, bool enabled, CancellationToken ct = default)
|
||||
{
|
||||
if (!Keys.TryGetValue(keyId, out var e)) return Task.FromResult(false);
|
||||
Keys[keyId] = e with { Enabled = enabled };
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<bool> SetMethodsAsync(string keyId, IReadOnlyCollection<string> methods, CancellationToken ct = default)
|
||||
{
|
||||
if (!Keys.TryGetValue(keyId, out var e)) return Task.FromResult(false);
|
||||
Keys[keyId] = e with { Methods = methods.ToList() };
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public Task<bool> DeleteAsync(string keyId, CancellationToken ct = default) =>
|
||||
Task.FromResult(Keys.Remove(keyId));
|
||||
|
||||
public Task<IReadOnlyList<string>> GetMethodsForKeyAsync(string keyId, CancellationToken ct = default)
|
||||
{
|
||||
IReadOnlyList<string> methods = Keys.TryGetValue(keyId, out var e) ? e.Methods : Array.Empty<string>();
|
||||
return Task.FromResult(methods);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<string>> GetKeysForMethodAsync(string methodName, CancellationToken ct = default)
|
||||
{
|
||||
IReadOnlyList<string> keys = Keys.Values
|
||||
.Where(e => e.Methods.Contains(methodName, StringComparer.Ordinal))
|
||||
.Select(e => e.KeyId)
|
||||
.ToList();
|
||||
return Task.FromResult(keys);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,7 +391,7 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
public void UpdateApiKey_WithDesignRole_ReturnsUnauthorized()
|
||||
{
|
||||
var actor = CreateActor();
|
||||
var envelope = Envelope(new UpdateApiKeyCommand(1, true), "Design");
|
||||
var envelope = Envelope(new UpdateApiKeyCommand("key-1", true), "Design");
|
||||
|
||||
actor.Tell(envelope);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user