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>
89 lines
4.6 KiB
C#
89 lines
4.6 KiB
C#
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
|
|
|
/// <summary>
|
|
/// Server-layer write-authorization policy. ACL enforcement lives here — drivers report
|
|
/// <see cref="SecurityClassification"/> as discovery metadata only; the server decides
|
|
/// whether a given session is allowed to write a given attribute by checking the session's
|
|
/// roles (resolved at login via <see cref="LdapUserAuthenticator"/>) against the required
|
|
/// role for the attribute's classification.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Matches the table in <c>docs/Configuration.md</c>:
|
|
/// <list type="bullet">
|
|
/// <item><c>FreeAccess</c>: no role required — anonymous sessions can write (matches v1 default).</item>
|
|
/// <item><c>Operate</c> / <c>SecuredWrite</c>: <c>WriteOperate</c> role required.</item>
|
|
/// <item><c>Tune</c>: <c>WriteTune</c> role required.</item>
|
|
/// <item><c>VerifiedWrite</c> / <c>Configure</c>: <c>WriteConfigure</c> role required.</item>
|
|
/// <item><c>ViewOnly</c>: no role grants write access.</item>
|
|
/// </list>
|
|
/// <c>AlarmAck</c> is checked at the alarm-acknowledge path, not here.
|
|
/// </remarks>
|
|
public static class WriteAuthzPolicy
|
|
{
|
|
public const string RoleWriteOperate = "WriteOperate";
|
|
public const string RoleWriteTune = "WriteTune";
|
|
public const string RoleWriteConfigure = "WriteConfigure";
|
|
|
|
/// <summary>
|
|
/// Decide whether a session with <paramref name="userRoles"/> is allowed to write to an
|
|
/// attribute with the given <paramref name="classification"/>. Returns true for
|
|
/// <c>FreeAccess</c> regardless of roles (including empty / anonymous sessions) and
|
|
/// false for <c>ViewOnly</c> regardless of roles. Every other classification requires
|
|
/// the session to carry the mapped role — case-insensitive match.
|
|
/// </summary>
|
|
public static bool IsAllowed(SecurityClassification classification, IReadOnlyCollection<string> userRoles)
|
|
{
|
|
if (classification == SecurityClassification.FreeAccess) return true;
|
|
if (classification == SecurityClassification.ViewOnly) return false;
|
|
|
|
var required = RequiredRole(classification);
|
|
if (required is null) return false;
|
|
|
|
foreach (var r in userRoles)
|
|
{
|
|
if (string.Equals(r, required, StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Required role for a classification, or null when no role grants access
|
|
/// (<see cref="SecurityClassification.ViewOnly"/>) or no role is needed
|
|
/// (<see cref="SecurityClassification.FreeAccess"/> — also returns null; callers use
|
|
/// <see cref="IsAllowed"/> which handles the special-cases rather than branching on
|
|
/// null themselves).
|
|
/// </summary>
|
|
public static string? RequiredRole(SecurityClassification classification) => classification switch
|
|
{
|
|
SecurityClassification.FreeAccess => null, // IsAllowed short-circuits
|
|
SecurityClassification.Operate => RoleWriteOperate,
|
|
SecurityClassification.SecuredWrite => RoleWriteOperate,
|
|
SecurityClassification.Tune => RoleWriteTune,
|
|
SecurityClassification.VerifiedWrite => RoleWriteConfigure,
|
|
SecurityClassification.Configure => RoleWriteConfigure,
|
|
SecurityClassification.ViewOnly => null, // IsAllowed short-circuits
|
|
_ => 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,
|
|
};
|
|
}
|