feat(auth): ScadaBridge ManagementActor + CLI + Commons messages onto IInboundApiKeyAdmin seam (re-arch C2; int->string keyId, +Methods, +SetApiKeyMethods)

This commit is contained in:
Joseph Doherty
2026-06-02 04:11:44 -04:00
parent 7f7ea3f3c9
commit 6518e93424
5 changed files with 316 additions and 108 deletions
@@ -37,44 +37,107 @@ public static class SecurityCommands
group.Add(listCmd); group.Add(listCmd);
var nameOption = new Option<string>("--name") { Description = "API key name", Required = true }; var nameOption = new Option<string>("--name") { Description = "API key name", Required = true };
var createMethodsOption = new Option<string>("--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" }; var createCmd = new Command("create") { Description = "Create an API key" };
createCmd.Add(nameOption); createCmd.Add(nameOption);
createCmd.Add(createMethodsOption);
createCmd.SetAction(async (ParseResult result) => createCmd.SetAction(async (ParseResult result) =>
{ {
var name = result.GetValue(nameOption)!; var name = result.GetValue(nameOption)!;
var methods = ParseMethods(result.GetValue(createMethodsOption));
return await CommandHelpers.ExecuteCommandAsync( 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); group.Add(createCmd);
var idOption = new Option<int>("--id") { Description = "API key ID", Required = true }; var deleteKeyIdOption = new Option<string>("--key-id") { Description = "API key ID", Required = true };
var deleteCmd = new Command("delete") { Description = "Delete an API key" }; var deleteCmd = new Command("delete") { Description = "Delete an API key" };
deleteCmd.Add(idOption); deleteCmd.Add(deleteKeyIdOption);
deleteCmd.SetAction(async (ParseResult result) => deleteCmd.SetAction(async (ParseResult result) =>
{ {
var id = result.GetValue(idOption); var keyId = result.GetValue(deleteKeyIdOption)!;
return await CommandHelpers.ExecuteCommandAsync( return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new DeleteApiKeyCommand(id)); result, urlOption, formatOption, usernameOption, passwordOption, new DeleteApiKeyCommand(keyId));
}); });
group.Add(deleteCmd); group.Add(deleteCmd);
var updateIdOption = new Option<int>("--id") { Description = "API key ID", Required = true }; var updateKeyIdOption = new Option<string>("--key-id") { Description = "API key ID", Required = true };
var enabledOption = new Option<bool>("--enabled") { Description = "Enable or disable", Required = true }; var enabledOption = new Option<bool>("--enabled") { Description = "Enable or disable", Required = true };
var updateCmd = new Command("update") { Description = "Enable or disable an API key" }; var updateCmd = new Command("update") { Description = "Enable or disable an API key" };
updateCmd.Add(updateIdOption); updateCmd.Add(updateKeyIdOption);
updateCmd.Add(enabledOption); updateCmd.Add(enabledOption);
updateCmd.SetAction(async (ParseResult result) => updateCmd.SetAction(async (ParseResult result) =>
{ {
var id = result.GetValue(updateIdOption); var keyId = result.GetValue(updateKeyIdOption)!;
var enabled = result.GetValue(enabledOption); var enabled = result.GetValue(enabledOption);
return await CommandHelpers.ExecuteCommandAsync( 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); group.Add(updateCmd);
var setMethodsKeyIdOption = new Option<string>("--key-id") { Description = "API key ID", Required = true };
var setMethodsOption = new Option<string>("--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; return group;
} }
/// <summary>
/// Splits a comma-separated <c>--methods</c> 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).
/// </summary>
/// <param name="raw">The raw delimited option value.</param>
private static IReadOnlyList<string> ParseMethods(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
return Array.Empty<string>();
return raw
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToArray();
}
/// <summary>
/// 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.
/// </summary>
/// <param name="json">The JSON success body returned by the management API.</param>
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<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption) private static Command BuildRoleMapping(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{ {
var group = new Command("role-mapping") { Description = "Manage LDAP role mappings" }; var group = new Command("role-mapping") { Description = "Manage LDAP role mappings" };
@@ -1,13 +1,14 @@
namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
public record ListApiKeysCommand; public record ListApiKeysCommand;
public record CreateApiKeyCommand(string Name); public record CreateApiKeyCommand(string Name, IReadOnlyList<string> Methods);
public record DeleteApiKeyCommand(int ApiKeyId); public record DeleteApiKeyCommand(string KeyId);
public record ListRoleMappingsCommand; public record ListRoleMappingsCommand;
public record CreateRoleMappingCommand(string LdapGroupName, string Role); public record CreateRoleMappingCommand(string LdapGroupName, string Role);
public record UpdateRoleMappingCommand(int MappingId, string LdapGroupName, string Role); public record UpdateRoleMappingCommand(int MappingId, string LdapGroupName, string Role);
public record DeleteRoleMappingCommand(int MappingId); 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<string> Methods);
public record ListScopeRulesCommand(int MappingId); public record ListScopeRulesCommand(int MappingId);
public record AddScopeRuleCommand(int MappingId, int SiteId); public record AddScopeRuleCommand(int MappingId, int SiteId);
public record DeleteScopeRuleCommand(int ScopeRuleId); public record DeleteScopeRuleCommand(int ScopeRuleId);
@@ -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.Security;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; 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.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView; using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
@@ -157,7 +158,7 @@ public class ManagementActor : ReceiveActor
or ListRoleMappingsCommand or CreateRoleMappingCommand or ListRoleMappingsCommand or CreateRoleMappingCommand
or UpdateRoleMappingCommand or DeleteRoleMappingCommand or UpdateRoleMappingCommand or DeleteRoleMappingCommand
or ListApiKeysCommand or CreateApiKeyCommand or DeleteApiKeyCommand or ListApiKeysCommand or CreateApiKeyCommand or DeleteApiKeyCommand
or UpdateApiKeyCommand or UpdateApiKeyCommand or SetApiKeyMethodsCommand
or ListScopeRulesCommand or AddScopeRuleCommand or DeleteScopeRuleCommand or ListScopeRulesCommand or AddScopeRuleCommand or DeleteScopeRuleCommand
// QueryAuditLogCommand: legacy Action/EntityType filter on the // QueryAuditLogCommand: legacy Action/EntityType filter on the
// configuration audit log. Gated Admin-only so this older path is // 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), CreateApiKeyCommand cmd => await HandleCreateApiKey(sp, cmd, user.Username),
DeleteApiKeyCommand cmd => await HandleDeleteApiKey(sp, cmd, user.Username), DeleteApiKeyCommand cmd => await HandleDeleteApiKey(sp, cmd, user.Username),
UpdateApiKeyCommand cmd => await HandleUpdateApiKey(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), ListScopeRulesCommand cmd => await HandleListScopeRules(sp, cmd),
AddScopeRuleCommand cmd => await HandleAddScopeRule(sp, cmd, user.Username), AddScopeRuleCommand cmd => await HandleAddScopeRule(sp, cmd, user.Username),
DeleteScopeRuleCommand cmd => await HandleDeleteScopeRule(sp, cmd, user.Username), DeleteScopeRuleCommand cmd => await HandleDeleteScopeRule(sp, cmd, user.Username),
@@ -1295,52 +1297,44 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleListApiKeys(IServiceProvider sp) private static async Task<object?> HandleListApiKeys(IServiceProvider sp)
{ {
var repo = sp.GetRequiredService<IInboundApiRepository>(); // Inbound-API key re-arch (C2): keys are now managed through the shared
var keys = await repo.GetAllApiKeysAsync(); // IInboundApiKeyAdmin seam (over the ZB.MOM.WW.Auth.ApiKeys library) rather than
// the SQL Server IInboundApiRepository. The seam projection is hash-free by
// ConfigurationDatabase-012: list/read paths must not expose the stored key // construction — only identity, status, and method-scopes are returned; the
// hash — it is a credential artifact. Only identity and status are returned; // secret is shown once at creation (token) and is never retrievable.
// the plaintext key is shown once at creation and is never retrievable. var admin = sp.GetRequiredService<IInboundApiKeyAdmin>();
var keys = await admin.ListAsync();
return keys return keys
.Select(k => new { k.Id, k.Name, k.IsEnabled }) .Select(k => new { k.KeyId, k.Name, k.Enabled, k.Methods })
.ToList(); .ToList();
} }
private static async Task<object?> HandleCreateApiKey(IServiceProvider sp, CreateApiKeyCommand cmd, string user) private static async Task<object?> HandleCreateApiKey(IServiceProvider sp, CreateApiKeyCommand cmd, string user)
{ {
var repo = sp.GetRequiredService<IInboundApiRepository>(); // Inbound-API key re-arch (C2): the library mints the key, persists only the
// peppered hash, and assembles the one-time bearer token (sbk_<keyId>_<secret>).
// 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<IInboundApiKeyAdmin>();
var created = await admin.CreateAsync(cmd.Name, cmd.Methods);
// ConfigurationDatabase-012: generate a high-entropy random key, persist only await AuditAsync(sp, user, "Create", "ApiKey", created.KeyId, cmd.Name,
// its peppered hash, and return the plaintext to the caller exactly once. The new { created.KeyId, cmd.Name });
// plaintext is never stored — the ApiKey entity carries only KeyHash.
var hasher = sp.GetService<IApiKeyHasher>() ?? ApiKeyHasher.Default;
var plaintextKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
var apiKey = ApiKey.FromHash(cmd.Name, hasher.Hash(plaintextKey));
apiKey.IsEnabled = true;
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 return new
{ {
apiKey.Id, created.KeyId,
apiKey.Name, cmd.Name,
apiKey.IsEnabled, created.Token,
PlaintextKey = plaintextKey,
}; };
} }
private static async Task<object?> HandleDeleteApiKey(IServiceProvider sp, DeleteApiKeyCommand cmd, string user) private static async Task<object?> HandleDeleteApiKey(IServiceProvider sp, DeleteApiKeyCommand cmd, string user)
{ {
var repo = sp.GetRequiredService<IInboundApiRepository>(); var admin = sp.GetRequiredService<IInboundApiKeyAdmin>();
await repo.DeleteApiKeyAsync(cmd.ApiKeyId); var deleted = await admin.DeleteAsync(cmd.KeyId);
await repo.SaveChangesAsync(); await AuditAsync(sp, user, "Delete", "ApiKey", cmd.KeyId, cmd.KeyId, null);
await AuditAsync(sp, user, "Delete", "ApiKey", cmd.ApiKeyId.ToString(), cmd.ApiKeyId.ToString(), null); return deleted;
return true;
} }
// ======================================================================== // ========================================================================
@@ -1761,14 +1755,23 @@ public class ManagementActor : ReceiveActor
private static async Task<object?> HandleUpdateApiKey(IServiceProvider sp, UpdateApiKeyCommand cmd, string user) private static async Task<object?> HandleUpdateApiKey(IServiceProvider sp, UpdateApiKeyCommand cmd, string user)
{ {
var repo = sp.GetRequiredService<IInboundApiRepository>(); // Inbound-API key re-arch (C2): enable/disable via the shared seam (no secret change).
var key = await repo.GetApiKeyByIdAsync(cmd.ApiKeyId) var admin = sp.GetRequiredService<IInboundApiKeyAdmin>();
?? throw new ManagementCommandException($"ApiKey with ID {cmd.ApiKeyId} not found."); await admin.SetEnabledAsync(cmd.KeyId, cmd.IsEnabled);
key.IsEnabled = cmd.IsEnabled; await AuditAsync(sp, user, "Update", "ApiKey", cmd.KeyId, cmd.KeyId,
await repo.UpdateApiKeyAsync(key); new { cmd.KeyId, cmd.IsEnabled });
await repo.SaveChangesAsync(); return new { cmd.KeyId, cmd.IsEnabled };
await AuditAsync(sp, user, "Update", "ApiKey", key.Id.ToString(), key.Name, new { key.Id, key.Name, key.IsEnabled }); }
return key;
private static async Task<object?> 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<IInboundApiKeyAdmin>();
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<object?> HandleListScopeRules(IServiceProvider sp, ListScopeRulesCommand cmd) private static async Task<object?> HandleListScopeRules(IServiceProvider sp, ListScopeRulesCommand cmd)
@@ -4,33 +4,32 @@ using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute; using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
using ZB.MOM.WW.ScadaBridge.ManagementService; using ZB.MOM.WW.ScadaBridge.ManagementService;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary> /// <summary>
/// ConfigurationDatabase-012: creating an API key must generate a random key, /// Inbound-API key re-arch (C2): the ManagementActor API-key management path now drives the
/// persist only its peppered hash, and return the plaintext to the caller exactly /// Commons <see cref="IInboundApiKeyAdmin"/> seam (added in C1) instead of the SQL Server
/// once. The plaintext must never reach the stored entity. /// <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> /// </summary>
public class ApiKeyCreationTests : TestKit, IDisposable public class ApiKeyCreationTests : TestKit, IDisposable
{ {
private const string Pepper = "a-sufficiently-long-server-side-pepper-value";
private readonly ServiceCollection _services = new(); private readonly ServiceCollection _services = new();
private readonly IInboundApiRepository _apiRepo = Substitute.For<IInboundApiRepository>(); private readonly FakeInboundApiKeyAdmin _admin = new();
private readonly IAuditService _auditService = Substitute.For<IAuditService>(); private readonly IAuditService _auditService = Substitute.For<IAuditService>();
public ApiKeyCreationTests() public ApiKeyCreationTests()
{ {
_services.AddScoped(_ => _apiRepo); _services.AddScoped<IInboundApiKeyAdmin>(_ => _admin);
_services.AddScoped(_ => _auditService); _services.AddScoped(_ => _auditService);
_services.AddSingleton<IApiKeyHasher>(new ApiKeyHasher(Pepper));
} }
private IActorRef CreateActor() private IActorRef CreateActor()
@@ -46,76 +45,218 @@ public class ApiKeyCreationTests : TestKit, IDisposable
void IDisposable.Dispose() => Shutdown(); void IDisposable.Dispose() => Shutdown();
[Fact] [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(); 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)); var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
// The response must carry the one-time plaintext key shown to the operator. using var doc = JsonDocument.Parse(response.JsonData);
var plaintext = ExtractPlaintextKey(response.JsonData); var root = doc.RootElement;
Assert.False(string.IsNullOrWhiteSpace(plaintext)); 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.False(string.IsNullOrWhiteSpace(keyId));
Assert.NotNull(persisted); Assert.False(string.IsNullOrWhiteSpace(token));
Assert.NotEqual(plaintext, persisted!.KeyHash); Assert.StartsWith($"sbk_{keyId}_", token);
Assert.Equal("MES-Production", name);
// The persisted hash must equal the peppered hash of the returned plaintext. // The seam was driven with the supplied name + methods.
var hasher = new ApiKeyHasher(Pepper); var created = Assert.Single(_admin.Keys.Values);
Assert.Equal(hasher.Hash(plaintext!), persisted.KeyHash); Assert.Equal("MES-Production", created.Name);
Assert.Equal(new[] { "MethodA", "MethodB" }, created.Methods);
} }
[Fact] [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(); 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)); var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotNull(persisted); // The hash-free seam never surfaces a stored hash; the only secret in the body
// The serialized response must not leak the stored hash as a usable artifact. // is the one-time token (sbk_<keyId>_<secret>).
Assert.DoesNotContain(persisted!.KeyHash, response.JsonData); Assert.DoesNotContain("hash", response.JsonData, StringComparison.OrdinalIgnoreCase);
} }
[Fact] [Fact]
public void CreateApiKey_TwoKeys_GenerateDistinctRandomValues() public void ListApiKeys_ReturnsKeysWithMethods()
{ {
var hashes = new List<string>(); _admin.Seed("key-1", "Service A", enabled: true, "M1", "M2");
_apiRepo.AddApiKeyAsync(Arg.Do<ApiKey>(k => hashes.Add(k.KeyHash))) _admin.Seed("key-2", "Service B", enabled: false, "M3");
.Returns(Task.CompletedTask);
var actor = CreateActor(); var actor = CreateActor();
actor.Tell(Envelope(new CreateApiKeyCommand("KeyA"), "Admin")); actor.Tell(Envelope(new ListApiKeysCommand(), "Admin"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
actor.Tell(Envelope(new CreateApiKeyCommand("KeyB"), "Admin"));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.Equal(2, hashes.Count); var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
Assert.NotEqual(hashes[0], hashes[1]);
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> /// <summary>
/// The create response is JSON carrying the one-time plaintext key under a /// Minimal in-memory fake of the C1 management seam. Models only what the
/// <c>PlaintextKey</c> (or <c>Key</c>) property. /// ManagementActor handlers exercise: create (assigns a keyId + assembles a
/// <c>sbk_&lt;keyId&gt;_&lt;secret&gt;</c> token), list, toggle-enabled, replace-methods,
/// and delete. Mirrors the real seam's bool/return semantics (false when a key is absent).
/// </summary> /// </summary>
private static string? ExtractPlaintextKey(string json) private sealed class FakeInboundApiKeyAdmin : IInboundApiKeyAdmin
{ {
using var doc = JsonDocument.Parse(json); public sealed record Entry(string KeyId, string Name, bool Enabled, IReadOnlyList<string> Methods);
var root = doc.RootElement;
if (root.TryGetProperty("plaintextKey", out var p) || root.TryGetProperty("PlaintextKey", out p)) public Dictionary<string, Entry> Keys { get; } = new(StringComparer.Ordinal);
return p.GetString();
if (root.TryGetProperty("key", out var k) || root.TryGetProperty("Key", out k)) public void Seed(string keyId, string name, bool enabled, params string[] methods) =>
return k.GetString(); Keys[keyId] = new Entry(keyId, name, enabled, methods.ToList());
return null;
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() public void UpdateApiKey_WithDesignRole_ReturnsUnauthorized()
{ {
var actor = CreateActor(); var actor = CreateActor();
var envelope = Envelope(new UpdateApiKeyCommand(1, true), "Design"); var envelope = Envelope(new UpdateApiKeyCommand("key-1", true), "Design");
actor.Tell(envelope); actor.Tell(envelope);