using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Server.Dashboard; using ZB.MOM.WW.MxGateway.Server.Galaxy; using ZB.MOM.WW.MxGateway.Server.Security.Authentication; using ZB.MOM.WW.MxGateway.Server.Security.Authorization; using ZB.MOM.WW.MxGateway.Server.Sessions; namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization; public sealed class ConstraintEnforcerTests { [Fact] public async Task CheckReadTagAsync_WhenOutsideReadSubtree_ReturnsFailure() { ConstraintEnforcer enforcer = CreateEnforcer(out _); ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with { ReadSubtrees = ["Area1/*"], }); ConstraintFailure? failure = await enforcer.CheckReadTagAsync( identity, "Other_001.PV", CancellationToken.None); Assert.NotNull(failure); Assert.Equal("read_scope", failure.ConstraintName); } [Fact] public async Task CheckWriteHandleAsync_WhenClassificationTooHigh_ReturnsFailureAndAudits() { ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditStore auditStore); ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with { WriteSubtrees = ["Area1/*"], MaxWriteClassification = 1, }); GatewaySession session = CreateSession(); session.TrackCommandReply( new MxCommand { Kind = MxCommandKind.AddItem, AddItem = new AddItemCommand { ServerHandle = 12, ItemDefinition = "Pump_001.PV", }, }, new MxCommandReply { ProtocolStatus = ZB.MOM.WW.MxGateway.Server.Grpc.MxAccessGrpcMapper.Ok(), AddItem = new AddItemReply { ItemHandle = 42 }, }); ConstraintFailure? failure = await enforcer.CheckWriteHandleAsync( identity, session, serverHandle: 12, itemHandle: 42, CancellationToken.None); Assert.NotNull(failure); await enforcer.RecordDenialAsync(identity, "Write", "42", failure, CancellationToken.None); ApiKeyAuditEntry entry = Assert.Single(auditStore.Entries); Assert.Equal("operator01", entry.KeyId); Assert.Equal("constraint-denied", entry.EventType); Assert.Contains("max_write_classification", entry.Details, StringComparison.Ordinal); } [Fact] public async Task CheckReadTagAsync_WithHistorizedOnly_RequiresRequestedAttributeToBeHistorized() { ConstraintEnforcer enforcer = CreateEnforcer(out _); ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with { ReadHistorizedOnly = true, }); ConstraintFailure? failure = await enforcer.CheckReadTagAsync( identity, "Pump_001.NonHistorized", CancellationToken.None); Assert.NotNull(failure); Assert.Equal("read_historized_only", failure.ConstraintName); } [Fact] public async Task CheckReadTagAsync_WithAlarmOnly_RequiresRequestedAttributeToBeAlarm() { ConstraintEnforcer enforcer = CreateEnforcer(out _); ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with { ReadAlarmOnly = true, }); ConstraintFailure? failure = await enforcer.CheckReadTagAsync( identity, "Pump_001.PV", CancellationToken.None); Assert.NotNull(failure); Assert.Equal("read_alarm_only", failure.ConstraintName); } [Fact] public async Task CheckReadTagAsync_WithAttributeOnlyConstraint_FailsClosedForObjectTag() { ConstraintEnforcer enforcer = CreateEnforcer(out _); ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with { ReadHistorizedOnly = true, }); ConstraintFailure? failure = await enforcer.CheckReadTagAsync( identity, "Pump_001", CancellationToken.None); Assert.NotNull(failure); Assert.Equal("read_historized_only", failure.ConstraintName); } private static ConstraintEnforcer CreateEnforcer(out FakeAuditStore auditStore) { auditStore = new FakeAuditStore(); return new ConstraintEnforcer(new StubGalaxyHierarchyCache(CreateEntry()), auditStore); } private static ApiKeyIdentity CreateIdentity(ApiKeyConstraints constraints) { return new ApiKeyIdentity( KeyId: "operator01", KeyPrefix: "mxgw_operator01", DisplayName: "Operator", Scopes: new HashSet(StringComparer.Ordinal), Constraints: constraints); } private static GatewaySession CreateSession() { GatewaySession session = new( "session-1", "mxaccess", "pipe", "nonce", "operator", "client", "correlation", TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5), DateTimeOffset.UtcNow); return session; } private static GalaxyHierarchyCacheEntry CreateEntry() { IReadOnlyList objects = [ new GalaxyObject { GobjectId = 1, TagName = "Area1", ContainedName = "Area1", }, new GalaxyObject { GobjectId = 2, TagName = "Pump_001", ContainedName = "Pump", ParentGobjectId = 1, Attributes = { new GalaxyAttribute { AttributeName = "PV", FullTagReference = "Pump_001.PV", SecurityClassification = 2, IsHistorized = true, }, new GalaxyAttribute { AttributeName = "Alarm", FullTagReference = "Pump_001.Alarm", IsAlarm = true, }, new GalaxyAttribute { AttributeName = "NonHistorized", FullTagReference = "Pump_001.NonHistorized", }, }, }, new GalaxyObject { GobjectId = 3, TagName = "Other_001", ContainedName = "Other", Attributes = { new GalaxyAttribute { AttributeName = "PV", FullTagReference = "Other_001.PV", }, }, }, ]; return GalaxyHierarchyCacheEntry.Empty with { Status = GalaxyCacheStatus.Healthy, Objects = objects, Index = GalaxyHierarchyIndex.Build(objects), DashboardSummary = DashboardGalaxySummary.Unknown, }; } private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache { public GalaxyHierarchyCacheEntry Current { get; } = current; public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; } private sealed class FakeAuditStore : IApiKeyAuditStore { public List Entries { get; } = []; public Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken cancellationToken) { Entries.Add(entry); return Task.CompletedTask; } public Task> ListRecentAsync(int count, CancellationToken cancellationToken) { return Task.FromResult>([]); } } }