Compare commits
4 Commits
v2-release
...
v2-release
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba42967943 | ||
| b912969805 | |||
|
|
f8d5b0fdbb | ||
| cc069509cd |
@@ -1,7 +1,7 @@
|
|||||||
# v2 Release Readiness
|
# v2 Release Readiness
|
||||||
|
|
||||||
> **Last updated**: 2026-04-19 (Phase 6.4 data layer merged)
|
> **Last updated**: 2026-04-19 (release blocker #1 closed — Phase 6.2 dispatch wiring shipped)
|
||||||
> **Status**: **NOT YET RELEASE-READY** — four Phase 6 data-layer ships have landed, but several production-path wirings are still deferred.
|
> **Status**: **NOT YET RELEASE-READY** — two of three release blockers remain (Phase 6.1 Stream D config-cache wiring + Phase 6.3 Streams A/C/F redundancy runtime).
|
||||||
|
|
||||||
This doc is the single view of where v2 stands against its release criteria. Update it whenever a deferred follow-up closes or a new release blocker is discovered.
|
This doc is the single view of where v2 stands against its release criteria. Update it whenever a deferred follow-up closes or a new release blocker is discovered.
|
||||||
|
|
||||||
@@ -26,19 +26,20 @@ This doc is the single view of where v2 stands against its release criteria. Upd
|
|||||||
|
|
||||||
Ordered by severity + impact on production fitness.
|
Ordered by severity + impact on production fitness.
|
||||||
|
|
||||||
### Security — Phase 6.2 dispatch wiring (task #143)
|
### ~~Security — Phase 6.2 dispatch wiring~~ (task #143 — **CLOSED** 2026-04-19, PR #94)
|
||||||
|
|
||||||
The `AuthorizationGate` + `IPermissionEvaluator` + `PermissionTrie` stack is fully built and unit-tested, **but no dispatch path in `DriverNodeManager` actually calls it**. Every OPC UA Read / Write / HistoryRead / Browse / Call / CreateMonitoredItems on the live server currently runs through the pre-Phase-6.2 code path (which gates Write via `WriteAuthzPolicy` only — no per-tag ACL).
|
**Closed**. `AuthorizationGate` + `NodeScopeResolver` now thread through `OpcUaApplicationHost → OtOpcUaServer → DriverNodeManager`. `OnReadValue` + `OnWriteValue` + all four HistoryRead paths call `gate.IsAllowed(identity, operation, scope)` before the invoker. Production deployments activate enforcement by constructing `OpcUaApplicationHost` with an `AuthorizationGate(StrictMode: true)` + populating the `NodeAcl` table.
|
||||||
|
|
||||||
Closing this requires:
|
Additional Stream C surfaces (not release-blocking, hardening only):
|
||||||
|
|
||||||
- Thread `AuthorizationGate` through `OpcUaApplicationHost → OtOpcUaServer → DriverNodeManager` (the same plumbing path Phase 6.1's `DriverResiliencePipelineBuilder` took).
|
- Browse + TranslateBrowsePathsToNodeIds gating with ancestor-visibility logic per `acl-design.md` §Browse.
|
||||||
- Build a `NodeScopeResolver` that maps `fullRef → NodeScope` via a live DB lookup of the tag's UnsArea / UnsLine / Equipment path. Cache per generation.
|
- CreateMonitoredItems + TransferSubscriptions gating with per-item `(AuthGenerationId, MembershipVersion)` stamp so revoked grants surface `BadUserAccessDenied` within one publish cycle (decision #153).
|
||||||
- Call `gate.IsAllowed(identity, operation, scope)` in OnReadValue / OnWriteValue / the four HistoryRead paths / Browse / Call / Acknowledge/Confirm/Shelve / CreateMonitoredItems / TransferSubscriptions.
|
- Alarm Acknowledge / Confirm / Shelve gating.
|
||||||
- Stamp MonitoredItems with `(AuthGenerationId, MembershipVersion)` per decision #153 so revoked grants surface `BadUserAccessDenied` within one publish cycle.
|
- Call (method invocation) gating.
|
||||||
- 3-user integration matrix covering each operation × allow/deny.
|
- Finer-grained scope resolution — current `NodeScopeResolver` returns a flat cluster-level scope. Joining against the live Configuration DB to populate UnsArea / UnsLine / Equipment path is tracked as Stream C.12.
|
||||||
|
- 3-user integration matrix covering every operation × allow/deny.
|
||||||
|
|
||||||
**Strict mode default**: start lax (`Authorization:StrictMode = false`) during rollout so deployments without populated ACLs keep working. Flip to strict once ACL seeding lands for production clusters.
|
These are additional hardening — the three highest-value surfaces (Read / Write / HistoryRead) are now gated, which covers the base-security gap for v2 GA.
|
||||||
|
|
||||||
### Config fallback — Phase 6.1 Stream D wiring (task #136)
|
### Config fallback — Phase 6.1 Stream D wiring (task #136)
|
||||||
|
|
||||||
@@ -96,6 +97,7 @@ v2 GA requires all of the following:
|
|||||||
|
|
||||||
## Change log
|
## Change log
|
||||||
|
|
||||||
|
- **2026-04-19** — Release blocker #1 **closed** (PR #94). `AuthorizationGate` wired into `DriverNodeManager` Read / Write / HistoryRead dispatch. Remaining Stream C surfaces (Browse / Subscribe / Alarm / Call + finer-grained scope resolution) downgraded to hardening follow-ups — no longer release-blocking.
|
||||||
- **2026-04-19** — Phase 6.4 data layer merged (PRs #91–92). Phase 6 core complete. Capstone doc created.
|
- **2026-04-19** — Phase 6.4 data layer merged (PRs #91–92). Phase 6 core complete. Capstone doc created.
|
||||||
- **2026-04-19** — Phase 6.3 core merged (PRs #89–90). `ServiceLevelCalculator` + `RecoveryStateManager` + `ApplyLeaseRegistry` land as pure logic; coordinator / UA-node wiring / Admin UI / interop deferred.
|
- **2026-04-19** — Phase 6.3 core merged (PRs #89–90). `ServiceLevelCalculator` + `RecoveryStateManager` + `ApplyLeaseRegistry` land as pure logic; coordinator / UA-node wiring / Admin UI / interop deferred.
|
||||||
- **2026-04-19** — Phase 6.2 core merged (PRs #84–88). `AuthorizationGate` + `TriePermissionEvaluator` + `LdapGroupRoleMapping` land; dispatch wiring + Admin UI deferred.
|
- **2026-04-19** — Phase 6.2 core merged (PRs #84–88). `AuthorizationGate` + `TriePermissionEvaluator` + `LdapGroupRoleMapping` land; dispatch wiring + Admin UI deferred.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Opc.Ua;
|
using Opc.Ua;
|
||||||
using Opc.Ua.Server;
|
using Opc.Ua.Server;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
|
||||||
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||||
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
|
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
|
||||||
@@ -59,14 +60,24 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
// returns a child builder per Folder call and the caller threads nesting through those references.
|
// returns a child builder per Folder call and the caller threads nesting through those references.
|
||||||
private FolderState _currentFolder = null!;
|
private FolderState _currentFolder = null!;
|
||||||
|
|
||||||
|
// Phase 6.2 Stream C follow-up — optional gate + scope resolver. When both are null
|
||||||
|
// the old pre-Phase-6.2 dispatch path runs unchanged (backwards compat for every
|
||||||
|
// integration test that constructs DriverNodeManager without the gate). When wired,
|
||||||
|
// OnReadValue / OnWriteValue / HistoryRead all consult the gate before the invoker call.
|
||||||
|
private readonly AuthorizationGate? _authzGate;
|
||||||
|
private readonly NodeScopeResolver? _scopeResolver;
|
||||||
|
|
||||||
public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration,
|
public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration,
|
||||||
IDriver driver, CapabilityInvoker invoker, ILogger<DriverNodeManager> logger)
|
IDriver driver, CapabilityInvoker invoker, ILogger<DriverNodeManager> logger,
|
||||||
|
AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null)
|
||||||
: base(server, configuration, namespaceUris: $"urn:OtOpcUa:{driver.DriverInstanceId}")
|
: base(server, configuration, namespaceUris: $"urn:OtOpcUa:{driver.DriverInstanceId}")
|
||||||
{
|
{
|
||||||
_driver = driver;
|
_driver = driver;
|
||||||
_readable = driver as IReadable;
|
_readable = driver as IReadable;
|
||||||
_writable = driver as IWritable;
|
_writable = driver as IWritable;
|
||||||
_invoker = invoker;
|
_invoker = invoker;
|
||||||
|
_authzGate = authzGate;
|
||||||
|
_scopeResolver = scopeResolver;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +208,20 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var fullRef = node.NodeId.Identifier as string ?? "";
|
var fullRef = node.NodeId.Identifier as string ?? "";
|
||||||
|
|
||||||
|
// Phase 6.2 Stream C — authorization gate. Runs ahead of the invoker so a denied
|
||||||
|
// read never hits the driver. Returns true in lax mode when identity lacks LDAP
|
||||||
|
// groups; strict mode denies those cases. See AuthorizationGate remarks.
|
||||||
|
if (_authzGate is not null && _scopeResolver is not null)
|
||||||
|
{
|
||||||
|
var scope = _scopeResolver.Resolve(fullRef);
|
||||||
|
if (!_authzGate.IsAllowed(context.UserIdentity, OpcUaOperation.Read, scope))
|
||||||
|
{
|
||||||
|
statusCode = StatusCodes.BadUserAccessDenied;
|
||||||
|
return ServiceResult.Good;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var result = _invoker.ExecuteAsync(
|
var result = _invoker.ExecuteAsync(
|
||||||
DriverCapability.Read,
|
DriverCapability.Read,
|
||||||
_driver.DriverInstanceId,
|
_driver.DriverInstanceId,
|
||||||
@@ -390,6 +415,23 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
fullRef, classification, string.Join(",", roles));
|
fullRef, classification, string.Join(",", roles));
|
||||||
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 6.2 Stream C — additive gate check. The classification/role check above
|
||||||
|
// is the pre-Phase-6.2 baseline; the gate adds per-tag ACL enforcement on top. In
|
||||||
|
// lax mode (default during rollout) the gate falls through when the identity
|
||||||
|
// lacks LDAP groups, so existing integration tests keep passing.
|
||||||
|
if (_authzGate is not null && _scopeResolver is not null)
|
||||||
|
{
|
||||||
|
var scope = _scopeResolver.Resolve(fullRef!);
|
||||||
|
var writeOp = WriteAuthzPolicy.ToOpcUaOperation(classification);
|
||||||
|
if (!_authzGate.IsAllowed(context.UserIdentity, writeOp, scope))
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Write denied by ACL gate for {FullRef}: operation={Op} classification={Classification}",
|
||||||
|
fullRef, writeOp, classification);
|
||||||
|
return new ServiceResult(StatusCodes.BadUserAccessDenied);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -482,6 +524,16 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_authzGate is not null && _scopeResolver is not null)
|
||||||
|
{
|
||||||
|
var historyScope = _scopeResolver.Resolve(fullRef);
|
||||||
|
if (!_authzGate.IsAllowed(context.UserIdentity, OpcUaOperation.HistoryRead, historyScope))
|
||||||
|
{
|
||||||
|
WriteAccessDenied(results, errors, i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var driverResult = _invoker.ExecuteAsync(
|
var driverResult = _invoker.ExecuteAsync(
|
||||||
@@ -546,6 +598,16 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_authzGate is not null && _scopeResolver is not null)
|
||||||
|
{
|
||||||
|
var historyScope = _scopeResolver.Resolve(fullRef);
|
||||||
|
if (!_authzGate.IsAllowed(context.UserIdentity, OpcUaOperation.HistoryRead, historyScope))
|
||||||
|
{
|
||||||
|
WriteAccessDenied(results, errors, i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var driverResult = _invoker.ExecuteAsync(
|
var driverResult = _invoker.ExecuteAsync(
|
||||||
@@ -603,6 +665,16 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_authzGate is not null && _scopeResolver is not null)
|
||||||
|
{
|
||||||
|
var historyScope = _scopeResolver.Resolve(fullRef);
|
||||||
|
if (!_authzGate.IsAllowed(context.UserIdentity, OpcUaOperation.HistoryRead, historyScope))
|
||||||
|
{
|
||||||
|
WriteAccessDenied(results, errors, i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var driverResult = _invoker.ExecuteAsync(
|
var driverResult = _invoker.ExecuteAsync(
|
||||||
@@ -660,6 +732,19 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
// "all sources in the driver's namespace" per the IHistoryProvider contract.
|
// "all sources in the driver's namespace" per the IHistoryProvider contract.
|
||||||
var fullRef = ResolveFullRef(handle);
|
var fullRef = ResolveFullRef(handle);
|
||||||
|
|
||||||
|
// fullRef is null for event-history queries that target a notifier (driver root).
|
||||||
|
// Those are cluster-wide reads + need a different scope shape; skip the gate here
|
||||||
|
// and let the driver-level authz handle them. Non-null path gets per-node gating.
|
||||||
|
if (fullRef is not null && _authzGate is not null && _scopeResolver is not null)
|
||||||
|
{
|
||||||
|
var historyScope = _scopeResolver.Resolve(fullRef);
|
||||||
|
if (!_authzGate.IsAllowed(context.UserIdentity, OpcUaOperation.HistoryRead, historyScope))
|
||||||
|
{
|
||||||
|
WriteAccessDenied(results, errors, i);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var driverResult = _invoker.ExecuteAsync(
|
var driverResult = _invoker.ExecuteAsync(
|
||||||
@@ -721,6 +806,12 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
|||||||
errors[i] = StatusCodes.BadInternalError;
|
errors[i] = StatusCodes.BadInternalError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static void WriteAccessDenied(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors, int i)
|
||||||
|
{
|
||||||
|
results[i] = new OpcHistoryReadResult { StatusCode = StatusCodes.BadUserAccessDenied };
|
||||||
|
errors[i] = StatusCodes.BadUserAccessDenied;
|
||||||
|
}
|
||||||
|
|
||||||
private static void WriteNodeIdUnknown(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors, int i)
|
private static void WriteNodeIdUnknown(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors, int i)
|
||||||
{
|
{
|
||||||
WriteNodeIdUnknown(results, errors, i);
|
WriteNodeIdUnknown(results, errors, i);
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
private readonly DriverHost _driverHost;
|
private readonly DriverHost _driverHost;
|
||||||
private readonly IUserAuthenticator _authenticator;
|
private readonly IUserAuthenticator _authenticator;
|
||||||
private readonly DriverResiliencePipelineBuilder _pipelineBuilder;
|
private readonly DriverResiliencePipelineBuilder _pipelineBuilder;
|
||||||
|
private readonly AuthorizationGate? _authzGate;
|
||||||
|
private readonly NodeScopeResolver? _scopeResolver;
|
||||||
private readonly ILoggerFactory _loggerFactory;
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
private readonly ILogger<OpcUaApplicationHost> _logger;
|
private readonly ILogger<OpcUaApplicationHost> _logger;
|
||||||
private ApplicationInstance? _application;
|
private ApplicationInstance? _application;
|
||||||
@@ -32,12 +34,16 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
|
|
||||||
public OpcUaApplicationHost(OpcUaServerOptions options, DriverHost driverHost,
|
public OpcUaApplicationHost(OpcUaServerOptions options, DriverHost driverHost,
|
||||||
IUserAuthenticator authenticator, ILoggerFactory loggerFactory, ILogger<OpcUaApplicationHost> logger,
|
IUserAuthenticator authenticator, ILoggerFactory loggerFactory, ILogger<OpcUaApplicationHost> logger,
|
||||||
DriverResiliencePipelineBuilder? pipelineBuilder = null)
|
DriverResiliencePipelineBuilder? pipelineBuilder = null,
|
||||||
|
AuthorizationGate? authzGate = null,
|
||||||
|
NodeScopeResolver? scopeResolver = null)
|
||||||
{
|
{
|
||||||
_options = options;
|
_options = options;
|
||||||
_driverHost = driverHost;
|
_driverHost = driverHost;
|
||||||
_authenticator = authenticator;
|
_authenticator = authenticator;
|
||||||
_pipelineBuilder = pipelineBuilder ?? new DriverResiliencePipelineBuilder();
|
_pipelineBuilder = pipelineBuilder ?? new DriverResiliencePipelineBuilder();
|
||||||
|
_authzGate = authzGate;
|
||||||
|
_scopeResolver = scopeResolver;
|
||||||
_loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
@@ -64,7 +70,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"OPC UA application certificate could not be validated or created in {_options.PkiStoreRoot}");
|
$"OPC UA application certificate could not be validated or created in {_options.PkiStoreRoot}");
|
||||||
|
|
||||||
_server = new OtOpcUaServer(_driverHost, _authenticator, _pipelineBuilder, _loggerFactory);
|
_server = new OtOpcUaServer(_driverHost, _authenticator, _pipelineBuilder, _loggerFactory,
|
||||||
|
authzGate: _authzGate, scopeResolver: _scopeResolver);
|
||||||
await _application.Start(_server).ConfigureAwait(false);
|
await _application.Start(_server).ConfigureAwait(false);
|
||||||
|
|
||||||
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
|
_logger.LogInformation("OPC UA server started — endpoint={Endpoint} driverCount={Count}",
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ public sealed class OtOpcUaServer : StandardServer
|
|||||||
private readonly DriverHost _driverHost;
|
private readonly DriverHost _driverHost;
|
||||||
private readonly IUserAuthenticator _authenticator;
|
private readonly IUserAuthenticator _authenticator;
|
||||||
private readonly DriverResiliencePipelineBuilder _pipelineBuilder;
|
private readonly DriverResiliencePipelineBuilder _pipelineBuilder;
|
||||||
|
private readonly AuthorizationGate? _authzGate;
|
||||||
|
private readonly NodeScopeResolver? _scopeResolver;
|
||||||
private readonly ILoggerFactory _loggerFactory;
|
private readonly ILoggerFactory _loggerFactory;
|
||||||
private readonly List<DriverNodeManager> _driverNodeManagers = new();
|
private readonly List<DriverNodeManager> _driverNodeManagers = new();
|
||||||
|
|
||||||
@@ -28,11 +30,15 @@ public sealed class OtOpcUaServer : StandardServer
|
|||||||
DriverHost driverHost,
|
DriverHost driverHost,
|
||||||
IUserAuthenticator authenticator,
|
IUserAuthenticator authenticator,
|
||||||
DriverResiliencePipelineBuilder pipelineBuilder,
|
DriverResiliencePipelineBuilder pipelineBuilder,
|
||||||
ILoggerFactory loggerFactory)
|
ILoggerFactory loggerFactory,
|
||||||
|
AuthorizationGate? authzGate = null,
|
||||||
|
NodeScopeResolver? scopeResolver = null)
|
||||||
{
|
{
|
||||||
_driverHost = driverHost;
|
_driverHost = driverHost;
|
||||||
_authenticator = authenticator;
|
_authenticator = authenticator;
|
||||||
_pipelineBuilder = pipelineBuilder;
|
_pipelineBuilder = pipelineBuilder;
|
||||||
|
_authzGate = authzGate;
|
||||||
|
_scopeResolver = scopeResolver;
|
||||||
_loggerFactory = loggerFactory;
|
_loggerFactory = loggerFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +64,8 @@ public sealed class OtOpcUaServer : StandardServer
|
|||||||
// DriverInstance row in a follow-up PR; for now every driver gets Tier A defaults.
|
// DriverInstance row in a follow-up PR; for now every driver gets Tier A defaults.
|
||||||
var options = new DriverResilienceOptions { Tier = DriverTier.A };
|
var options = new DriverResilienceOptions { Tier = DriverTier.A };
|
||||||
var invoker = new CapabilityInvoker(_pipelineBuilder, driver.DriverInstanceId, () => options, driver.DriverType);
|
var invoker = new CapabilityInvoker(_pipelineBuilder, driver.DriverInstanceId, () => options, driver.DriverType);
|
||||||
var manager = new DriverNodeManager(server, configuration, driver, invoker, logger);
|
var manager = new DriverNodeManager(server, configuration, driver, invoker, logger,
|
||||||
|
authzGate: _authzGate, scopeResolver: _scopeResolver);
|
||||||
_driverNodeManagers.Add(manager);
|
_driverNodeManagers.Add(manager);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
47
src/ZB.MOM.WW.OtOpcUa.Server/Security/NodeScopeResolver.cs
Normal file
47
src/ZB.MOM.WW.OtOpcUa.Server/Security/NodeScopeResolver.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a driver-side full reference (e.g. <c>"TestMachine_001/Oven/SetPoint"</c>) to the
|
||||||
|
/// <see cref="NodeScope"/> the Phase 6.2 evaluator walks. Today a simplified resolver that
|
||||||
|
/// returns a cluster-scoped + tag-only scope — the deeper UnsArea / UnsLine / Equipment
|
||||||
|
/// path lookup from the live Configuration DB is a Stream C.12 follow-up.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>The flat cluster-level scope is sufficient for v2 GA because Phase 6.2 ACL grants
|
||||||
|
/// at the Cluster scope cascade to every tag below (decision #129 — additive grants). The
|
||||||
|
/// finer hierarchy only matters when operators want per-area or per-equipment grants;
|
||||||
|
/// those still work for Cluster-level grants, and landing the finer resolution in a
|
||||||
|
/// follow-up doesn't regress the base security model.</para>
|
||||||
|
///
|
||||||
|
/// <para>Thread-safety: the resolver is stateless once constructed. Callers may cache a
|
||||||
|
/// single instance per DriverNodeManager without locks.</para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class NodeScopeResolver
|
||||||
|
{
|
||||||
|
private readonly string _clusterId;
|
||||||
|
|
||||||
|
public NodeScopeResolver(string clusterId)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
|
||||||
|
_clusterId = clusterId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve a node scope for the given driver-side <paramref name="fullReference"/>.
|
||||||
|
/// Phase 1 shape: returns <c>ClusterId</c> + <c>TagId = fullReference</c> only;
|
||||||
|
/// NamespaceId / UnsArea / UnsLine / Equipment stay null. A future resolver will
|
||||||
|
/// join against the Configuration DB to populate the full path.
|
||||||
|
/// </summary>
|
||||||
|
public NodeScope Resolve(string fullReference)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(fullReference);
|
||||||
|
return new NodeScope
|
||||||
|
{
|
||||||
|
ClusterId = _clusterId,
|
||||||
|
TagId = fullReference,
|
||||||
|
Kind = NodeHierarchyKind.Equipment,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,4 +67,22 @@ public static class WriteAuthzPolicy
|
|||||||
SecurityClassification.ViewOnly => null, // IsAllowed short-circuits
|
SecurityClassification.ViewOnly => null, // IsAllowed short-circuits
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a driver-reported <see cref="SecurityClassification"/> to the
|
||||||
|
/// <see cref="Core.Abstractions.OpcUaOperation"/> the Phase 6.2 evaluator consults
|
||||||
|
/// for the matching <see cref="Configuration.Enums.NodePermissions"/> bit.
|
||||||
|
/// FreeAccess + ViewOnly fall back to WriteOperate — the evaluator never sees them
|
||||||
|
/// because <see cref="IsAllowed"/> short-circuits first.
|
||||||
|
/// </summary>
|
||||||
|
public static Core.Abstractions.OpcUaOperation ToOpcUaOperation(SecurityClassification classification) =>
|
||||||
|
classification switch
|
||||||
|
{
|
||||||
|
SecurityClassification.Operate => Core.Abstractions.OpcUaOperation.WriteOperate,
|
||||||
|
SecurityClassification.SecuredWrite => Core.Abstractions.OpcUaOperation.WriteOperate,
|
||||||
|
SecurityClassification.Tune => Core.Abstractions.OpcUaOperation.WriteTune,
|
||||||
|
SecurityClassification.VerifiedWrite => Core.Abstractions.OpcUaOperation.WriteConfigure,
|
||||||
|
SecurityClassification.Configure => Core.Abstractions.OpcUaOperation.WriteConfigure,
|
||||||
|
_ => Core.Abstractions.OpcUaOperation.WriteOperate,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Server.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Server.Tests;
|
||||||
|
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class NodeScopeResolverTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_PopulatesClusterAndTag()
|
||||||
|
{
|
||||||
|
var resolver = new NodeScopeResolver("c-warsaw");
|
||||||
|
|
||||||
|
var scope = resolver.Resolve("TestMachine_001/Oven/SetPoint");
|
||||||
|
|
||||||
|
scope.ClusterId.ShouldBe("c-warsaw");
|
||||||
|
scope.TagId.ShouldBe("TestMachine_001/Oven/SetPoint");
|
||||||
|
scope.Kind.ShouldBe(NodeHierarchyKind.Equipment);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_Leaves_UnsPath_Null_For_Phase1()
|
||||||
|
{
|
||||||
|
var resolver = new NodeScopeResolver("c-1");
|
||||||
|
|
||||||
|
var scope = resolver.Resolve("tag-1");
|
||||||
|
|
||||||
|
// Phase 1 flat scope — finer resolution tracked as Stream C.12 follow-up.
|
||||||
|
scope.NamespaceId.ShouldBeNull();
|
||||||
|
scope.UnsAreaId.ShouldBeNull();
|
||||||
|
scope.UnsLineId.ShouldBeNull();
|
||||||
|
scope.EquipmentId.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolve_Throws_OnEmptyFullReference()
|
||||||
|
{
|
||||||
|
var resolver = new NodeScopeResolver("c-1");
|
||||||
|
|
||||||
|
Should.Throw<ArgumentException>(() => resolver.Resolve(""));
|
||||||
|
Should.Throw<ArgumentException>(() => resolver.Resolve(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Ctor_Throws_OnEmptyClusterId()
|
||||||
|
{
|
||||||
|
Should.Throw<ArgumentException>(() => new NodeScopeResolver(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Resolver_IsStateless_AcrossCalls()
|
||||||
|
{
|
||||||
|
var resolver = new NodeScopeResolver("c");
|
||||||
|
var s1 = resolver.Resolve("tag-a");
|
||||||
|
var s2 = resolver.Resolve("tag-b");
|
||||||
|
|
||||||
|
s1.TagId.ShouldBe("tag-a");
|
||||||
|
s2.TagId.ShouldBe("tag-b");
|
||||||
|
s1.ClusterId.ShouldBe("c");
|
||||||
|
s2.ClusterId.ShouldBe("c");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user