2ef8ee3ade
- OtOpcUaNodeManager overrides HistoryUpdateData / HistoryDeleteRawModified / HistoryDeleteAtTime, block-bridging to the optional IHistorianDataUpdater under the shared HistoryRead batch limiter + deadline. Insert/Replace/Update map to HistoryUpdateMode; Remove, IsDeleteModified, events and structure data are BadHistoryOperationUnsupported. Per-item codes stitched back in request order (MinValue SourceTimestamp => BadInvalidTimestamp, untypeable value => BadTypeMismatch); success clears the base's pre-seeded Unsupported error. - HistoryWrite AccessLevel bit OR-ed in for historized nodes when an updater is wired (raw + UNS); it is dispatch-load-bearing in the SDK base. OtOpcUaSdkServer.SetHistorianDataUpdater; the hosted service assigns it from an optional HistorianUpdaterHolder before SetSink, clears it on stop. - AuthorizeHistoryUpdate: ACL on => scoped OpcUaOperation.HistoryUpdate decision (replaces the role check); ACL off => #563 key re-check + new OpcUaDataPlaneRoles.HistoryUpdate role, conferred by ApiKeyInterimRoleMapper only from a Cluster-scoped HistoryUpdate grant (WriteOperate does not). - AdminUI ACL editor offers NodePermissions.HistoryUpdate. Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
84 lines
3.2 KiB
C#
84 lines
3.2 KiB
C#
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
|
|
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
|
|
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Host.Tests.OpcUa;
|
|
|
|
/// <summary>
|
|
/// The flag-off <see cref="OpcUaDataPlaneRoles.HistoryUpdate"/> role (pluggable historian, spec §5). The
|
|
/// broader mapper behaviour is covered by <see cref="ApiKeyAuthenticationTests"/>.
|
|
/// </summary>
|
|
public sealed class ApiKeyInterimRoleMapperTests
|
|
{
|
|
private const string Principal = "historian-editor";
|
|
|
|
[Fact]
|
|
public void A_cluster_scoped_history_update_grant_confers_the_history_update_role()
|
|
{
|
|
var roles = ApiKeyInterimRoleMapper.Map(
|
|
[Grant(NodeAclScopeKind.Cluster, NodePermissions.HistoryUpdate)], Principal);
|
|
|
|
roles.ShouldBe([OpcUaDataPlaneRoles.HistoryUpdate]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Writing a live value and rewriting recorded history are different authorities, so a key that can
|
|
/// write must not thereby gain HistoryUpdate — neither from WriteOperate nor from any composite bundle
|
|
/// (HistoryUpdate is deliberately in none of them).
|
|
/// </summary>
|
|
[Fact]
|
|
public void Write_operate_and_the_bundles_do_not_confer_history_update()
|
|
{
|
|
foreach (var flags in new[]
|
|
{
|
|
NodePermissions.WriteOperate, NodePermissions.HistoryRead, NodePermissions.ReadOnly,
|
|
NodePermissions.Operator, NodePermissions.Engineer, NodePermissions.Admin,
|
|
})
|
|
{
|
|
ApiKeyInterimRoleMapper.Map([Grant(NodeAclScopeKind.Cluster, flags)], Principal)
|
|
.ShouldNotContain(OpcUaDataPlaneRoles.HistoryUpdate, $"{flags} must not confer HistoryUpdate");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void A_narrower_scoped_history_update_grant_confers_nothing()
|
|
{
|
|
foreach (var narrower in new[]
|
|
{
|
|
NodeAclScopeKind.Equipment, NodeAclScopeKind.UnsArea,
|
|
NodeAclScopeKind.UnsLine, NodeAclScopeKind.Namespace,
|
|
})
|
|
{
|
|
ApiKeyInterimRoleMapper.Map([Grant(narrower, NodePermissions.HistoryUpdate)], Principal)
|
|
.ShouldBeEmpty($"a {narrower}-scoped grant must not confer a server-wide role");
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void History_update_unions_with_the_other_roles()
|
|
{
|
|
var roles = ApiKeyInterimRoleMapper.Map(
|
|
[
|
|
Grant(NodeAclScopeKind.Cluster, NodePermissions.WriteOperate),
|
|
Grant(NodeAclScopeKind.Cluster, NodePermissions.HistoryUpdate | NodePermissions.AlarmAcknowledge),
|
|
], Principal);
|
|
|
|
roles.ShouldBe(
|
|
[OpcUaDataPlaneRoles.WriteOperate, OpcUaDataPlaneRoles.AlarmAck, OpcUaDataPlaneRoles.HistoryUpdate],
|
|
ignoreOrder: true);
|
|
}
|
|
|
|
private static NodeAcl Grant(NodeAclScopeKind scope, NodePermissions flags) => new()
|
|
{
|
|
NodeAclId = $"ACL-{scope}-{flags}",
|
|
ClusterId = "MAIN",
|
|
Principal = Principal,
|
|
ScopeKind = scope,
|
|
ScopeId = scope == NodeAclScopeKind.Cluster ? null : "SOME-SCOPE",
|
|
PermissionFlags = flags,
|
|
};
|
|
}
|