fix(security): enforce template locks on native alarm source overrides at flatten and management-command level

Add IsLocked to ResolvedNativeAlarmSource; FlatteningService skips overrides on a
locked source (mirrors attribute/alarm lock rules); ManagementActor SetInstanceNativeAlarmSourceOverride
flattens the instance and rejects an override on a locked source (and rejects a
canonical name that does not resolve, preventing dangling overrides).

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-09 16:28:52 -04:00
parent ab6077708f
commit 57f7f65772
6 changed files with 109 additions and 3 deletions
@@ -12,6 +12,8 @@ using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;
@@ -232,6 +234,39 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.Contains("Pump1", response.JsonData);
}
[Fact]
public void SetNativeAlarmSourceOverride_OnLockedSource_ReturnsManagementError()
{
// #05-T13: the ManagementActor must reject an override targeting a source
// that is locked at the template level, before persisting anything.
var pipeline = Substitute.For<IFlatteningPipeline>();
var flattened = new FlattenedConfiguration
{
NativeAlarmSources = new List<ResolvedNativeAlarmSource>
{
new() { CanonicalName = "Pressure", IsLocked = true, Source = "Template" }
}
};
pipeline.FlattenAndValidateAsync(10, Arg.Any<CancellationToken>(), Arg.Any<bool>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
_services.AddScoped(_ => pipeline);
var actor = CreateActor();
var envelope = Envelope(
new SetInstanceNativeAlarmSourceOverrideCommand(10, "Pressure", "OtherOpc", null, null),
"Deployer");
actor.Tell(envelope);
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
Assert.Contains("locked", response.Error, StringComparison.OrdinalIgnoreCase);
// The override must NOT be persisted.
_templateRepo.DidNotReceive().AddInstanceNativeAlarmSourceOverrideAsync(
Arg.Any<InstanceNativeAlarmSourceOverride>());
}
// ========================================================================
// 5. Error handling test -- service throws exception
// ========================================================================
@@ -2479,7 +2514,21 @@ public class ManagementActorTests : TestKit, IDisposable
[Fact]
public void SetInstanceNativeAlarmSourceOverride_WithDeploymentRole_ReturnsSuccess()
{
// No prior override → Add path.
// No prior override → Add path. The source resolves and is UNLOCKED, so the
// lock guard (#05-T13) lets the override through.
var pipeline = Substitute.For<IFlatteningPipeline>();
var flattened = new FlattenedConfiguration
{
NativeAlarmSources = new List<ResolvedNativeAlarmSource>
{
new() { CanonicalName = "Pressure", IsLocked = false, Source = "Template" }
}
};
pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), Arg.Any<bool>())
.Returns(Result<FlatteningPipelineResult>.Success(
new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
_services.AddScoped(_ => pipeline);
var actor = CreateActor();
var envelope = Envelope(
new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", "Opc2", "ns=2;s=NEW", null),
@@ -789,6 +789,32 @@ public class FlatteningServiceTests
Assert.Equal("Override", result.Value.NativeAlarmSources[0].Source);
}
[Fact]
public void Flatten_LockedNativeAlarmSource_IgnoresInstanceOverride()
{
// #05-T13: a native alarm source locked at the template level must not be
// repointed by an instance override — the resolved source keeps the template
// values and its Source stays "Template" (mirrors attribute/alarm lock rules).
var template = CreateTemplate(1, "Base");
template.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Pressure")
{ ConnectionName = "Opc", SourceReference = "ns=2;s=DEFAULT", IsLocked = true });
var instance = CreateInstance();
instance.NativeAlarmSourceOverrides.Add(new InstanceNativeAlarmSourceOverride("Pressure")
{ ConnectionNameOverride = "OtherOpc", SourceReferenceOverride = "ns=2;s=Tank07" });
var result = _sut.Flatten(instance, [template],
new Dictionary<int, IReadOnlyList<TemplateComposition>>(),
new Dictionary<int, IReadOnlyList<Template>>(),
new Dictionary<int, DataConnection>());
Assert.True(result.IsSuccess);
var src = result.Value.NativeAlarmSources[0];
Assert.Equal("Opc", src.ConnectionName); // override ignored
Assert.Equal("ns=2;s=DEFAULT", src.SourceReference); // override ignored
Assert.Equal("Template", src.Source);
Assert.True(src.IsLocked);
}
// ── MV-4: ElementDataType carried through flatten/override ────────────
[Fact]