using System.Text.Json; 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, correlationId: null, 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 carrying a parseable correlation id stores it on the audit record. [Fact] public async Task RecordDenialAsync_WithGuidCorrelationId_StoresCorrelationId() { ConstraintEnforcer enforcer = CreateEnforcer(out FakeAuditWriter auditWriter); Guid correlationId = Guid.NewGuid(); await enforcer.RecordDenialAsync( identity: null, "Read", "Secret.Tag", new ConstraintFailure("read_scope", "Tag is outside the API key read scope."), correlationId.ToString(), CancellationToken.None); AuditEvent auditEvent = Assert.Single(auditWriter.Events); Assert.Equal(correlationId, auditEvent.CorrelationId); } /// /// A denial with a non-GUID correlation id leaves the typed audit correlation id null but /// still preserves the raw client correlation id in DetailsJson so it is not lost. /// [Fact] public async Task RecordDenialAsync_WithNonGuidCorrelationId_LeavesCorrelationIdNullButPreservesRawInDetails() { 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."), "rust-client-Write-7", CancellationToken.None); AuditEvent auditEvent = Assert.Single(auditWriter.Events); Assert.Null(auditEvent.CorrelationId); Assert.NotNull(auditEvent.DetailsJson); Dictionary? details = JsonSerializer.Deserialize>(auditEvent.DetailsJson); Assert.NotNull(details); Assert.Equal("rust-client-Write-7", details["clientCorrelationId"]); } /// 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."), correlationId: null, 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); } /// /// A bare array attribute address (no trailing []) resolves through the Galaxy index /// even though arrays are keyed by their suffixed FullTagReference (e.g. "Pump_001.Levels[]"). /// Without the [] fallback in ResolveTarget the bare name misses the index and a /// read-constrained key gets a spurious tag_metadata denial for an AddItem it should allow. /// [Fact] public async Task CheckReadTagAsync_WithBareArrayName_ResolvesViaArraySuffixFallback() { ConstraintEnforcer enforcer = CreateEnforcer(out _); ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with { // A read constraint that covers the Pump_001 subtree; the array attribute is inside it. ReadTagGlobs = ["Pump_001.*"], }); ConstraintFailure? failure = await enforcer.CheckReadTagAsync( identity, "Pump_001.Levels", CancellationToken.None); // Before the fix: bare "Pump_001.Levels" misses the index (keyed "Pump_001.Levels[]") and // returns a tag_metadata failure. After the fix: it resolves and is within scope -> null. Assert.Null(failure); } /// /// A bare non-array name that is genuinely absent from the index still resolves to null: /// the [] probe must not manufacture a false positive for a scalar/missing tag. /// [Fact] public async Task CheckReadTagAsync_WithMissingNonArrayName_StillFailsToResolve() { ConstraintEnforcer enforcer = CreateEnforcer(out _); ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with { ReadTagGlobs = ["Pump_001.*"], }); // "Pump_001.Scalar" is not in the index, and "Pump_001.Scalar[]" is not either, so the // suffix probe must not resolve it. A genuinely-unknown name behaves the same. ConstraintFailure? missingScalar = await enforcer.CheckReadTagAsync( identity, "Pump_001.Scalar", CancellationToken.None); Assert.NotNull(missingScalar); Assert.Equal("tag_metadata", missingScalar.ConstraintName); ConstraintFailure? unknown = await enforcer.CheckReadTagAsync( identity, "DoesNotExist_999.Whatever", CancellationToken.None); Assert.NotNull(unknown); Assert.Equal("tag_metadata", unknown.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 GalaxyAttribute { // Galaxy SQL keys array attributes by their suffixed FullTagReference, // so the index entry is "Pump_001.Levels[]", not the bare name. AttributeName = "Levels", FullTagReference = "Pump_001.Levels[]", IsArray = true, }, }, }, 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; } } }