using ZB.MOM.WW.Audit; 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; // ConstraintEnforcer enforces against the gateway's constraint-bearing identity; the shared library // also defines an ApiKeyIdentity, so disambiguate to the gateway type. using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity; namespace ZB.MOM.WW.MxGateway.Tests.Security.Authorization; public sealed class ConstraintEnforcerTests { /// Verifies that read outside allowed subtree returns failure. [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); } /// Verifies that write with high classification returns failure and audits. [Fact] public async Task CheckWriteHandleAsync_WhenClassificationTooHigh_ReturnsFailureAndAudits() { ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditWriter auditWriter); 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); AuditEvent auditEvent = Assert.Single(auditWriter.Events); Assert.Equal("operator01", auditEvent.Actor); Assert.Equal("constraint-denied", auditEvent.Action); Assert.Equal(AuditOutcome.Denied, auditEvent.Outcome); Assert.Equal("ApiKey", auditEvent.Category); // Target is the structured ":" form. Assert.Equal("Write:42", auditEvent.Target); Assert.NotNull(auditEvent.DetailsJson); Assert.Contains("max_write_classification", auditEvent.DetailsJson, StringComparison.Ordinal); Assert.Null(auditEvent.CorrelationId); } /// A denial with no identity records the canonical "anonymous" actor. [Fact] public async Task RecordDenialAsync_WithoutIdentity_UsesAnonymousActor() { ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditWriter auditWriter); await enforcer.RecordDenialAsync( identity: null, "Read", "Secret.Tag", new ConstraintFailure("read_scope", "Tag is outside the API key read scope."), CancellationToken.None); AuditEvent auditEvent = Assert.Single(auditWriter.Events); Assert.Equal("anonymous", auditEvent.Actor); Assert.Equal(AuditOutcome.Denied, auditEvent.Outcome); Assert.Equal("Read:Secret.Tag", auditEvent.Target); } /// Verifies that historized-only constraint requires historized attribute. [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); } /// Verifies that alarm-only constraint requires alarm attribute. [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); } /// Verifies that attribute-only constraint fails closed for object tag. [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 FakeAuditWriter auditWriter) { auditWriter = new FakeAuditWriter(); return new ConstraintEnforcer(new StubGalaxyHierarchyCache(CreateEntry()), auditWriter); } 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 { /// Gets the current cache entry. public GalaxyHierarchyCacheEntry Current { get; } = current; /// public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask; /// public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask; } private sealed class FakeAuditWriter : IAuditWriter { /// Gets the recorded canonical audit events. public List Events { get; } = []; /// public Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default) { Events.Add(auditEvent); return Task.CompletedTask; } } }