Lands the integration seam between the Server project's OPC UA stack and the Core.Authorization evaluator. Actual DriverNodeManager dispatch-path wiring (Read/Write/HistoryRead/Browse/Call/Subscribe/Alarm surfaces) lands in the follow-up PR on this branch — covered by Task #143 below. Server.Security additions: - ILdapGroupsBearer — marker interface a custom IUserIdentity implements to expose its resolved LDAP group DNs. Parallel to the existing IRoleBearer (admin roles) — control/data-plane separation per decision #150. - AuthorizationGate — stateless bridge between Opc.Ua.IUserIdentity and IPermissionEvaluator. IsAllowed(identity, operation, scope) materializes a UserAuthorizationState from the identity's LDAP groups, delegates to the evaluator, and returns a single bool the dispatch paths use to decide whether to surface BadUserAccessDenied. - StrictMode knob controls fail-open-during-transition vs fail-closed: - strict=false (default during rollout) — null identity, identity without ILdapGroupsBearer, or NotGranted outcome all return true so older deployments without ACL data keep working. - strict=true (production target) — any of the above returns false. The appsetting `Authorization:StrictMode = true` flips deployments over once their ACL data is populated. Tests (9 new in Server.Tests/AuthorizationGateTests): - Null identity — strict denies, lax allows. - Identity without LDAP groups — strict denies, lax allows. - LDAP group with matching grant allows. - LDAP group without grant — strict denies. - Wrong operation denied (Read-only grant, WriteOperate requested). - BuildSessionState returns materialized state with LDAP groups + null when identity doesn't carry them. Full solution dotnet test: 1087 passing (Phase 6.1 = 1042, Phase 6.2 A = +9, B = +27, C foundation = +9 = 1087). Pre-existing Client.CLI Subscribe flake unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
87 lines
3.8 KiB
C#
87 lines
3.8 KiB
C#
using Opc.Ua;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
|
|
|
|
/// <summary>
|
|
/// Bridges the OPC UA stack's <see cref="ISystemContext.UserIdentity"/> to the
|
|
/// <see cref="IPermissionEvaluator"/> evaluator. Resolves the session's
|
|
/// <see cref="UserAuthorizationState"/> 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 <c>BadUserAccessDenied</c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>This class is deliberately the single integration seam between the Server
|
|
/// project and the <c>Core.Authorization</c> evaluator. DriverNodeManager holds one
|
|
/// reference and calls <see cref="IsAllowed"/> on every Read / Write / HistoryRead /
|
|
/// Browse / Call / CreateMonitoredItems / etc. The evaluator itself stays pure — it
|
|
/// doesn't know about the OPC UA stack types.</para>
|
|
///
|
|
/// <para>Fail-open-during-transition: when the evaluator is configured with
|
|
/// <c>StrictMode = false</c>, missing cluster tries OR sessions without resolved
|
|
/// LDAP groups get <c>true</c> so existing deployments keep working while ACLs are
|
|
/// populated. Flip to strict via <c>Authorization:StrictMode = true</c> in production.</para>
|
|
/// </remarks>
|
|
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;
|
|
}
|
|
|
|
/// <summary>True when strict authorization is enabled — no-grant = denied.</summary>
|
|
public bool StrictMode => _strictMode;
|
|
|
|
/// <summary>
|
|
/// Authorize an OPC UA operation against the session identity + scope. Returns true to
|
|
/// allow the dispatch to continue; false to surface <c>BadUserAccessDenied</c>.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Materialize a <see cref="UserAuthorizationState"/> from the session identity.
|
|
/// Returns null when the identity doesn't carry LDAP group metadata.
|
|
/// </summary>
|
|
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,
|
|
};
|
|
}
|
|
}
|