Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Acls/AclScopeTestDb.cs
T
Joseph Doherty d542ab5b7c feat(security): author ACL grants from the real tree, not free text (#520 stage 7)
The last stage of #520. Enforcement landed in stages 5-6; this closes the
authoring end, where a grant could be saved, deployed and sealed green while
resolving to nothing.

Scope kind and scope target are now chosen TOGETHER from a picker over the
cluster's raw and UNS trees (AclScopePickerModal -> AclScopeTree, backed by
IAclScopeService). They were previously an unrelated dropdown and a free-text
box, so "RawDevice + a TagId" was a well-formed row. Because the trees are
rooted at the grant's own cluster and each node supplies its own kind,
NodeAclScopeCrossCluster, NodeAclScopeIdUnexpected, NodeAclScopeIdMissing and
NodeAclScopeKindRetired all become unauthorable by construction.

CheckScopeAsync mirrors DraftValidator.ValidateNodeAclScopes rule for rule and
blocks the save when a scope resolves to nothing - re-checked on submit, not
trusted from form load, because a row whose entity was deleted since is exactly
where the operator never touches the picker. The list page marks such rows
unresolved with the reason. The principal field suggests known principals and
reports how many live keys reach the one typed; reaching nobody warns rather
than blocks, since authoring ahead of minting is legitimate.

Deliberate departures from the plan line, all recorded in the design doc:
- The banners are reshaped, not removed. "Not enforced" stopped being true at
  stage 6, but enforcement is still a per-node flag the AdminUI cannot see, so
  a narrow grant really does confer nothing on a default-configured node.
- A Cluster row produces a NULL ScopeId. The deploy gate rejects one that
  carries an id, so filling in the cluster id would author undeployable rows.
- AclScopeTree is a new component rather than a third mode on RawTree, which
  already carries the /raw editing tree and the equipment reference picker.
- UNS tags are not offered: a UNS signal is a UnsTagReference, and a Tag grant
  already covers the raw node and every UNS projection of it.

Also fixed: the ACL editor had a DataAnnotationsValidator and no
ValidationSummary, so an invalid NodeAclId made Create a silent no-op.

Tests: 30 in AdminUI.Tests/Acls, falsified with six production mutations -
each turned exactly the intended tests red and no others.

Live gate PASSED on docker-dev, 11 legs; both central containers asserted on
the same image by container id. The pair that matters is legs 8 and 11: the
page and the deploy gate reject the same row with the same reason. Legs 5 and
9 are the positive controls - without them the gate passes against a page that
refuses everything. Not covered: UNS-realm scope selection (SITE-A has no UNS
areas). Rig restored to zero ACL rows.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
2026-08-01 10:53:31 -04:00

144 lines
5.9 KiB
C#

using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.AdminUI.Acls;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Acls;
/// <summary>
/// Fixture for <see cref="AclScopeService"/> — two clusters, so cross-cluster scoping is testable, and
/// one deliberately broken UNS chain, so "the row exists but no cluster owns it" is testable
/// separately from "the row does not exist".
/// </summary>
internal static class AclScopeTestDb
{
public const string ClusterId = "MAIN";
public const string OtherClusterId = "SIDE";
public const string FolderId = "RF-plcs";
public const string DriverId = "DRV-modbus";
public const string DeviceId = "DEV-1";
public const string TagGroupId = "TG-motors";
public const string TagId = "TAG-speed";
public const string AreaId = "AREA-1";
public const string LineId = "LINE-1";
public const string EquipmentId = "EQ-1";
/// <summary>An equipment whose line does not exist — present, but owned by no cluster.</summary>
public const string OrphanEquipmentId = "EQ-orphan";
/// <summary>A driver in the OTHER cluster, for the cross-cluster rule.</summary>
public const string OtherDriverId = "DRV-side";
public const string LivePrincipal = "scadabridge-dcl";
public const string StalePrincipal = "retired-consumer";
/// <summary>The clock the fixture's key expiries are expressed against.</summary>
public static readonly DateTime Now = new(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc);
public static OtOpcUaConfigDbContext CreateNamed(string name) =>
new(new DbContextOptionsBuilder<OtOpcUaConfigDbContext>().UseInMemoryDatabase(name).Options);
public static IDbContextFactory<OtOpcUaConfigDbContext> Factory(string name) => new NamedFactory(name);
/// <summary>Seeds a fresh uniquely-named InMemory database and returns a service over it.</summary>
public static AclScopeService NewService(TimeProvider? time = null)
{
var name = $"acl-{Guid.NewGuid():N}";
using (var db = CreateNamed(name)) { Seed(db); }
var factory = Factory(name);
return new AclScopeService(factory, new RawTreeService(factory), new UnsTreeService(factory), time);
}
public static void Seed(OtOpcUaConfigDbContext db)
{
db.ServerClusters.Add(new ServerCluster
{
ClusterId = ClusterId, Name = "Main", Enterprise = "zb", Site = "warsaw-west",
RedundancyMode = RedundancyMode.None, CreatedBy = "test",
});
db.ServerClusters.Add(new ServerCluster
{
ClusterId = OtherClusterId, Name = "Side", Enterprise = "zb", Site = "krakow",
RedundancyMode = RedundancyMode.None, CreatedBy = "test",
});
// MAIN raw: folder → driver → device → group → tag.
db.RawFolders.Add(new RawFolder
{
RawFolderId = FolderId, ClusterId = ClusterId, ParentRawFolderId = null, Name = "PLCs",
});
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = DriverId, ClusterId = ClusterId, RawFolderId = FolderId,
Name = "modbus-1", DriverType = "Modbus", DriverConfig = "{}",
});
db.Devices.Add(new Device
{
DeviceId = DeviceId, DriverInstanceId = DriverId, Name = "dev1", DeviceConfig = "{}",
});
db.TagGroups.Add(new TagGroup
{
TagGroupId = TagGroupId, DeviceId = DeviceId, ParentTagGroupId = null, Name = "Motors",
});
db.Tags.Add(new Tag
{
TagId = TagId, DeviceId = DeviceId, TagGroupId = TagGroupId, Name = "speed",
DataType = "Float", AccessLevel = TagAccessLevel.ReadWrite, TagConfig = "{}",
});
// SIDE raw: one driver, so a cross-cluster scope has something real to point at.
db.DriverInstances.Add(new DriverInstance
{
DriverInstanceId = OtherDriverId, ClusterId = OtherClusterId, RawFolderId = null,
Name = "s7-side", DriverType = "S7", DriverConfig = "{}",
});
// MAIN UNS: area → line → equipment.
db.UnsAreas.Add(new UnsArea { UnsAreaId = AreaId, ClusterId = ClusterId, Name = "Packaging" });
db.UnsLines.Add(new UnsLine { UnsLineId = LineId, UnsAreaId = AreaId, Name = "Line 1" });
db.Equipment.Add(new Equipment
{
EquipmentId = EquipmentId, EquipmentUuid = Guid.NewGuid(), UnsLineId = LineId,
Name = "packer-1", MachineCode = "packer_001",
});
// An equipment whose line was deleted: it exists, but nothing owns it.
db.Equipment.Add(new Equipment
{
EquipmentId = OrphanEquipmentId, EquipmentUuid = Guid.NewGuid(), UnsLineId = "LINE-GONE",
Name = "stranded", MachineCode = "stranded_001",
});
db.ApiKeys.Add(Key("k1", LivePrincipal, enabled: true, expires: null));
db.ApiKeys.Add(Key("k2", LivePrincipal, enabled: true, expires: null));
db.ApiKeys.Add(Key("k3", LivePrincipal, enabled: false, expires: null));
db.ApiKeys.Add(Key("k4", StalePrincipal, enabled: true, expires: Now.AddDays(-1)));
db.SaveChanges();
}
private static ApiKey Key(string id, string principal, bool enabled, DateTime? expires) => new()
{
ApiKeyId = id,
Name = id,
SecretHash = "not-a-secret",
Principal = principal,
Enabled = enabled,
CreatedUtc = Now.AddDays(-30),
ExpiresUtc = expires,
};
private sealed class NamedFactory(string name) : IDbContextFactory<OtOpcUaConfigDbContext>
{
public OtOpcUaConfigDbContext CreateDbContext() => CreateNamed(name);
public Task<OtOpcUaConfigDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(CreateNamed(name));
}
}