b104760b3a
Standardize role string VALUES on the canonical vocabulary
(Administrator/Designer/Deployer/Viewer; Operator/Engineer unused here):
Admin -> Administrator
Design -> Designer
Deployment -> Deployer
Audit -> Administrator (COLLAPSE; accepted privilege escalation)
AuditReadOnly-> Viewer (COLLAPSE; keeps audit-read, no export)
SoD: OperationalAuditRoles = { Administrator, Viewer },
AuditExportRoles = { Administrator }
so Viewer reads the audit log + nav but cannot bulk-export, while
Administrator does both + holds the full admin surface (the documented,
accepted auditor/admin SoD collapse).
Atomic move across every enforcement site:
- Roles constants; AuthorizationPolicies (RequireClaim values + SoD arrays +
honest XML-doc); RoleMapper Deployer check.
- ManagementActor.GetRequiredRole switch + the hard-coded site-scope
admin-bypass (now Roles.Administrator at all 6 sites). Site-scoping logic
is otherwise unchanged.
- DebugStreamHub Administrator/Deployer gates (Deployer kept case-sensitive).
- CentralUI BrowseService/BindingTester Designer guards; LdapMappingForm
dropdown now offers canonical values (incl. Viewer).
- Config-DB seed (LdapGroupMappings Id 1-4) + EF migration CanonicalizeRoles:
Id-keyed UpdateData for seed rows + idempotent raw catch-all UPDATEs for
operator-added rows. Down is lossy on the collapse (documented in-file).
No pending model changes.
Tests reworked to the collapsed model across Security/CentralUI/
ManagementService/ConfigurationDatabase/Integration suites, incl. explicit
Viewer-reads-not-exports and former-Audit-now-Administrator-escalation cases.
CHANGELOG: BREAKING security note documenting the canonicalization + SoD
collapse.
350 lines
15 KiB
C#
350 lines
15 KiB
C#
using System.Text.Json;
|
|
using Akka.Actor;
|
|
using Akka.TestKit.Xunit2;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using NSubstitute;
|
|
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.ManagementService;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
|
|
|
/// <summary>
|
|
/// 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 "Administrator" role gate still applies to all five commands.
|
|
/// </summary>
|
|
public class ApiKeyCreationTests : TestKit, IDisposable
|
|
{
|
|
private readonly ServiceCollection _services = new();
|
|
private readonly FakeInboundApiKeyAdmin _admin = new();
|
|
private readonly IAuditService _auditService = Substitute.For<IAuditService>();
|
|
|
|
public ApiKeyCreationTests()
|
|
{
|
|
_services.AddScoped<IInboundApiKeyAdmin>(_ => _admin);
|
|
_services.AddScoped(_ => _auditService);
|
|
}
|
|
|
|
private IActorRef CreateActor()
|
|
{
|
|
var sp = _services.BuildServiceProvider();
|
|
return Sys.ActorOf(Props.Create(() => new ManagementActor(sp, NullLogger<ManagementActor>.Instance)));
|
|
}
|
|
|
|
private static ManagementEnvelope Envelope(object command, params string[] roles) =>
|
|
new(new AuthenticatedUser("admin", "Admin User", roles, Array.Empty<string>()),
|
|
command, Guid.NewGuid().ToString("N"));
|
|
|
|
void IDisposable.Dispose() => Shutdown();
|
|
|
|
[Fact]
|
|
public void CreateApiKey_ReturnsKeyIdAndOneTimeToken()
|
|
{
|
|
var actor = CreateActor();
|
|
actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", new[] { "MethodA", "MethodB" }), "Administrator"));
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
|
|
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();
|
|
|
|
Assert.False(string.IsNullOrWhiteSpace(keyId));
|
|
Assert.False(string.IsNullOrWhiteSpace(token));
|
|
Assert.StartsWith($"sbk_{keyId}_", token);
|
|
Assert.Equal("MES-Production", name);
|
|
|
|
// 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_AuditsTheCreate()
|
|
{
|
|
var actor = CreateActor();
|
|
actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", new[] { "MethodA" }), "Administrator"));
|
|
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" }), "Administrator"));
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
|
|
// 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 ListApiKeys_ReturnsKeysWithMethods()
|
|
{
|
|
_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 ListApiKeysCommand(), "Administrator"));
|
|
|
|
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), "Administrator"));
|
|
|
|
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"), "Administrator"));
|
|
|
|
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" }), "Administrator"));
|
|
|
|
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
|
|
|
Assert.Equal(new[] { "New1", "New2", "New3" }, _admin.Keys["key-1"].Methods);
|
|
|
|
// Fix 3 (review): response is now the richer { KeyId, Methods } shape.
|
|
using var doc = JsonDocument.Parse(response.JsonData);
|
|
Assert.Equal("key-1", doc.RootElement.GetProperty("keyId").GetString());
|
|
var methods = doc.RootElement.GetProperty("methods").EnumerateArray()
|
|
.Select(m => m.GetString()).ToArray();
|
|
Assert.Equal(new[] { "New1", "New2", "New3" }, methods);
|
|
|
|
_auditService.Received(1).LogAsync(
|
|
"admin", "Update", "ApiKey", "key-1", Arg.Any<string>(), Arg.Any<object?>());
|
|
}
|
|
|
|
// =========================================================================
|
|
// Fix 1 (review): not-found on mutating ops → ManagementError, no audit
|
|
// =========================================================================
|
|
|
|
[Fact]
|
|
public void UpdateApiKey_UnknownKey_ReturnsManagementError_AndDoesNotAudit()
|
|
{
|
|
// No keys seeded — "key-unknown" does not exist.
|
|
var actor = CreateActor();
|
|
actor.Tell(Envelope(new UpdateApiKeyCommand("key-unknown", false), "Administrator"));
|
|
|
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
|
|
|
Assert.Contains("key-unknown", response.Error);
|
|
// Seam returned false → audit must not have fired.
|
|
_auditService.DidNotReceive().LogAsync(
|
|
Arg.Any<string>(), "Update", "ApiKey", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>());
|
|
}
|
|
|
|
[Fact]
|
|
public void SetApiKeyMethods_UnknownKey_ReturnsManagementError_AndDoesNotAudit()
|
|
{
|
|
var actor = CreateActor();
|
|
actor.Tell(Envelope(new SetApiKeyMethodsCommand("key-unknown", new[] { "M1" }), "Administrator"));
|
|
|
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
|
|
|
Assert.Contains("key-unknown", response.Error);
|
|
_auditService.DidNotReceive().LogAsync(
|
|
Arg.Any<string>(), "Update", "ApiKey", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>());
|
|
}
|
|
|
|
[Fact]
|
|
public void DeleteApiKey_UnknownKey_ReturnsManagementError_AndDoesNotAudit()
|
|
{
|
|
var actor = CreateActor();
|
|
actor.Tell(Envelope(new DeleteApiKeyCommand("key-unknown"), "Administrator"));
|
|
|
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
|
|
|
Assert.Contains("key-unknown", response.Error);
|
|
_auditService.DidNotReceive().LogAsync(
|
|
Arg.Any<string>(), "Delete", "ApiKey", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>());
|
|
}
|
|
|
|
// =========================================================================
|
|
// Fix 2 (review): empty methods set is rejected before seam + audit
|
|
// =========================================================================
|
|
|
|
[Fact]
|
|
public void CreateApiKey_EmptyMethods_ReturnsManagementError()
|
|
{
|
|
var actor = CreateActor();
|
|
actor.Tell(Envelope(new CreateApiKeyCommand("MES-Production", Array.Empty<string>()), "Administrator"));
|
|
|
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
|
|
|
Assert.Contains("method", response.Error, StringComparison.OrdinalIgnoreCase);
|
|
// No key should have been created.
|
|
Assert.Empty(_admin.Keys);
|
|
_auditService.DidNotReceive().LogAsync(
|
|
Arg.Any<string>(), "Create", "ApiKey", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>());
|
|
}
|
|
|
|
[Fact]
|
|
public void SetApiKeyMethods_EmptyMethods_ReturnsManagementError()
|
|
{
|
|
_admin.Seed("key-1", "Service A", enabled: true, "M1");
|
|
|
|
var actor = CreateActor();
|
|
actor.Tell(Envelope(new SetApiKeyMethodsCommand("key-1", Array.Empty<string>()), "Administrator"));
|
|
|
|
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
|
|
|
Assert.Contains("method", response.Error, StringComparison.OrdinalIgnoreCase);
|
|
// Existing scope set must be unchanged.
|
|
Assert.Equal(new[] { "M1" }, _admin.Keys["key-1"].Methods);
|
|
_auditService.DidNotReceive().LogAsync(
|
|
Arg.Any<string>(), "Update", "ApiKey", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>());
|
|
}
|
|
|
|
[Theory]
|
|
[MemberData(nameof(AllApiKeyCommands))]
|
|
public void EveryApiKeyCommand_RequiresAdminRole(object command)
|
|
{
|
|
var actor = CreateActor();
|
|
// A Designer-role caller (not Administrator) must be rejected for every API-key command.
|
|
actor.Tell(Envelope(command, "Designer"));
|
|
|
|
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
|
Assert.Contains("Administrator", 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>
|
|
/// Minimal in-memory fake of the C1 management seam. Models only what the
|
|
/// ManagementActor handlers exercise: create (assigns a keyId + assembles a
|
|
/// <c>sbk_<keyId>_<secret></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 sealed class FakeInboundApiKeyAdmin : IInboundApiKeyAdmin
|
|
{
|
|
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);
|
|
}
|
|
}
|
|
}
|