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 { /// 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)); } /// 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)); } /// 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); } /// public async Task RecordDenialAsync( ApiKeyIdentity? identity, string commandKind, string target, ConstraintFailure failure, 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. // TODO(Task 2.3): CorrelationId is left null here. Threading the per-request // ClientCorrelationId down to RecordDenialAsync would require an invasive IConstraintEnforcer // signature change across the gRPC call path; that is deferred to a follow-up. 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 = null, DetailsJson = JsonSerializer.Serialize(new Dictionary { ["constraint"] = failure.ConstraintName, ["message"] = failure.Message, ["commandKind"] = commandKind, ["target"] = target, }), }; 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)); } }