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
@@ -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)