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);
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" };
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<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" };
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<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 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<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;
}
/// <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)
{
var group = new Command("role-mapping") { Description = "Manage LDAP role mappings" };
@@ -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<string> 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<string> Methods);
public record ListScopeRulesCommand(int MappingId);
public record AddScopeRuleCommand(int MappingId, int SiteId);
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.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<object?> HandleListApiKeys(IServiceProvider sp)
{
var repo = sp.GetRequiredService<IInboundApiRepository>();
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<IInboundApiKeyAdmin>();
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<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
// 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<IApiKeyHasher>() ?? 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<object?> HandleDeleteApiKey(IServiceProvider sp, DeleteApiKeyCommand cmd, string user)
{
var repo = sp.GetRequiredService<IInboundApiRepository>();
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<IInboundApiKeyAdmin>();
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<object?> HandleUpdateApiKey(IServiceProvider sp, UpdateApiKeyCommand cmd, string user)
{
var repo = sp.GetRequiredService<IInboundApiRepository>();
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<IInboundApiKeyAdmin>();
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<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)
@@ -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_&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>
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);