diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/SecurityCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/SecurityCommands.cs index 917468a8..b55865b4 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/SecurityCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/SecurityCommands.cs @@ -37,44 +37,107 @@ public static class SecurityCommands group.Add(listCmd); var nameOption = new Option("--name") { Description = "API key name", Required = true }; + var createMethodsOption = new Option("--methods") + { + Description = "Comma-separated API method names this key may call (e.g. \"MethodA,MethodB\")", + Required = true + }; var createCmd = new Command("create") { Description = "Create an API key" }; createCmd.Add(nameOption); + createCmd.Add(createMethodsOption); createCmd.SetAction(async (ParseResult result) => { var name = result.GetValue(nameOption)!; + var methods = ParseMethods(result.GetValue(createMethodsOption)); return await CommandHelpers.ExecuteCommandAsync( - result, urlOption, formatOption, usernameOption, passwordOption, new CreateApiKeyCommand(name)); + result, urlOption, formatOption, usernameOption, passwordOption, + new CreateApiKeyCommand(name, methods), + onSuccess: PrintCreatedKey); }); group.Add(createCmd); - var idOption = new Option("--id") { Description = "API key ID", Required = true }; + var deleteKeyIdOption = new Option("--key-id") { Description = "API key ID", Required = true }; var deleteCmd = new Command("delete") { Description = "Delete an API key" }; - deleteCmd.Add(idOption); + deleteCmd.Add(deleteKeyIdOption); deleteCmd.SetAction(async (ParseResult result) => { - var id = result.GetValue(idOption); + var keyId = result.GetValue(deleteKeyIdOption)!; return await CommandHelpers.ExecuteCommandAsync( - result, urlOption, formatOption, usernameOption, passwordOption, new DeleteApiKeyCommand(id)); + result, urlOption, formatOption, usernameOption, passwordOption, new DeleteApiKeyCommand(keyId)); }); group.Add(deleteCmd); - var updateIdOption = new Option("--id") { Description = "API key ID", Required = true }; + var updateKeyIdOption = new Option("--key-id") { Description = "API key ID", Required = true }; var enabledOption = new Option("--enabled") { Description = "Enable or disable", Required = true }; var updateCmd = new Command("update") { Description = "Enable or disable an API key" }; - updateCmd.Add(updateIdOption); + updateCmd.Add(updateKeyIdOption); updateCmd.Add(enabledOption); updateCmd.SetAction(async (ParseResult result) => { - var id = result.GetValue(updateIdOption); + var keyId = result.GetValue(updateKeyIdOption)!; var enabled = result.GetValue(enabledOption); return await CommandHelpers.ExecuteCommandAsync( - result, urlOption, formatOption, usernameOption, passwordOption, new UpdateApiKeyCommand(id, enabled)); + result, urlOption, formatOption, usernameOption, passwordOption, new UpdateApiKeyCommand(keyId, enabled)); }); group.Add(updateCmd); + var setMethodsKeyIdOption = new Option("--key-id") { Description = "API key ID", Required = true }; + var setMethodsOption = new Option("--methods") + { + Description = "Comma-separated API method names this key may call (replaces the existing set)", + Required = true + }; + var setMethodsCmd = new Command("set-methods") { Description = "Replace the method-scopes on an API key" }; + setMethodsCmd.Add(setMethodsKeyIdOption); + setMethodsCmd.Add(setMethodsOption); + setMethodsCmd.SetAction(async (ParseResult result) => + { + var keyId = result.GetValue(setMethodsKeyIdOption)!; + var methods = ParseMethods(result.GetValue(setMethodsOption)); + return await CommandHelpers.ExecuteCommandAsync( + result, urlOption, formatOption, usernameOption, passwordOption, + new SetApiKeyMethodsCommand(keyId, methods)); + }); + group.Add(setMethodsCmd); + return group; } + /// + /// Splits a comma-separated --methods value into a trimmed, non-empty list of + /// method names. A null/empty value yields an empty list (the server rejects an empty + /// scope set if its rules require one). + /// + /// The raw delimited option value. + private static IReadOnlyList ParseMethods(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + return Array.Empty(); + + return raw + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToArray(); + } + + /// + /// Renders the create-key response, surfacing the one-time bearer token prominently — + /// it is the only moment the secret is available and cannot be retrieved afterwards. + /// + /// The JSON success body returned by the management API. + private static int PrintCreatedKey(string json) + { + using var doc = System.Text.Json.JsonDocument.Parse(json); + var root = doc.RootElement; + var keyId = root.TryGetProperty("keyId", out var k) ? k.GetString() : null; + var token = root.TryGetProperty("token", out var t) ? t.GetString() : null; + + Console.WriteLine($"API key created. KeyId: {keyId}"); + Console.WriteLine(); + Console.WriteLine("Save this token now — it will not be shown again:"); + Console.WriteLine($" {token}"); + return 0; + } + private static Command BuildRoleMapping(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var group = new Command("role-mapping") { Description = "Manage LDAP role mappings" }; diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/SecurityCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/SecurityCommands.cs index 7ee986e5..ceb0dcde 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/SecurityCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/SecurityCommands.cs @@ -1,13 +1,14 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; public record ListApiKeysCommand; -public record CreateApiKeyCommand(string Name); -public record DeleteApiKeyCommand(int ApiKeyId); +public record CreateApiKeyCommand(string Name, IReadOnlyList Methods); +public record DeleteApiKeyCommand(string KeyId); public record ListRoleMappingsCommand; public record CreateRoleMappingCommand(string LdapGroupName, string Role); public record UpdateRoleMappingCommand(int MappingId, string LdapGroupName, string Role); public record DeleteRoleMappingCommand(int MappingId); -public record UpdateApiKeyCommand(int ApiKeyId, bool IsEnabled); +public record UpdateApiKeyCommand(string KeyId, bool IsEnabled); +public record SetApiKeyMethodsCommand(string KeyId, IReadOnlyList Methods); public record ListScopeRulesCommand(int MappingId); public record AddScopeRuleCommand(int MappingId, int SiteId); public record DeleteScopeRuleCommand(int ScopeRuleId); diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index 1af5ba32..a82b7326 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -14,6 +14,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; 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.DebugView; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; @@ -157,7 +158,7 @@ public class ManagementActor : ReceiveActor or ListRoleMappingsCommand or CreateRoleMappingCommand or UpdateRoleMappingCommand or DeleteRoleMappingCommand or ListApiKeysCommand or CreateApiKeyCommand or DeleteApiKeyCommand - or UpdateApiKeyCommand + or UpdateApiKeyCommand or SetApiKeyMethodsCommand or ListScopeRulesCommand or AddScopeRuleCommand or DeleteScopeRuleCommand // QueryAuditLogCommand: legacy Action/EntityType filter on the // configuration audit log. Gated Admin-only so this older path is @@ -340,6 +341,7 @@ public class ManagementActor : ReceiveActor CreateApiKeyCommand cmd => await HandleCreateApiKey(sp, cmd, user.Username), DeleteApiKeyCommand cmd => await HandleDeleteApiKey(sp, cmd, user.Username), UpdateApiKeyCommand cmd => await HandleUpdateApiKey(sp, cmd, user.Username), + SetApiKeyMethodsCommand cmd => await HandleSetApiKeyMethods(sp, cmd, user.Username), ListScopeRulesCommand cmd => await HandleListScopeRules(sp, cmd), AddScopeRuleCommand cmd => await HandleAddScopeRule(sp, cmd, user.Username), DeleteScopeRuleCommand cmd => await HandleDeleteScopeRule(sp, cmd, user.Username), @@ -1295,52 +1297,44 @@ public class ManagementActor : ReceiveActor private static async Task HandleListApiKeys(IServiceProvider sp) { - var repo = sp.GetRequiredService(); - var keys = await repo.GetAllApiKeysAsync(); - - // ConfigurationDatabase-012: list/read paths must not expose the stored key - // hash — it is a credential artifact. Only identity and status are returned; - // the plaintext key is shown once at creation and is never retrievable. + // Inbound-API key re-arch (C2): keys are now managed through the shared + // IInboundApiKeyAdmin seam (over the ZB.MOM.WW.Auth.ApiKeys library) rather than + // the SQL Server IInboundApiRepository. The seam projection is hash-free by + // construction — only identity, status, and method-scopes are returned; the + // secret is shown once at creation (token) and is never retrievable. + var admin = sp.GetRequiredService(); + var keys = await admin.ListAsync(); return keys - .Select(k => new { k.Id, k.Name, k.IsEnabled }) + .Select(k => new { k.KeyId, k.Name, k.Enabled, k.Methods }) .ToList(); } private static async Task HandleCreateApiKey(IServiceProvider sp, CreateApiKeyCommand cmd, string user) { - var repo = sp.GetRequiredService(); + // Inbound-API key re-arch (C2): the library mints the key, persists only the + // peppered hash, and assembles the one-time bearer token (sbk__). + // The token is shown to the operator only here, in the create response; it cannot + // be retrieved later. No hash/secret is stored or returned by ScadaBridge. + var admin = sp.GetRequiredService(); + var created = await admin.CreateAsync(cmd.Name, cmd.Methods); - // ConfigurationDatabase-012: generate a high-entropy random key, persist only - // its peppered hash, and return the plaintext to the caller exactly once. The - // plaintext is never stored — the ApiKey entity carries only KeyHash. - var hasher = sp.GetService() ?? ApiKeyHasher.Default; - var plaintextKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); - var apiKey = ApiKey.FromHash(cmd.Name, hasher.Hash(plaintextKey)); - apiKey.IsEnabled = true; + await AuditAsync(sp, user, "Create", "ApiKey", created.KeyId, cmd.Name, + new { created.KeyId, cmd.Name }); - await repo.AddApiKeyAsync(apiKey); - await repo.SaveChangesAsync(); - await AuditAsync(sp, user, "Create", "ApiKey", apiKey.Id.ToString(), apiKey.Name, - new { apiKey.Id, apiKey.Name, apiKey.IsEnabled }); - - // The plaintext key is shown to the operator only here, in the create response; - // it cannot be retrieved later. The stored hash is deliberately not returned. return new { - apiKey.Id, - apiKey.Name, - apiKey.IsEnabled, - PlaintextKey = plaintextKey, + created.KeyId, + cmd.Name, + created.Token, }; } private static async Task HandleDeleteApiKey(IServiceProvider sp, DeleteApiKeyCommand cmd, string user) { - var repo = sp.GetRequiredService(); - await repo.DeleteApiKeyAsync(cmd.ApiKeyId); - await repo.SaveChangesAsync(); - await AuditAsync(sp, user, "Delete", "ApiKey", cmd.ApiKeyId.ToString(), cmd.ApiKeyId.ToString(), null); - return true; + var admin = sp.GetRequiredService(); + var deleted = await admin.DeleteAsync(cmd.KeyId); + await AuditAsync(sp, user, "Delete", "ApiKey", cmd.KeyId, cmd.KeyId, null); + return deleted; } // ======================================================================== @@ -1761,14 +1755,23 @@ public class ManagementActor : ReceiveActor private static async Task HandleUpdateApiKey(IServiceProvider sp, UpdateApiKeyCommand cmd, string user) { - var repo = sp.GetRequiredService(); - var key = await repo.GetApiKeyByIdAsync(cmd.ApiKeyId) - ?? throw new ManagementCommandException($"ApiKey with ID {cmd.ApiKeyId} not found."); - key.IsEnabled = cmd.IsEnabled; - await repo.UpdateApiKeyAsync(key); - await repo.SaveChangesAsync(); - await AuditAsync(sp, user, "Update", "ApiKey", key.Id.ToString(), key.Name, new { key.Id, key.Name, key.IsEnabled }); - return key; + // Inbound-API key re-arch (C2): enable/disable via the shared seam (no secret change). + var admin = sp.GetRequiredService(); + await admin.SetEnabledAsync(cmd.KeyId, cmd.IsEnabled); + await AuditAsync(sp, user, "Update", "ApiKey", cmd.KeyId, cmd.KeyId, + new { cmd.KeyId, cmd.IsEnabled }); + return new { cmd.KeyId, cmd.IsEnabled }; + } + + private static async Task HandleSetApiKeyMethods(IServiceProvider sp, SetApiKeyMethodsCommand cmd, string user) + { + // Inbound-API key re-arch (C2): replace a key's method-scope set via the shared seam + // (no secret change). The library is authoritative for the scope replacement. + var admin = sp.GetRequiredService(); + var updated = await admin.SetMethodsAsync(cmd.KeyId, cmd.Methods); + await AuditAsync(sp, user, "Update", "ApiKey", cmd.KeyId, cmd.KeyId, + new { cmd.KeyId, cmd.Methods }); + return updated; } private static async Task HandleListScopeRules(IServiceProvider sp, ListScopeRulesCommand cmd) diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ApiKeyCreationTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ApiKeyCreationTests.cs index d4d046bd..e48ccc1c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ApiKeyCreationTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ApiKeyCreationTests.cs @@ -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; /// -/// 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 seam (added in C1) instead of the SQL Server +/// IInboundApiRepository. These tests exercise the actor against an in-memory fake of +/// the seam (the real LibraryInboundApiKeyAdmin + SQLite mapping is covered end-to-end +/// by the Security project's LibraryInboundApiKeyAdminTests). 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. /// 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(); + private readonly FakeInboundApiKeyAdmin _admin = new(); private readonly IAuditService _auditService = Substitute.For(); public ApiKeyCreationTests() { - _services.AddScoped(_ => _apiRepo); + _services.AddScoped(_ => _admin); _services.AddScoped(_ => _auditService); - _services.AddSingleton(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(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(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(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(TimeSpan.FromSeconds(5)); + + _auditService.Received(1).LogAsync( + "admin", "Create", "ApiKey", Arg.Any(), "MES-Production", Arg.Any()); + } + + [Fact] + public void CreateApiKey_ResponseDoesNotEchoAHash() + { + var actor = CreateActor(); + actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", new[] { "MethodA" }), "Admin")); var response = ExpectMsg(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__). + Assert.DoesNotContain("hash", response.JsonData, StringComparison.OrdinalIgnoreCase); } [Fact] - public void CreateApiKey_TwoKeys_GenerateDistinctRandomValues() + public void ListApiKeys_ReturnsKeysWithMethods() { - var hashes = new List(); - _apiRepo.AddApiKeyAsync(Arg.Do(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(TimeSpan.FromSeconds(5)); - actor.Tell(Envelope(new CreateApiKeyCommand("KeyB"), "Admin")); - ExpectMsg(TimeSpan.FromSeconds(5)); + actor.Tell(Envelope(new ListApiKeysCommand(), "Admin")); - Assert.Equal(2, hashes.Count); - Assert.NotEqual(hashes[0], hashes[1]); + var response = ExpectMsg(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(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(), Arg.Any()); + } + + [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(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(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(), Arg.Any()); + } + + [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(TimeSpan.FromSeconds(5)); + Assert.Contains("Admin", response.Message); + } + + public static IEnumerable 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" }) }, + }; + /// - /// The create response is JSON carrying the one-time plaintext key under a - /// PlaintextKey (or Key) property. + /// Minimal in-memory fake of the C1 management seam. Models only what the + /// ManagementActor handlers exercise: create (assigns a keyId + assembles a + /// sbk_<keyId>_<secret> token), list, toggle-enabled, replace-methods, + /// and delete. Mirrors the real seam's bool/return semantics (false when a key is absent). /// - 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 Methods); + + public Dictionary 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 CreateAsync( + string name, IReadOnlyCollection 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> ListAsync(CancellationToken ct = default) + { + IReadOnlyList 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 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 SetMethodsAsync(string keyId, IReadOnlyCollection 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 DeleteAsync(string keyId, CancellationToken ct = default) => + Task.FromResult(Keys.Remove(keyId)); + + public Task> GetMethodsForKeyAsync(string keyId, CancellationToken ct = default) + { + IReadOnlyList methods = Keys.TryGetValue(keyId, out var e) ? e.Methods : Array.Empty(); + return Task.FromResult(methods); + } + + public Task> GetKeysForMethodAsync(string methodName, CancellationToken ct = default) + { + IReadOnlyList keys = Keys.Values + .Where(e => e.Methods.Contains(methodName, StringComparer.Ordinal)) + .Select(e => e.KeyId) + .ToList(); + return Task.FromResult(keys); + } } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index 919cd04d..e54af2ad 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -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);