diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationGate.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationGate.cs new file mode 100644 index 0000000..adaed4a --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Security/AuthorizationGate.cs @@ -0,0 +1,86 @@ +using Opc.Ua; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; + +namespace ZB.MOM.WW.OtOpcUa.Server.Security; + +/// +/// Bridges the OPC UA stack's to the +/// evaluator. Resolves the session's +/// from whatever the identity claims + the stack's +/// session handle, then delegates to the evaluator and returns a single bool the +/// dispatch paths can use to short-circuit with BadUserAccessDenied. +/// +/// +/// This class is deliberately the single integration seam between the Server +/// project and the Core.Authorization evaluator. DriverNodeManager holds one +/// reference and calls on every Read / Write / HistoryRead / +/// Browse / Call / CreateMonitoredItems / etc. The evaluator itself stays pure — it +/// doesn't know about the OPC UA stack types. +/// +/// Fail-open-during-transition: when the evaluator is configured with +/// StrictMode = false, missing cluster tries OR sessions without resolved +/// LDAP groups get true so existing deployments keep working while ACLs are +/// populated. Flip to strict via Authorization:StrictMode = true in production. +/// +public sealed class AuthorizationGate +{ + private readonly IPermissionEvaluator _evaluator; + private readonly bool _strictMode; + private readonly TimeProvider _timeProvider; + + public AuthorizationGate(IPermissionEvaluator evaluator, bool strictMode = false, TimeProvider? timeProvider = null) + { + _evaluator = evaluator ?? throw new ArgumentNullException(nameof(evaluator)); + _strictMode = strictMode; + _timeProvider = timeProvider ?? TimeProvider.System; + } + + /// True when strict authorization is enabled — no-grant = denied. + public bool StrictMode => _strictMode; + + /// + /// Authorize an OPC UA operation against the session identity + scope. Returns true to + /// allow the dispatch to continue; false to surface BadUserAccessDenied. + /// + public bool IsAllowed(IUserIdentity? identity, OpcUaOperation operation, NodeScope scope) + { + // Anonymous / unknown identity — strict mode denies, lax mode allows so the fallback + // auth layers (WriteAuthzPolicy) still see the call. + if (identity is null) return !_strictMode; + + var session = BuildSessionState(identity, scope.ClusterId); + if (session is null) + { + // Identity doesn't carry LDAP groups. In lax mode let the dispatch proceed so + // older deployments keep working; strict mode denies. + return !_strictMode; + } + + var decision = _evaluator.Authorize(session, operation, scope); + if (decision.IsAllowed) return true; + + return !_strictMode; + } + + /// + /// Materialize a from the session identity. + /// Returns null when the identity doesn't carry LDAP group metadata. + /// + public UserAuthorizationState? BuildSessionState(IUserIdentity identity, string clusterId) + { + if (identity is not ILdapGroupsBearer bearer || bearer.LdapGroups.Count == 0) + return null; + + var sessionId = identity.DisplayName ?? Guid.NewGuid().ToString("N"); + return new UserAuthorizationState + { + SessionId = sessionId, + ClusterId = clusterId, + LdapGroups = bearer.LdapGroups, + MembershipResolvedUtc = _timeProvider.GetUtcNow().UtcDateTime, + AuthGenerationId = 0, + MembershipVersion = 0, + }; + } +} diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/Security/ILdapGroupsBearer.cs b/src/ZB.MOM.WW.OtOpcUa.Server/Security/ILdapGroupsBearer.cs new file mode 100644 index 0000000..838e8e1 --- /dev/null +++ b/src/ZB.MOM.WW.OtOpcUa.Server/Security/ILdapGroupsBearer.cs @@ -0,0 +1,20 @@ +namespace ZB.MOM.WW.OtOpcUa.Server.Security; + +/// +/// Minimal interface an exposes so the Phase 6.2 +/// authorization evaluator can read the session's resolved LDAP group DNs without a +/// hard dependency on any specific identity subtype. Implemented by OtOpcUaServer's +/// role-based identity; tests stub it to drive the evaluator under different group +/// memberships. +/// +/// +/// Control/data-plane separation (decision #150): Admin UI role routing consumes +/// via LdapGroupRoleMapping; the OPC UA data-path +/// evaluator consumes directly against NodeAcl. The two +/// are sourced from the same directory query at sign-in but never cross. +/// +public interface ILdapGroupsBearer +{ + /// Fully-qualified LDAP group DNs the user is a member of. + IReadOnlyList LdapGroups { get; } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/AuthorizationGateTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/AuthorizationGateTests.cs new file mode 100644 index 0000000..c0605d2 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/AuthorizationGateTests.cs @@ -0,0 +1,136 @@ +using Opc.Ua; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +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 AuthorizationGateTests +{ + private static NodeScope Scope(string cluster = "c1", string? tag = "tag1") => new() + { + ClusterId = cluster, + NamespaceId = "ns", + UnsAreaId = "area", + UnsLineId = "line", + EquipmentId = "eq", + TagId = tag, + Kind = NodeHierarchyKind.Equipment, + }; + + private static NodeAcl Row(string group, NodePermissions flags) => new() + { + NodeAclRowId = Guid.NewGuid(), + NodeAclId = Guid.NewGuid().ToString(), + GenerationId = 1, + ClusterId = "c1", + LdapGroup = group, + ScopeKind = NodeAclScopeKind.Cluster, + ScopeId = null, + PermissionFlags = flags, + }; + + private static AuthorizationGate MakeGate(bool strict, NodeAcl[] rows) + { + var cache = new PermissionTrieCache(); + cache.Install(PermissionTrieBuilder.Build("c1", 1, rows)); + var evaluator = new TriePermissionEvaluator(cache); + return new AuthorizationGate(evaluator, strictMode: strict); + } + + private sealed class FakeIdentity : UserIdentity, ILdapGroupsBearer + { + public FakeIdentity(string name, IReadOnlyList groups) + { + DisplayName = name; + LdapGroups = groups; + } + public new string DisplayName { get; } + public IReadOnlyList LdapGroups { get; } + } + + [Fact] + public void NullIdentity_StrictMode_Denies() + { + var gate = MakeGate(strict: true, rows: []); + gate.IsAllowed(null, OpcUaOperation.Read, Scope()).ShouldBeFalse(); + } + + [Fact] + public void NullIdentity_LaxMode_Allows() + { + var gate = MakeGate(strict: false, rows: []); + gate.IsAllowed(null, OpcUaOperation.Read, Scope()).ShouldBeTrue(); + } + + [Fact] + public void IdentityWithoutLdapGroups_StrictMode_Denies() + { + var gate = MakeGate(strict: true, rows: []); + var identity = new UserIdentity(); // anonymous, no LDAP groups + + gate.IsAllowed(identity, OpcUaOperation.Read, Scope()).ShouldBeFalse(); + } + + [Fact] + public void IdentityWithoutLdapGroups_LaxMode_Allows() + { + var gate = MakeGate(strict: false, rows: []); + var identity = new UserIdentity(); + + gate.IsAllowed(identity, OpcUaOperation.Read, Scope()).ShouldBeTrue(); + } + + [Fact] + public void LdapGroupWithGrant_Allows() + { + var gate = MakeGate(strict: true, rows: [Row("cn=ops", NodePermissions.Read)]); + var identity = new FakeIdentity("ops-user", ["cn=ops"]); + + gate.IsAllowed(identity, OpcUaOperation.Read, Scope()).ShouldBeTrue(); + } + + [Fact] + public void LdapGroupWithoutGrant_StrictMode_Denies() + { + var gate = MakeGate(strict: true, rows: [Row("cn=ops", NodePermissions.Read)]); + var identity = new FakeIdentity("other-user", ["cn=other"]); + + gate.IsAllowed(identity, OpcUaOperation.Read, Scope()).ShouldBeFalse(); + } + + [Fact] + public void WrongOperation_Denied() + { + var gate = MakeGate(strict: true, rows: [Row("cn=ops", NodePermissions.Read)]); + var identity = new FakeIdentity("ops-user", ["cn=ops"]); + + gate.IsAllowed(identity, OpcUaOperation.WriteOperate, Scope()).ShouldBeFalse(); + } + + [Fact] + public void BuildSessionState_IncludesLdapGroups() + { + var gate = MakeGate(strict: true, rows: []); + var identity = new FakeIdentity("u", ["cn=a", "cn=b"]); + + var state = gate.BuildSessionState(identity, "c1"); + + state.ShouldNotBeNull(); + state!.LdapGroups.Count.ShouldBe(2); + state.ClusterId.ShouldBe("c1"); + } + + [Fact] + public void BuildSessionState_ReturnsNull_ForIdentityWithoutLdapGroups() + { + var gate = MakeGate(strict: true, rows: []); + + gate.BuildSessionState(new UserIdentity(), "c1").ShouldBeNull(); + } +}