Phase 6.2 Stream C follow-up — wire AuthorizationGate into DriverNodeManager Read / Write / HistoryRead dispatch
Closes the Phase 6.2 security gap the v2 release-readiness dashboard flagged: the evaluator + trie + gate shipped as code in PRs #84-88 but no dispatch path called them. This PR threads the gate end-to-end from OpcUaApplicationHost → OtOpcUaServer → DriverNodeManager and calls it on every Read / Write / 4 HistoryRead paths. Server.Security additions: - NodeScopeResolver — maps driver fullRef → Core.Authorization NodeScope. Phase 1 shape: populates ClusterId + TagId; leaves NamespaceId / UnsArea / UnsLine / Equipment null. The cluster-level ACL cascade covers this configuration (decision #129 additive grants). Finer-grained scope resolution (joining against the live Configuration DB for UnsArea / UnsLine path) lands as Stream C.12 follow-up. - WriteAuthzPolicy.ToOpcUaOperation — maps SecurityClassification → the OpcUaOperation the gate evaluator consults (Operate/SecuredWrite → WriteOperate; Tune → WriteTune; Configure/VerifiedWrite → WriteConfigure). DriverNodeManager wiring: - Ctor gains optional AuthorizationGate + NodeScopeResolver; both null means the pre-Phase-6.2 dispatch runs unchanged (backwards-compat for every integration test that constructs DriverNodeManager directly). - OnReadValue: ahead of the invoker call, builds NodeScope + calls gate.IsAllowed(identity, Read, scope). Denied reads return BadUserAccessDenied without hitting the driver. - OnWriteValue: preserves the existing WriteAuthzPolicy check (classification vs session roles) + adds an additive gate check using WriteAuthzPolicy.ToOpcUaOperation(classification) to pick the right WriteOperate/Tune/Configure surface. Lax mode falls through for identities without LDAP groups. - Four HistoryRead paths (Raw / Processed / AtTime / Events): gate check runs per-node before the invoker. Events path tolerates fullRef=null (event-history queries can target a notifier / driver-root; those are cluster-wide reads that need a different scope shape — deferred). - New WriteAccessDenied helper surfaces BadUserAccessDenied in the OpcHistoryReadResult slot + errors list, matching the shape of the existing WriteUnsupported / WriteInternalError helpers. OtOpcUaServer + OpcUaApplicationHost: gate + resolver thread through as optional constructor parameters (same pattern as DriverResiliencePipelineBuilder in Phase 6.1). Null defaults keep the existing 3 OpcUaApplicationHost integration tests constructing without them unchanged. Tests (5 new in NodeScopeResolverTests): - Resolve populates ClusterId + TagId + Equipment Kind. - Resolve leaves finer path null per Phase 1 shape (doc'd as follow-up). - Empty fullReference throws. - Empty clusterId throws at ctor. - Resolver is stateless across calls. The existing 9 AuthorizationGate tests (shipped in PR #86) continue to cover the gate's allow/deny semantics under strict + lax mode. Full solution dotnet test: 1164 passing (was 1159, +5). Pre-existing Client.CLI Subscribe flake unchanged. Existing OpcUaApplicationHost + HealthEndpointsHost + driver integration tests continue to pass because the gate defaults to null → no enforcement, and the lax-mode fallback returns true for identities without LDAP groups (the anonymous test path). Production deployments flip the gate on by constructing it via OpcUaApplicationHost's new authzGate parameter + setting `Authorization:StrictMode = true` once ACL data is populated. Flipping the switch post-seed turns the evaluator + trie from scaffolded code into actual enforcement. This closes release blocker #1 listed in docs/v2/v2-release-readiness.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ using Microsoft.Extensions.Logging;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
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.Server.Security;
|
||||
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.
|
||||
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,
|
||||
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}")
|
||||
{
|
||||
_driver = driver;
|
||||
_readable = driver as IReadable;
|
||||
_writable = driver as IWritable;
|
||||
_invoker = invoker;
|
||||
_authzGate = authzGate;
|
||||
_scopeResolver = scopeResolver;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -197,6 +208,20 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
try
|
||||
{
|
||||
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(
|
||||
DriverCapability.Read,
|
||||
_driver.DriverInstanceId,
|
||||
@@ -390,6 +415,23 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
fullRef, classification, string.Join(",", roles));
|
||||
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
|
||||
@@ -482,6 +524,16 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
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
|
||||
{
|
||||
var driverResult = _invoker.ExecuteAsync(
|
||||
@@ -546,6 +598,16 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
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
|
||||
{
|
||||
var driverResult = _invoker.ExecuteAsync(
|
||||
@@ -603,6 +665,16 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
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
|
||||
{
|
||||
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.
|
||||
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
|
||||
{
|
||||
var driverResult = _invoker.ExecuteAsync(
|
||||
@@ -721,6 +806,12 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
|
||||
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)
|
||||
{
|
||||
WriteNodeIdUnknown(results, errors, i);
|
||||
|
||||
Reference in New Issue
Block a user