dc0a24f224
v2-ci / unit-tests (push) Failing after 1h8m43s
mxaccessgw 488649c+ acknowledges by AuthenticateUser as the operator and
writing <alarm>.AckMsg, so AcknowledgeAlarmRequest now needs a real Galaxy
operator_user + operator_domain + operator_password. The OPC UA data plane is
API-key/anonymous and carries no Galaxy password, so the driver resolves the
account per ack:
1. the session's API key's Galaxy ack identity, set on AdminUI /apikeys and
held ONLY in the encrypted Secrets store
(otopcua/apikeys/<keyId>/galaxy-ack-{user,password}); a key with a user but
no password secret is refused, never silently downgraded;
2. else the driver instance's AlarmAck fallback account (DriverConfig
FallbackUser/FallbackDomain/FallbackPasswordSecretRef; secret:/${secret:}/
env:/file:/dev: only — a literal is refused);
3. else DriverOperationRefusedException: nothing is sent, never an empty
operator_user. Refusals are excluded from the resilience retry/breaker.
- Pins ZB.MOM.WW.MxGateway.Client/Contracts 0.2.1 -> 0.3.0 (vendored into
nuget-packages/ from the Gitea feed, per the local-mxgw source mapping).
- The session ApiKeyId (not a secret) rides NativeAlarmAck ->
RouteNativeAlarmAck -> RouteAlarmAck -> AlarmAcknowledgeRequest; the password
is resolved in the driver and never travels through actor messages.
- The OPC UA principal + key id are appended to the AckMsg comment
("[OPC UA: <principal>, key <id>]") and logged, so a shared fallback
account does not lose who acknowledged.
- Password never logged: GalaxyAckCredential.ToString omits it, the
acknowledger scrubs it from every gateway message it echoes and withholds
any exception that echoes it; audit rows carry only the key id.
- AdminUI: /apikeys "Ack identity" editor (set/remove; delete-key clears it),
Galaxy driver form gains the fallback section.
- Synchronous OPC UA status for a refusal is follow-up #760 (the native ack
path is fire-and-forget after the Part 9 method returns).
Tests: request mapping (password populated), password absent from logs and
exception text (mutation-checked), key-mapped / fallback / refused paths at
resolver and driver level, breaker non-trip, key-id propagation through node
manager and DriverHostActor, AdminUI service + form round-trip.
Closes #749
157 lines
6.8 KiB
C#
157 lines
6.8 KiB
C#
using System.Text;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.Audit;
|
|
using ZB.MOM.WW.OtOpcUa.AdminUI.ApiKeyAck;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Config;
|
|
using ZB.MOM.WW.Secrets.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ApiKeyAck;
|
|
|
|
/// <summary>
|
|
/// lmxopcua#749 — the AdminUI half of the per-key Galaxy ack identity: written ENCRYPTED to the Secrets
|
|
/// store under the names the driver reads, listed without touching the password, cleared on request, and
|
|
/// audited without the credential.
|
|
/// </summary>
|
|
public sealed class ApiKeyGalaxyAckIdentityServiceTests
|
|
{
|
|
private const string KeyId = "0a1b2c3d4e5f6071";
|
|
private const string Password = "pw-S3cr3t!";
|
|
|
|
private readonly InMemorySecretStore _store = new();
|
|
private readonly XorCipher _cipher = new();
|
|
private readonly RecordingAudit _audit = new();
|
|
private readonly RecordingInvalidator _invalidator = new();
|
|
|
|
private ApiKeyGalaxyAckIdentityService Service() => new(_store, _cipher, _audit, _invalidator);
|
|
|
|
[Fact]
|
|
public async Task Set_writes_both_secrets_encrypted_under_the_driver_names()
|
|
{
|
|
await Service().SetAsync(KeyId, "alice", "CORP", Password, "admin", default);
|
|
|
|
var user = _store.Rows[GalaxyAckIdentitySecrets.UserSecretName(KeyId)];
|
|
var pw = _store.Rows[GalaxyAckIdentitySecrets.PasswordSecretName(KeyId)];
|
|
_cipher.Decrypt(user).ShouldBe("CORP\\alice");
|
|
_cipher.Decrypt(pw).ShouldBe(Password);
|
|
|
|
// At rest it is ciphertext, not the password.
|
|
Encoding.UTF8.GetString(pw.Ciphertext).ShouldNotContain(Password);
|
|
pw.Description.ShouldNotBeNull().ShouldNotContain(Password);
|
|
|
|
// Password written before the user (a reader never sees a user without its password).
|
|
_store.UpsertOrder.ShouldBe([GalaxyAckIdentitySecrets.PasswordSecretName(KeyId), GalaxyAckIdentitySecrets.UserSecretName(KeyId)]);
|
|
_invalidator.Invalidated.ShouldContain(GalaxyAckIdentitySecrets.PasswordSecretName(KeyId));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task List_returns_identity_and_never_decrypts_a_password()
|
|
{
|
|
var svc = Service();
|
|
await svc.SetAsync(KeyId, "alice", "CORP", Password, "admin", default);
|
|
await svc.SetAsync("ffff0000ffff0000", "bob", null, "", "admin", default);
|
|
_store.GetCalls.Clear();
|
|
|
|
var list = await svc.ListAsync(default);
|
|
|
|
list[KeyId].ShouldBe("CORP\\alice");
|
|
list["ffff0000ffff0000"].ShouldBe("bob");
|
|
_store.GetCalls.ShouldAllBe(n => n.EndsWith("/galaxy-ack-user"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Clear_removes_both_and_audits_without_the_credential()
|
|
{
|
|
var svc = Service();
|
|
await svc.SetAsync(KeyId, "alice", "CORP", Password, "admin", default);
|
|
|
|
await svc.ClearAsync(KeyId, "admin", default);
|
|
|
|
_store.Rows.ShouldBeEmpty();
|
|
(await svc.ListAsync(default)).ShouldBeEmpty();
|
|
_audit.Events.Select(e => e.Action).ShouldBe(["apikey.galaxy-ack-identity.set", "apikey.galaxy-ack-identity.clear"]);
|
|
_audit.Events.ShouldAllBe(e => e.Target == KeyId && e.Actor == "admin");
|
|
string.Join("|", _audit.Events.Select(e => e.ToString())).ShouldNotContain(Password);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData("")]
|
|
[InlineData("CORP\\alice")]
|
|
public async Task Set_rejects_missing_or_domain_qualified_user(string user)
|
|
{
|
|
await Should.ThrowAsync<ArgumentException>(() => Service().SetAsync(KeyId, user, null, Password, "admin", default));
|
|
_store.Rows.ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Set_rejects_a_malformed_key_id()
|
|
{
|
|
await Should.ThrowAsync<ArgumentException>(() => Service().SetAsync("../x", "alice", null, Password, "admin", default));
|
|
_store.Rows.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>Reversible stand-in for the AES-GCM envelope cipher — enough to prove the value is not stored in clear.</summary>
|
|
private sealed class XorCipher : ISecretCipher
|
|
{
|
|
public StoredSecret Encrypt(SecretName name, string plaintext, SecretContentType contentType) => new()
|
|
{
|
|
Name = name, ContentType = contentType,
|
|
Ciphertext = Encoding.UTF8.GetBytes(plaintext).Select(b => (byte)(b ^ 0x5A)).ToArray(),
|
|
Nonce = [], Tag = [], WrappedDek = [], WrapNonce = [], WrapTag = [],
|
|
KekId = "test", Revision = 0, CreatedUtc = DateTimeOffset.UtcNow, UpdatedUtc = DateTimeOffset.UtcNow,
|
|
};
|
|
|
|
public string Decrypt(StoredSecret secret) =>
|
|
Encoding.UTF8.GetString(secret.Ciphertext.Select(b => (byte)(b ^ 0x5A)).ToArray());
|
|
|
|
public StoredSecret Rewrap(StoredSecret secret, IMasterKeyProvider oldKek, IMasterKeyProvider newKek) =>
|
|
throw new NotSupportedException();
|
|
}
|
|
|
|
private sealed class RecordingAudit : IAuditWriter
|
|
{
|
|
public List<AuditEvent> Events { get; } = [];
|
|
public Task WriteAsync(AuditEvent evt, CancellationToken ct = default) { Events.Add(evt); return Task.CompletedTask; }
|
|
}
|
|
|
|
private sealed class RecordingInvalidator : ISecretCacheInvalidator
|
|
{
|
|
public List<string> Invalidated { get; } = [];
|
|
public void Invalidate(SecretName name) => Invalidated.Add(name.Value);
|
|
}
|
|
|
|
private sealed class InMemorySecretStore : ISecretStore
|
|
{
|
|
public Dictionary<string, StoredSecret> Rows { get; } = new();
|
|
public List<string> UpsertOrder { get; } = [];
|
|
public List<string> GetCalls { get; } = [];
|
|
|
|
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct)
|
|
{
|
|
GetCalls.Add(name.Value);
|
|
return Task.FromResult(Rows.TryGetValue(name.Value, out var r) ? r : null);
|
|
}
|
|
|
|
public Task UpsertAsync(StoredSecret row, CancellationToken ct)
|
|
{
|
|
UpsertOrder.Add(row.Name.Value);
|
|
Rows[row.Name.Value] = row;
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct) =>
|
|
Task.FromResult(Rows.Remove(name.Value));
|
|
|
|
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) =>
|
|
Task.FromResult<IReadOnlyList<SecretMetadata>>(Rows.Values.Select(r => new SecretMetadata
|
|
{
|
|
Name = r.Name, ContentType = r.ContentType, KekId = r.KekId, Revision = r.Revision,
|
|
CreatedUtc = r.CreatedUtc, UpdatedUtc = r.UpdatedUtc,
|
|
}).ToList());
|
|
|
|
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) => throw new NotSupportedException();
|
|
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) => throw new NotSupportedException();
|
|
public Task<bool> ApplyRewrapAsync(StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) => throw new NotSupportedException();
|
|
}
|
|
}
|