96bea1d478
Restyles the Blazor dashboard onto a portable token-based theme so it reads like an instrument panel: warm-paper background, hairline-ruled panels, IBM Plex type, monospace tabular numerics, and status carried by colour chips. Vendors theme.css + IBM Plex fonts, rewrites dashboard.css as a thin token-driven view layer, and swaps the Bootstrap navbar and status badges for the design-system app bar and chips. Also includes pending API-key management, Galaxy hierarchy projection, and constraint-enforcement work with their tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
166 lines
6.2 KiB
C#
166 lines
6.2 KiB
C#
using MxGateway.Contracts.Proto.Galaxy;
|
|
using MxGateway.Server.Galaxy;
|
|
using MxGateway.Server.Security.Authentication;
|
|
using MxGateway.Server.Sessions;
|
|
|
|
namespace MxGateway.Server.Security.Authorization;
|
|
|
|
public sealed class ConstraintEnforcer(
|
|
IGalaxyHierarchyCache cache,
|
|
IApiKeyAuditStore auditStore) : IConstraintEnforcer
|
|
{
|
|
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));
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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,
|
|
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));
|
|
}
|
|
}
|