using System.Text.Json; using ZB.MOM.WW.Audit; using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy; using ZB.MOM.WW.MxGateway.Server.Galaxy; using ZB.MOM.WW.MxGateway.Server.Security.Audit; 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, IAuditWriter auditWriter) : 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. /// /// The per-request client correlation id, if any. Persisted as the audit record's typed /// CorrelationId when it parses as a GUID; a non-GUID value leaves that column null. /// The raw string is always preserved in DetailsJson["clientCorrelationId"] so a /// non-GUID id (e.g. from Rust/Python/Java clients) is never silently lost. /// /// Token to observe for cancellation. public async Task RecordDenialAsync( ApiKeyIdentity? identity, string commandKind, string target, ConstraintFailure failure, string? correlationId, CancellationToken cancellationToken) { // Emit a canonical Denied AuditEvent directly through the best-effort IAuditWriter // (Task 2.3 #6): structured Target (":") and a richer DetailsJson // envelope carrying constraint/message/commandKind/target. AuditEvent auditEvent = new() { EventId = Guid.NewGuid(), OccurredAtUtc = DateTimeOffset.UtcNow, Actor = identity?.KeyId ?? "anonymous", Action = "constraint-denied", Outcome = AuditOutcome.Denied, Category = CanonicalForwardingApiKeyAuditStore.ApiKeyCategory, Target = $"{commandKind}:{target}", SourceNode = null, CorrelationId = Guid.TryParse(correlationId, out var cid) ? cid : (Guid?)null, DetailsJson = JsonSerializer.Serialize(new Dictionary { ["constraint"] = failure.ConstraintName, ["message"] = failure.Message, ["commandKind"] = commandKind, ["target"] = target, // Always preserve the raw client correlation id here so it is never silently // lost: the typed CorrelationId column only retains GUID-parseable ids, but // clients (Rust/Python/Java) commonly send non-GUID or empty trace ids. The // raw id is a client trace id, not a secret, so storing it is fine. ["clientCorrelationId"] = correlationId ?? "", }), }; await auditWriter.WriteAsync(auditEvent, 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)); } }