194 lines
8.3 KiB
C#
194 lines
8.3 KiB
C#
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
|
|
{
|
|
/// <summary>Checks read constraints on a tag address.</summary>
|
|
/// <param name="identity">The API key identity to check constraints for.</param>
|
|
/// <param name="tagAddress">Tag address to validate.</param>
|
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
|
public Task<ConstraintFailure?> CheckReadTagAsync(
|
|
ApiKeyIdentity? identity,
|
|
string tagAddress,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
|
|
if (!constraints.HasReadConstraints)
|
|
{
|
|
return Task.FromResult<ConstraintFailure?>(null);
|
|
}
|
|
|
|
return Task.FromResult(CheckReadTarget(constraints, tagAddress));
|
|
}
|
|
|
|
/// <summary>Checks read constraints on a server and item handle.</summary>
|
|
/// <param name="identity">The API key identity to check constraints for.</param>
|
|
/// <param name="session">The gateway session containing handle registrations.</param>
|
|
/// <param name="serverHandle">The MXAccess server handle.</param>
|
|
/// <param name="itemHandle">The MXAccess item handle.</param>
|
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
|
public Task<ConstraintFailure?> CheckReadHandleAsync(
|
|
ApiKeyIdentity? identity,
|
|
GatewaySession session,
|
|
int serverHandle,
|
|
int itemHandle,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
|
|
if (!constraints.HasReadConstraints)
|
|
{
|
|
return Task.FromResult<ConstraintFailure?>(null);
|
|
}
|
|
|
|
if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration))
|
|
{
|
|
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure("item_handle", "Item handle is not registered in the constrained session."));
|
|
}
|
|
|
|
return Task.FromResult(CheckReadTarget(constraints, registration.TagAddress));
|
|
}
|
|
|
|
/// <summary>Checks write constraints on a server and item handle.</summary>
|
|
/// <param name="identity">The API key identity to check constraints for.</param>
|
|
/// <param name="session">The gateway session containing handle registrations.</param>
|
|
/// <param name="serverHandle">The MXAccess server handle.</param>
|
|
/// <param name="itemHandle">The MXAccess item handle.</param>
|
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
|
public Task<ConstraintFailure?> CheckWriteHandleAsync(
|
|
ApiKeyIdentity? identity,
|
|
GatewaySession session,
|
|
int serverHandle,
|
|
int itemHandle,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ApiKeyConstraints constraints = identity?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
|
|
if (!constraints.HasWriteConstraints)
|
|
{
|
|
return Task.FromResult<ConstraintFailure?>(null);
|
|
}
|
|
|
|
if (!session.TryGetItemRegistration(serverHandle, itemHandle, out SessionItemRegistration registration))
|
|
{
|
|
return Task.FromResult<ConstraintFailure?>(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<ConstraintFailure?>(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<ConstraintFailure?>(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<ConstraintFailure?>(new ConstraintFailure("max_write_classification", "Attribute security classification is not available."));
|
|
}
|
|
|
|
if (attribute.SecurityClassification > maxClassification)
|
|
{
|
|
return Task.FromResult<ConstraintFailure?>(new ConstraintFailure(
|
|
"max_write_classification",
|
|
$"Attribute security classification {attribute.SecurityClassification} exceeds allowed maximum {maxClassification}."));
|
|
}
|
|
}
|
|
|
|
return Task.FromResult<ConstraintFailure?>(null);
|
|
}
|
|
|
|
/// <summary>Records a constraint denial audit entry.</summary>
|
|
/// <param name="identity">The API key identity that was denied.</param>
|
|
/// <param name="commandKind">The command type (e.g., read, write).</param>
|
|
/// <param name="target">The target being accessed (tag address or handle).</param>
|
|
/// <param name="failure">The constraint failure details.</param>
|
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
|
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<string> subtreeGlobs,
|
|
IReadOnlyList<string> 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));
|
|
}
|
|
}
|