Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CredentialReferenceSavePathTests.cs
T
Joseph Doherty a224fd6391 fix(security): scope operator-authored credential references per driver (#815)
A Designer could point a Galaxy / OpcUaClient endpoint at a host they control,
set a credential field to any reference the node can resolve
(env:ConnectionStrings__ConfigDb, env:ServerHistorian__ApiKey, secret:<any>,
Galaxy file:<any path>) and click Test Connect / live browse; the node resolved
the value and sent it to that host.

One shared rule in Core.Abstractions (CredentialReferences +
DriverCredentialPolicy) generalises the #803 Heidenhain namespace:
- Galaxy: secret:galaxy/... | secret:otopcua/galaxy/... | env:OTOPCUA_GALAXY_... |
  legacy env:GALAXY_MXGW_...; file: refused; dev:/literal API key unchanged.
- OpcUaClient: secret:opcuaclient/... | env:OTOPCUA_OPCUACLIENT_...; literals unchanged.
- Heidenhain adopts the shared parser/resolver (namespace unchanged).
Enforced at the AdminUI save path (DriverConfigSaveGate), the deploy gate
(CredentialReferenceDeployChecks, code CredentialReferenceOutOfScope), Test
Connect (AdminOperationsActor, before any probe), live browse
(BrowserSessionService, before any browser) and again at resolve time in every
driver/browser (fail closed, before any store/env read).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-11 01:28:58 -05:00

78 lines
4.2 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// lmxopcua#815 at the SAVE path (RawTreeService): a Galaxy / OpcUaClient credential reference outside the
/// driver's namespace never reaches the config DB, through driver and device create + update.
/// </summary>
[Trait("Category", "Unit")]
public sealed class CredentialReferenceSavePathTests
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
[Theory]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"Endpoint":"http://evil:5001","ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""")]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"file:appsettings.json"}}""")]
[InlineData(DriverTypeNames.Galaxy, """{"AlarmAck":{"FallbackPasswordSecretRef":"secret:otopcua/apikeys/ab/galaxy-ack-password"}}""")]
[InlineData(DriverTypeNames.OpcUaClient, """{"EndpointUrl":"opc.tcp://evil:4840","Password":"secret:otopcua/historian/api-key"}""")]
[InlineData(DriverTypeNames.OpcUaClient, """{"UserCertificatePassword":"env:ServerHistorian__ApiKey"}""")]
public async Task CreateDriver_refuses_an_out_of_namespace_reference_and_persists_nothing(string type, string config)
{
var name = RawTreeTestDb.SeedFresh();
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
var result = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "d815", type, config, Ct);
result.Ok.ShouldBeFalse();
result.Error!.ShouldContain("lmxopcua#815");
using var db = RawTreeTestDb.CreateNamed(name);
db.DriverInstances.ShouldNotContain(d => d.Name == "d815");
}
[Fact]
public async Task Update_paths_refuse_and_keep_the_stored_config()
{
var name = RawTreeTestDb.SeedFresh();
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
var created = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "gx", DriverTypeNames.Galaxy,
"""{"Gateway":{"Endpoint":"http://gw:5001","ApiKeySecretRef":"secret:galaxy/gx/apikey"}}""", Ct);
created.Ok.ShouldBeTrue(created.Error);
byte[] rv;
string deviceId;
byte[] deviceRv;
using (var db = RawTreeTestDb.CreateNamed(name))
{
rv = db.DriverInstances.Single(d => d.DriverInstanceId == created.CreatedId).RowVersion;
var device = db.Devices.Single(d => d.DriverInstanceId == created.CreatedId);
deviceId = device.DeviceId;
deviceRv = device.RowVersion;
}
(await svc.UpdateDriverAsync(created.CreatedId!, "gx",
"""{"Gateway":{"ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""", null, rv, Ct)).Ok.ShouldBeFalse();
(await svc.UpdateDeviceAsync(deviceId, "dev",
"""{"Gateway":{"ApiKeySecretRef":"env:ConnectionStrings__ConfigDb"}}""", true, deviceRv, Ct)).Ok.ShouldBeFalse();
(await svc.CreateDeviceAsync(created.CreatedId!, "dev2",
"""{"Gateway":{"ApiKeySecretRef":"secret:otopcua/historian/api-key"}}""", Ct)).Ok.ShouldBeFalse();
using var check = RawTreeTestDb.CreateNamed(name);
check.DriverInstances.Single(d => d.DriverInstanceId == created.CreatedId).DriverConfig.ShouldContain("secret:galaxy/gx/apikey");
check.Devices.Where(d => d.DriverInstanceId == created.CreatedId)
.ShouldAllBe(d => !d.DeviceConfig.Contains("ConnectionStrings") && !d.DeviceConfig.Contains("historian"));
}
[Theory]
[InlineData(DriverTypeNames.Galaxy, """{"Gateway":{"ApiKeySecretRef":"env:GALAXY_MXGW_API_KEY"},"AlarmAck":{"FallbackPasswordSecretRef":"secret:otopcua/galaxy/ack-fallback"}}""")]
[InlineData(DriverTypeNames.OpcUaClient, """{"Password":"literal-password","UserCertificatePassword":"secret:opcuaclient/plant/cert"}""")]
public async Task Namespaced_references_save(string type, string config)
{
var name = RawTreeTestDb.SeedFresh();
var svc = new RawTreeService(RawTreeTestDb.Factory(name));
var result = await svc.CreateDriverAsync(RawTreeTestDb.ClusterId, null, "ok815", type, config, Ct);
result.Ok.ShouldBeTrue(result.Error);
}
}