feat(auth): ScadaBridge ManagementActor + CLI + Commons messages onto IInboundApiKeyAdmin seam (re-arch C2; int->string keyId, +Methods, +SetApiKeyMethods)
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user