Compare commits

...

4 Commits

Author SHA1 Message Date
Joseph Doherty
3b8280f08a Phase 6.2 Stream D (data layer) — ValidatedNodeAclAuthoringService with write-time invariants
Ships the non-UI piece of Stream D: a draft-aware write surface over NodeAcl
that enforces the Phase 6.2 plan's scope-uniqueness + grant-shape invariants.
Blazor UI pieces (RoleGrantsTab + AclsTab refresh + SignalR invalidation +
visual-compliance reviewer signoff) are deferred to the Phase 6.1-style
follow-up task.

Admin.Services:
- ValidatedNodeAclAuthoringService — alongside existing NodeAclService (raw
  CRUD, kept for read + revoke paths). GrantAsync enforces:
    * Permissions != None (decision #129 — additive only, no empty grants).
    * Cluster scope has null ScopeId.
    * Sub-cluster scope requires a populated ScopeId.
    * No duplicate (GenerationId, ClusterId, LdapGroup, ScopeKind, ScopeId)
      tuple — operator updates the row instead of inserting a duplicate.
  UpdatePermissionsAsync also rejects None (operator revokes via NodeAclService).
  Violations throw InvalidNodeAclGrantException.

Tests (10 new in Admin.Tests/ValidatedNodeAclAuthoringServiceTests):
- Grant rejects None permissions.
- Grant rejects Cluster-scope with ScopeId / sub-cluster without ScopeId.
- Grant succeeds on well-formed row.
- Grant rejects duplicate (group, scope) in same draft.
- Grant allows same group at different scope.
- Grant allows same (group, scope) in different draft.
- UpdatePermissions rejects None.
- UpdatePermissions round-trips new flags + notes.
- UpdatePermissions on unknown rowid throws.

Microsoft.EntityFrameworkCore.InMemory 10.0.0 added to Admin.Tests csproj.

Full solution dotnet test: 1097 passing (was 1087, +10). Phase 6.2 total is
now 1087+10 = 1097; baseline 906 → +191 net across Phase 6.1 (all streams) +
Phase 6.2 (Streams A, B, C foundation, D data layer).

Stream D follow-up task tracks: RoleGrantsTab CRUD over LdapGroupRoleMapping,
AclsTab write-through + Probe-this-permission diagnostic, draft-diff ACL
section, SignalR PermissionTrieCache invalidation push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 09:39:06 -04:00
70f3ec0092 Merge pull request (#86) - Phase 6.2 Stream C foundation 2026-04-19 09:35:48 -04:00
Joseph Doherty
8efb99b6be Phase 6.2 Stream C (foundation) — AuthorizationGate + ILdapGroupsBearer
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>
2026-04-19 09:33:51 -04:00
f74e141e64 Merge pull request (#85) - Phase 6.2 Stream B 2026-04-19 09:29:51 -04:00
6 changed files with 506 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Admin.Services;
/// <summary>
/// Draft-aware write surface over <see cref="NodeAcl"/>. Replaces direct
/// <see cref="NodeAclService"/> CRUD for Admin UI grant authoring; the raw service stays
/// as the read / delete surface. Enforces the invariants listed in Phase 6.2 Stream D.2:
/// scope-uniqueness per (LdapGroup, ScopeKind, ScopeId, GenerationId), grant shape
/// consistency, and no empty permission masks.
/// </summary>
/// <remarks>
/// <para>Per decision #129 grants are additive — <see cref="NodePermissions.None"/> is
/// rejected at write time. Explicit Deny is v2.1 and is not representable in the current
/// <c>NodeAcl</c> row; attempts to express it (e.g. empty permission set) surface as
/// <see cref="InvalidNodeAclGrantException"/>.</para>
///
/// <para>Draft scope: writes always target an unpublished (Draft-state) generation id.
/// Once a generation publishes, its rows are frozen.</para>
/// </remarks>
public sealed class ValidatedNodeAclAuthoringService(OtOpcUaConfigDbContext db)
{
/// <summary>Add a new grant row to the given draft generation.</summary>
public async Task<NodeAcl> GrantAsync(
long draftGenerationId,
string clusterId,
string ldapGroup,
NodeAclScopeKind scopeKind,
string? scopeId,
NodePermissions permissions,
string? notes,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(clusterId);
ArgumentException.ThrowIfNullOrWhiteSpace(ldapGroup);
ValidateGrantShape(scopeKind, scopeId, permissions);
await EnsureNoDuplicate(draftGenerationId, clusterId, ldapGroup, scopeKind, scopeId, cancellationToken).ConfigureAwait(false);
var row = new NodeAcl
{
GenerationId = draftGenerationId,
NodeAclId = $"acl-{Guid.NewGuid():N}"[..20],
ClusterId = clusterId,
LdapGroup = ldapGroup,
ScopeKind = scopeKind,
ScopeId = scopeId,
PermissionFlags = permissions,
Notes = notes,
};
db.NodeAcls.Add(row);
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return row;
}
/// <summary>
/// Replace an existing grant's permission set in place. Validates the new shape;
/// rejects attempts to blank-out to None (that's a Revoke via <see cref="NodeAclService"/>).
/// </summary>
public async Task<NodeAcl> UpdatePermissionsAsync(
Guid nodeAclRowId,
NodePermissions newPermissions,
string? notes,
CancellationToken cancellationToken)
{
if (newPermissions == NodePermissions.None)
throw new InvalidNodeAclGrantException(
"Permission set cannot be None — revoke the row instead of writing an empty grant.");
var row = await db.NodeAcls.FirstOrDefaultAsync(a => a.NodeAclRowId == nodeAclRowId, cancellationToken).ConfigureAwait(false)
?? throw new InvalidNodeAclGrantException($"NodeAcl row {nodeAclRowId} not found.");
row.PermissionFlags = newPermissions;
if (notes is not null) row.Notes = notes;
await db.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return row;
}
private static void ValidateGrantShape(NodeAclScopeKind scopeKind, string? scopeId, NodePermissions permissions)
{
if (permissions == NodePermissions.None)
throw new InvalidNodeAclGrantException(
"Permission set cannot be None — grants must carry at least one flag (decision #129, additive only).");
if (scopeKind == NodeAclScopeKind.Cluster && !string.IsNullOrEmpty(scopeId))
throw new InvalidNodeAclGrantException(
"Cluster-scope grants must have null ScopeId. ScopeId only applies to sub-cluster scopes.");
if (scopeKind != NodeAclScopeKind.Cluster && string.IsNullOrEmpty(scopeId))
throw new InvalidNodeAclGrantException(
$"ScopeKind={scopeKind} requires a populated ScopeId.");
}
private async Task EnsureNoDuplicate(
long generationId, string clusterId, string ldapGroup, NodeAclScopeKind scopeKind, string? scopeId,
CancellationToken cancellationToken)
{
var exists = await db.NodeAcls.AsNoTracking()
.AnyAsync(a => a.GenerationId == generationId
&& a.ClusterId == clusterId
&& a.LdapGroup == ldapGroup
&& a.ScopeKind == scopeKind
&& a.ScopeId == scopeId,
cancellationToken).ConfigureAwait(false);
if (exists)
throw new InvalidNodeAclGrantException(
$"A grant for (LdapGroup={ldapGroup}, ScopeKind={scopeKind}, ScopeId={scopeId}) already exists in generation {generationId}. " +
"Update the existing row's permissions instead of inserting a duplicate.");
}
}
/// <summary>Thrown when a <see cref="NodeAcl"/> grant authoring request violates an invariant.</summary>
public sealed class InvalidNodeAclGrantException(string message) : Exception(message);

View File

@@ -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;
/// <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,
};
}
}

View File

@@ -0,0 +1,20 @@
namespace ZB.MOM.WW.OtOpcUa.Server.Security;
/// <summary>
/// Minimal interface an <see cref="Opc.Ua.IUserIdentity"/> 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.
/// </summary>
/// <remarks>
/// Control/data-plane separation (decision #150): Admin UI role routing consumes
/// <see cref="IRoleBearer.Roles"/> via <c>LdapGroupRoleMapping</c>; the OPC UA data-path
/// evaluator consumes <see cref="LdapGroups"/> directly against <c>NodeAcl</c>. The two
/// are sourced from the same directory query at sign-in but never cross.
/// </remarks>
public interface ILdapGroupsBearer
{
/// <summary>Fully-qualified LDAP group DNs the user is a member of.</summary>
IReadOnlyList<string> LdapGroups { get; }
}

View File

@@ -0,0 +1,146 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Admin.Services;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.Admin.Tests;
[Trait("Category", "Unit")]
public sealed class ValidatedNodeAclAuthoringServiceTests : IDisposable
{
private readonly OtOpcUaConfigDbContext _db;
public ValidatedNodeAclAuthoringServiceTests()
{
var options = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase($"val-nodeacl-{Guid.NewGuid():N}")
.Options;
_db = new OtOpcUaConfigDbContext(options);
}
public void Dispose() => _db.Dispose();
[Fact]
public async Task Grant_Rejects_NonePermissions()
{
var svc = new ValidatedNodeAclAuthoringService(_db);
await Should.ThrowAsync<InvalidNodeAclGrantException>(() => svc.GrantAsync(
draftGenerationId: 1, clusterId: "c1", ldapGroup: "cn=ops",
scopeKind: NodeAclScopeKind.Cluster, scopeId: null,
permissions: NodePermissions.None, notes: null, CancellationToken.None));
}
[Fact]
public async Task Grant_Rejects_ClusterScope_With_ScopeId()
{
var svc = new ValidatedNodeAclAuthoringService(_db);
await Should.ThrowAsync<InvalidNodeAclGrantException>(() => svc.GrantAsync(
1, "c1", "cn=ops",
NodeAclScopeKind.Cluster, scopeId: "not-null-wrong",
NodePermissions.Read, null, CancellationToken.None));
}
[Fact]
public async Task Grant_Rejects_SubClusterScope_Without_ScopeId()
{
var svc = new ValidatedNodeAclAuthoringService(_db);
await Should.ThrowAsync<InvalidNodeAclGrantException>(() => svc.GrantAsync(
1, "c1", "cn=ops",
NodeAclScopeKind.Equipment, scopeId: null,
NodePermissions.Read, null, CancellationToken.None));
}
[Fact]
public async Task Grant_Succeeds_When_Valid()
{
var svc = new ValidatedNodeAclAuthoringService(_db);
var row = await svc.GrantAsync(
1, "c1", "cn=ops",
NodeAclScopeKind.Cluster, null,
NodePermissions.Read | NodePermissions.Browse, "fleet reader", CancellationToken.None);
row.LdapGroup.ShouldBe("cn=ops");
row.PermissionFlags.ShouldBe(NodePermissions.Read | NodePermissions.Browse);
row.NodeAclId.ShouldNotBeNullOrWhiteSpace();
}
[Fact]
public async Task Grant_Rejects_DuplicateScopeGroup_Pair()
{
var svc = new ValidatedNodeAclAuthoringService(_db);
await svc.GrantAsync(1, "c1", "cn=ops", NodeAclScopeKind.Cluster, null,
NodePermissions.Read, null, CancellationToken.None);
await Should.ThrowAsync<InvalidNodeAclGrantException>(() => svc.GrantAsync(
1, "c1", "cn=ops", NodeAclScopeKind.Cluster, null,
NodePermissions.WriteOperate, null, CancellationToken.None));
}
[Fact]
public async Task Grant_SameGroup_DifferentScope_IsAllowed()
{
var svc = new ValidatedNodeAclAuthoringService(_db);
await svc.GrantAsync(1, "c1", "cn=ops", NodeAclScopeKind.Cluster, null,
NodePermissions.Read, null, CancellationToken.None);
var tagRow = await svc.GrantAsync(1, "c1", "cn=ops",
NodeAclScopeKind.Tag, scopeId: "tag-xyz",
NodePermissions.WriteOperate, null, CancellationToken.None);
tagRow.ScopeKind.ShouldBe(NodeAclScopeKind.Tag);
}
[Fact]
public async Task Grant_SameGroupScope_DifferentDraft_IsAllowed()
{
var svc = new ValidatedNodeAclAuthoringService(_db);
await svc.GrantAsync(1, "c1", "cn=ops", NodeAclScopeKind.Cluster, null,
NodePermissions.Read, null, CancellationToken.None);
var draft2Row = await svc.GrantAsync(2, "c1", "cn=ops",
NodeAclScopeKind.Cluster, null,
NodePermissions.Read, null, CancellationToken.None);
draft2Row.GenerationId.ShouldBe(2);
}
[Fact]
public async Task UpdatePermissions_Rejects_None()
{
var svc = new ValidatedNodeAclAuthoringService(_db);
var row = await svc.GrantAsync(1, "c1", "cn=ops", NodeAclScopeKind.Cluster, null,
NodePermissions.Read, null, CancellationToken.None);
await Should.ThrowAsync<InvalidNodeAclGrantException>(
() => svc.UpdatePermissionsAsync(row.NodeAclRowId, NodePermissions.None, null, CancellationToken.None));
}
[Fact]
public async Task UpdatePermissions_RoundTrips_NewFlags()
{
var svc = new ValidatedNodeAclAuthoringService(_db);
var row = await svc.GrantAsync(1, "c1", "cn=ops", NodeAclScopeKind.Cluster, null,
NodePermissions.Read, null, CancellationToken.None);
var updated = await svc.UpdatePermissionsAsync(row.NodeAclRowId,
NodePermissions.Read | NodePermissions.WriteOperate, "bumped", CancellationToken.None);
updated.PermissionFlags.ShouldBe(NodePermissions.Read | NodePermissions.WriteOperate);
updated.Notes.ShouldBe("bumped");
}
[Fact]
public async Task UpdatePermissions_MissingRow_Throws()
{
var svc = new ValidatedNodeAclAuthoringService(_db);
await Should.ThrowAsync<InvalidNodeAclGrantException>(
() => svc.UpdatePermissionsAsync(Guid.NewGuid(), NodePermissions.Read, null, CancellationToken.None));
}
}

View File

@@ -22,6 +22,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Admin\ZB.MOM.WW.OtOpcUa.Admin.csproj"/>
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.1.1"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.0"/>
</ItemGroup>
<ItemGroup>

View File

@@ -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<string> groups)
{
DisplayName = name;
LdapGroups = groups;
}
public new string DisplayName { get; }
public IReadOnlyList<string> 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();
}
}