using ZB.MOM.WW.Auth.Abstractions.ApiKeys; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Server.Galaxy; using ZB.MOM.WW.MxGateway.Server.Security.Authentication; using ZB.MOM.WW.MxGateway.Server.Sessions; // The gateway carries its own constraint-bearing identity downstream; the shared library also // defines an ApiKeyIdentity (scopes + opaque constraints JSON), so disambiguate to the gateway type. using ApiKeyIdentity = ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyIdentity; namespace ZB.MOM.WW.MxGateway.Server.Security.Authorization; public sealed class ConstraintEnforcer( IGalaxyHierarchyCache cache, IApiKeyAuditStore auditStore) : IConstraintEnforcer { /// Checks read constraints on a tag address. /// The API key identity to check constraints for. /// Tag address to validate. /// Token to observe for cancellation. public Task CheckReadTagAsync( ApiKeyIdentity? identity, string tagAddress, CancellationToken cancellationToken) { ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty; if (!constraints.HasReadConstraints) { return Task.FromResult(null); } return Task.FromResult(CheckReadTarget(constraints, tagAddress)); } /// Checks read constraints on a server and item handle. /// The API key identity to check constraints for. /// The gateway session containing handle registrations. /// The MXAccess server handle. /// The MXAccess item handle. /// Token to observe for cancellation. public Task CheckReadHandleAsync( ApiKeyIdentity? identity, GatewaySession session, int serverHandle, int itemHandle, CancellationToken cancellationToken) { ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty; if (!constraints.HasReadConstraints) { return Task.FromResult(null); } if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration)) { return Task.FromResult(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session.")); } return Task.FromResult(CheckReadTarget(constraints, registration.TagAddress)); } /// Checks write constraints on a server and item handle. /// The API key identity to check constraints for. /// The gateway session containing handle registrations. /// The MXAccess server handle. /// The MXAccess item handle. /// Token to observe for cancellation. public Task CheckWriteHandleAsync( ApiKeyIdentity? identity, GatewaySession session, int serverHandle, int itemHandle, CancellationToken cancellationToken) { ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty; if (!constraints.HasWriteConstraints) { return Task.FromResult(null); } if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration)) { return Task.FromResult(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session.")); } GalaxyTagLookup? target = ResolveTarget(registration.TagAddress); if (target is null) { return Task.FromResult(new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache.")); } if (!MatchesPathOrTag(target.ContainedPath, registration.TagAddress, constraints.WriteSubtrees, constraints.WriteTagGlobs)) { return Task.FromResult(new ConstraintFailure("write_scope", "Tag is outside the API key write scope.")); } if (constraints.MaxWriteClassification is { } maxClassification) { GalaxyAttribute? attribute = target.Attribute; if (attribute is null) { return Task.FromResult(new ConstraintFailure("max_write_classification", "Attribute security classification is not available.")); } if (attribute.SecurityClassification > maxClassification) { return Task.FromResult(new ConstraintFailure( "max_write_classification", $"Attribute security classification {attribute.SecurityClassification} exceeds allowed maximum {maxClassification}.")); } } return Task.FromResult(null); } /// Records a constraint denial audit entry. /// The API key identity that was denied. /// The command type (e.g., read, write). /// The target being accessed (tag address or handle). /// The constraint failure details. /// Token to observe for cancellation. public async Task RecordDenialAsync( ApiKeyIdentity? identity, string commandKind, string target, ConstraintFailure failure, CancellationToken cancellationToken) { await auditStore.AppendAsync( new ApiKeyAuditEntry( KeyId: identity?.KeyId, EventType: "constraint-denied", RemoteAddress: null, CreatedUtc: DateTimeOffset.UtcNow, Details: $"{commandKind}: {target}: {failure.ConstraintName}: {failure.Message}"), cancellationToken) .ConfigureAwait(false); } private ConstraintFailure? CheckReadTarget( ApiKeyConstraints constraints, string tagAddress) { GalaxyTagLookup? target = ResolveTarget(tagAddress); if (target is null) { return new ConstraintFailure("tag_metadata", "Tag metadata is not available in the Galaxy hierarchy cache."); } if (!MatchesPathOrTag(target.ContainedPath, tagAddress, constraints.ReadSubtrees, constraints.ReadTagGlobs)) { return new ConstraintFailure("read_scope", "Tag is outside the API key read scope."); } if (constraints.ReadAlarmOnly && target.Attribute is not { IsAlarm: true }) { return new ConstraintFailure("read_alarm_only", "Tag is not an alarm-bearing attribute."); } if (constraints.ReadHistorizedOnly && target.Attribute is not { IsHistorized: true }) { return new ConstraintFailure("read_historized_only", "Tag is not a historized attribute."); } return null; } private GalaxyTagLookup? ResolveTarget(string tagAddress) { GalaxyHierarchyCacheEntry entry = cache.Current; return !string.IsNullOrWhiteSpace(tagAddress) && entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup) ? lookup : null; } private static bool MatchesPathOrTag( string containedPath, string tagAddress, IReadOnlyList subtreeGlobs, IReadOnlyList tagGlobs) { bool hasSubtreeConstraint = subtreeGlobs.Count > 0; bool hasTagConstraint = tagGlobs.Count > 0; if (!hasSubtreeConstraint && !hasTagConstraint) { return true; } return subtreeGlobs.Any(glob => GalaxyGlobMatcher.IsMatch(containedPath, glob)) || tagGlobs.Any(glob => GalaxyGlobMatcher.IsMatch(tagAddress, glob)); } }