Merge origin/main with local pending work and update AGENTS.md references
- Resolve 14 conflicts from popping local stash on top of origin'seed1e88+8d3352fdoc-comment additions (11 mechanical, plus version.rs, DashboardAuthenticatorTests.cs, DashboardGalaxyProjector.cs) - Fix 4 test files that used AGENTS.md as the repo-root sentinel (now use CLAUDE.md, since AGENTS.md was removed in4731ab5) - Redirect 10 doc citations from AGENTS.md to the matching gateway.md sections (Value Model, Status Model, Security, STA Worker Thread Model, gRPC Layer rule, cancellation rule) Verified: solution build clean, x86 worker build clean, 266/266 gateway tests passing, 121/121 worker tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveGatewayConfiguration(
|
||||
EffectiveAuthenticationConfiguration Authentication,
|
||||
EffectiveLdapConfiguration Ldap,
|
||||
EffectiveWorkerConfiguration Worker,
|
||||
EffectiveSessionConfiguration Sessions,
|
||||
EffectiveEventConfiguration Events,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
namespace MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveProtocolConfiguration(uint WorkerProtocolVersion);
|
||||
public sealed record EffectiveProtocolConfiguration(
|
||||
uint WorkerProtocolVersion,
|
||||
int MaxGrpcMessageBytes);
|
||||
|
||||
@@ -3,4 +3,7 @@ namespace MxGateway.Server.Configuration;
|
||||
public sealed record EffectiveSessionConfiguration(
|
||||
int DefaultCommandTimeoutSeconds,
|
||||
int MaxSessions,
|
||||
int MaxPendingCommandsPerSession,
|
||||
int DefaultLeaseSeconds,
|
||||
int LeaseSweepIntervalSeconds,
|
||||
bool AllowMultipleEventSubscribers);
|
||||
|
||||
@@ -19,6 +19,19 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
|
||||
SqlitePath: value.Authentication.SqlitePath,
|
||||
PepperSecretName: RedactedValue,
|
||||
RunMigrationsOnStartup: value.Authentication.RunMigrationsOnStartup),
|
||||
Ldap: new EffectiveLdapConfiguration(
|
||||
Enabled: value.Ldap.Enabled,
|
||||
Server: value.Ldap.Server,
|
||||
Port: value.Ldap.Port,
|
||||
UseTls: value.Ldap.UseTls,
|
||||
AllowInsecureLdap: value.Ldap.AllowInsecureLdap,
|
||||
SearchBase: value.Ldap.SearchBase,
|
||||
ServiceAccountDn: value.Ldap.ServiceAccountDn,
|
||||
ServiceAccountPassword: RedactedValue,
|
||||
UserNameAttribute: value.Ldap.UserNameAttribute,
|
||||
DisplayNameAttribute: value.Ldap.DisplayNameAttribute,
|
||||
GroupAttribute: value.Ldap.GroupAttribute,
|
||||
RequiredGroup: value.Ldap.RequiredGroup),
|
||||
Worker: new EffectiveWorkerConfiguration(
|
||||
ExecutablePath: value.Worker.ExecutablePath,
|
||||
WorkingDirectory: value.Worker.WorkingDirectory,
|
||||
@@ -31,6 +44,9 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
|
||||
Sessions: new EffectiveSessionConfiguration(
|
||||
DefaultCommandTimeoutSeconds: value.Sessions.DefaultCommandTimeoutSeconds,
|
||||
MaxSessions: value.Sessions.MaxSessions,
|
||||
MaxPendingCommandsPerSession: value.Sessions.MaxPendingCommandsPerSession,
|
||||
DefaultLeaseSeconds: value.Sessions.DefaultLeaseSeconds,
|
||||
LeaseSweepIntervalSeconds: value.Sessions.LeaseSweepIntervalSeconds,
|
||||
AllowMultipleEventSubscribers: value.Sessions.AllowMultipleEventSubscribers),
|
||||
Events: new EffectiveEventConfiguration(
|
||||
QueueCapacity: value.Events.QueueCapacity,
|
||||
@@ -44,6 +60,8 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
|
||||
RecentFaultLimit: value.Dashboard.RecentFaultLimit,
|
||||
RecentSessionLimit: value.Dashboard.RecentSessionLimit,
|
||||
ShowTagValues: value.Dashboard.ShowTagValues),
|
||||
Protocol: new EffectiveProtocolConfiguration(value.Protocol.WorkerProtocolVersion));
|
||||
Protocol: new EffectiveProtocolConfiguration(
|
||||
value.Protocol.WorkerProtocolVersion,
|
||||
value.Protocol.MaxGrpcMessageBytes));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ public sealed class GatewayOptions
|
||||
/// </summary>
|
||||
public AuthenticationOptions Authentication { get; init; } = new();
|
||||
|
||||
public LdapOptions Ldap { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets worker process configuration options.
|
||||
/// </summary>
|
||||
|
||||
@@ -19,6 +19,7 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
||||
List<string> failures = [];
|
||||
|
||||
ValidateAuthentication(options.Authentication, failures);
|
||||
ValidateLdap(options.Ldap, failures);
|
||||
ValidateWorker(options.Worker, failures);
|
||||
ValidateSessions(options.Sessions, failures);
|
||||
ValidateEvents(options.Events, failures);
|
||||
@@ -55,6 +56,47 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateLdap(LdapOptions options, List<string> failures)
|
||||
{
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AddIfBlank(options.Server, "MxGateway:Ldap:Server is required when LDAP login is enabled.", failures);
|
||||
AddIfBlank(options.SearchBase, "MxGateway:Ldap:SearchBase is required when LDAP login is enabled.", failures);
|
||||
AddIfBlank(
|
||||
options.ServiceAccountDn,
|
||||
"MxGateway:Ldap:ServiceAccountDn is required when LDAP login is enabled.",
|
||||
failures);
|
||||
AddIfBlank(
|
||||
options.ServiceAccountPassword,
|
||||
"MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.",
|
||||
failures);
|
||||
AddIfBlank(
|
||||
options.UserNameAttribute,
|
||||
"MxGateway:Ldap:UserNameAttribute is required when LDAP login is enabled.",
|
||||
failures);
|
||||
AddIfBlank(
|
||||
options.DisplayNameAttribute,
|
||||
"MxGateway:Ldap:DisplayNameAttribute is required when LDAP login is enabled.",
|
||||
failures);
|
||||
AddIfBlank(
|
||||
options.GroupAttribute,
|
||||
"MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.",
|
||||
failures);
|
||||
AddIfBlank(
|
||||
options.RequiredGroup,
|
||||
"MxGateway:Ldap:RequiredGroup is required when LDAP login is enabled.",
|
||||
failures);
|
||||
AddIfNotPositive(options.Port, "MxGateway:Ldap:Port must be greater than zero.", failures);
|
||||
|
||||
if (!options.UseTls && !options.AllowInsecureLdap)
|
||||
{
|
||||
failures.Add("MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateWorker(WorkerOptions options, List<string> failures)
|
||||
{
|
||||
AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", failures);
|
||||
@@ -135,6 +177,14 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
||||
options.MaxPendingCommandsPerSession,
|
||||
"MxGateway:Sessions:MaxPendingCommandsPerSession must be greater than zero.",
|
||||
failures);
|
||||
AddIfNotPositive(
|
||||
options.DefaultLeaseSeconds,
|
||||
"MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.",
|
||||
failures);
|
||||
AddIfNotPositive(
|
||||
options.LeaseSweepIntervalSeconds,
|
||||
"MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.",
|
||||
failures);
|
||||
|
||||
if (options.AllowMultipleEventSubscribers)
|
||||
{
|
||||
@@ -185,6 +235,12 @@ public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
||||
failures.Add(
|
||||
$"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}.");
|
||||
}
|
||||
|
||||
if (options.MaxGrpcMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
||||
{
|
||||
failures.Add(
|
||||
$"MxGateway:Protocol:MaxGrpcMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIfBlank(string? value, string message, List<string> failures)
|
||||
|
||||
@@ -11,4 +11,6 @@ public sealed class ProtocolOptions
|
||||
/// Gets or sets the worker protocol version.
|
||||
/// </summary>
|
||||
public uint WorkerProtocolVersion { get; init; } = GatewayContractInfo.WorkerProtocolVersion;
|
||||
|
||||
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ public sealed class SessionOptions
|
||||
/// </summary>
|
||||
public int MaxPendingCommandsPerSession { get; init; } = 128;
|
||||
|
||||
public int DefaultLeaseSeconds { get; init; } = 1800;
|
||||
|
||||
public int LeaseSweepIntervalSeconds { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether multiple event subscribers are allowed per session.
|
||||
/// </summary>
|
||||
|
||||
@@ -26,14 +26,27 @@
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="galaxy">Galaxy</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="apikeys">API Keys</NavLink>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<NavLink class="nav-link" href="settings">Settings</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
<form method="post" action="@DashboardPath("/logout")" class="d-flex">
|
||||
<AntiforgeryToken />
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
|
||||
</form>
|
||||
<AuthorizeView>
|
||||
<Authorized Context="authState">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="navbar-text">@authState.User.Identity?.Name</span>
|
||||
<form method="post" action="@DashboardPath("/logout")">
|
||||
<AntiforgeryToken />
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="@DashboardPath("/login")">Sign in</a>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -190,6 +190,8 @@ else
|
||||
|
||||
private int CommandTimeoutSeconds() => GalaxyOptions.Value.CommandTimeoutSeconds;
|
||||
|
||||
private string? GalaxyConnectionStringDisplay() =>
|
||||
DashboardRedactor.Redact(GalaxyOptions.Value.ConnectionString);
|
||||
private string GalaxyConnectionStringDisplay()
|
||||
{
|
||||
return DashboardConnectionStringDisplay.GalaxyRepositoryConnectionString(GalaxyOptions.Value.ConnectionString);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,15 @@ else
|
||||
<tr><th scope="row">Auth database</th><td><code>@Snapshot.Configuration.Authentication.SqlitePath</code></td></tr>
|
||||
<tr><th scope="row">Pepper secret</th><td>@Snapshot.Configuration.Authentication.PepperSecretName</td></tr>
|
||||
<tr><th scope="row">Run migrations</th><td>@Snapshot.Configuration.Authentication.RunMigrationsOnStartup</td></tr>
|
||||
<tr><th scope="row">LDAP enabled</th><td>@Snapshot.Configuration.Ldap.Enabled</td></tr>
|
||||
<tr><th scope="row">LDAP server</th><td>@Snapshot.Configuration.Ldap.Server:@Snapshot.Configuration.Ldap.Port</td></tr>
|
||||
<tr><th scope="row">LDAP TLS</th><td>@Snapshot.Configuration.Ldap.UseTls</td></tr>
|
||||
<tr><th scope="row">LDAP search base</th><td><code>@Snapshot.Configuration.Ldap.SearchBase</code></td></tr>
|
||||
<tr><th scope="row">LDAP service account</th><td><code>@Snapshot.Configuration.Ldap.ServiceAccountDn</code></td></tr>
|
||||
<tr><th scope="row">LDAP service password</th><td>@Snapshot.Configuration.Ldap.ServiceAccountPassword</td></tr>
|
||||
<tr><th scope="row">LDAP username attribute</th><td>@Snapshot.Configuration.Ldap.UserNameAttribute</td></tr>
|
||||
<tr><th scope="row">LDAP group attribute</th><td>@Snapshot.Configuration.Ldap.GroupAttribute</td></tr>
|
||||
<tr><th scope="row">LDAP required group</th><td>@Snapshot.Configuration.Ldap.RequiredGroup</td></tr>
|
||||
<tr><th scope="row">Worker executable</th><td><code>@Snapshot.Configuration.Worker.ExecutablePath</code></td></tr>
|
||||
<tr><th scope="row">Worker architecture</th><td>@Snapshot.Configuration.Worker.RequiredArchitecture</td></tr>
|
||||
<tr><th scope="row">Startup timeout</th><td>@Snapshot.Configuration.Worker.StartupTimeoutSeconds seconds</td></tr>
|
||||
|
||||
@@ -8,5 +8,6 @@
|
||||
@using MxGateway.Server.Dashboard
|
||||
@using MxGateway.Server.Dashboard.Components.Layout
|
||||
@using MxGateway.Server.Dashboard.Components.Shared
|
||||
@using MxGateway.Server.Security.Authorization
|
||||
@using MxGateway.Server.Workers
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
|
||||
@@ -5,6 +5,7 @@ public static class DashboardAuthenticationDefaults
|
||||
public const string AuthenticationScheme = "MxGateway.Dashboard";
|
||||
public const string AuthorizationPolicy = "MxGateway.Dashboard";
|
||||
public const string ScopeClaimType = "scope";
|
||||
public const string LdapGroupClaimType = "mxgateway:ldap_group";
|
||||
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
|
||||
public const string CookieName = "__Host-MxGatewayDashboard";
|
||||
}
|
||||
|
||||
@@ -1,81 +1,258 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MxGateway.Server.Configuration;
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
using MxGateway.Server.Security.Authorization;
|
||||
using Novell.Directory.Ldap;
|
||||
|
||||
namespace MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed class DashboardAuthenticator(
|
||||
IApiKeyVerifier apiKeyVerifier,
|
||||
IOptions<GatewayOptions> options) : IDashboardAuthenticator
|
||||
IOptions<GatewayOptions> options,
|
||||
ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
|
||||
{
|
||||
private const string GenericFailureMessage = "The API key is invalid or is not authorized for dashboard access.";
|
||||
private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DashboardAuthenticationResult> AuthenticateAsync(
|
||||
string? apiKey,
|
||||
string? username,
|
||||
string? password,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (options.Value.Authentication.Mode == AuthenticationMode.Disabled)
|
||||
{
|
||||
return DashboardAuthenticationResult.Success(CreatePrincipal(new ApiKeyIdentity(
|
||||
KeyId: "authentication-disabled",
|
||||
KeyPrefix: "authentication-disabled",
|
||||
DisplayName: "Authentication Disabled",
|
||||
Scopes: new HashSet<string>([GatewayScopes.Admin], StringComparer.Ordinal))));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(apiKey))
|
||||
LdapOptions ldapOptions = options.Value.Ldap;
|
||||
if (!ldapOptions.Enabled
|
||||
|| string.IsNullOrWhiteSpace(username)
|
||||
|| string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
ApiKeyVerificationResult verificationResult = await apiKeyVerifier
|
||||
.VerifyAsync(FormatAuthorizationHeader(apiKey), cancellationToken)
|
||||
if (!ldapOptions.UseTls && !ldapOptions.AllowInsecureLdap)
|
||||
{
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
string normalizedUsername = username.Trim();
|
||||
|
||||
try
|
||||
{
|
||||
using LdapConnection connection = new();
|
||||
connection.SecureSocketLayer = ldapOptions.UseTls;
|
||||
|
||||
await Task.Run(
|
||||
() => connection.Connect(ldapOptions.Server, ldapOptions.Port),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
|
||||
LdapEntry? candidate = await SearchUserAsync(
|
||||
connection,
|
||||
ldapOptions,
|
||||
normalizedUsername,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (candidate is null)
|
||||
{
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
await Task.Run(
|
||||
() => connection.Bind(candidate.Dn, password),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
|
||||
LdapEntry? authenticatedEntry = await SearchUserAsync(
|
||||
connection,
|
||||
ldapOptions,
|
||||
normalizedUsername,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (authenticatedEntry is null)
|
||||
{
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
string displayName = ReadAttribute(authenticatedEntry, ldapOptions.DisplayNameAttribute)
|
||||
?? normalizedUsername;
|
||||
IReadOnlyList<string> groups = ReadAttributeValues(authenticatedEntry, ldapOptions.GroupAttribute);
|
||||
|
||||
if (!IsMemberOfRequiredGroup(groups, ldapOptions.RequiredGroup))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"LDAP dashboard login denied for user {User}: missing required group {RequiredGroup}.",
|
||||
normalizedUsername,
|
||||
ldapOptions.RequiredGroup);
|
||||
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
return DashboardAuthenticationResult.Success(CreatePrincipal(
|
||||
normalizedUsername,
|
||||
displayName,
|
||||
groups));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"LDAP dashboard login rejected for user {User}: result code {ResultCode}.",
|
||||
normalizedUsername,
|
||||
ex.ResultCode);
|
||||
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Unexpected LDAP dashboard login error for user {User}.", normalizedUsername);
|
||||
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string EscapeLdapFilter(string value)
|
||||
{
|
||||
StringBuilder builder = new(value.Length);
|
||||
foreach (char character in value)
|
||||
{
|
||||
builder.Append(character switch
|
||||
{
|
||||
'\\' => @"\5c",
|
||||
'*' => @"\2a",
|
||||
'(' => @"\28",
|
||||
')' => @"\29",
|
||||
'\0' => @"\00",
|
||||
_ => character.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
internal static bool IsMemberOfRequiredGroup(IEnumerable<string> groups, string requiredGroup)
|
||||
{
|
||||
string normalizedRequiredGroup = requiredGroup.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedRequiredGroup))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (string group in groups)
|
||||
{
|
||||
string normalizedGroup = group.Trim();
|
||||
if (string.Equals(normalizedGroup, normalizedRequiredGroup, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(
|
||||
ExtractFirstRdnValue(normalizedGroup),
|
||||
normalizedRequiredGroup,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static string ExtractFirstRdnValue(string distinguishedName)
|
||||
{
|
||||
int equalsIndex = distinguishedName.IndexOf('=');
|
||||
if (equalsIndex < 0)
|
||||
{
|
||||
return distinguishedName;
|
||||
}
|
||||
|
||||
int valueStart = equalsIndex + 1;
|
||||
int commaIndex = distinguishedName.IndexOf(',', valueStart);
|
||||
|
||||
return commaIndex > valueStart
|
||||
? distinguishedName[valueStart..commaIndex]
|
||||
: distinguishedName[valueStart..];
|
||||
}
|
||||
|
||||
private static Task BindServiceAccountAsync(
|
||||
LdapConnection connection,
|
||||
LdapOptions ldapOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.Run(
|
||||
() => connection.Bind(ldapOptions.ServiceAccountDn, ldapOptions.ServiceAccountPassword),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<LdapEntry?> SearchUserAsync(
|
||||
LdapConnection connection,
|
||||
LdapOptions ldapOptions,
|
||||
string username,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string filter = $"({ldapOptions.UserNameAttribute}={EscapeLdapFilter(username)})";
|
||||
ILdapSearchResults results = await Task.Run(
|
||||
() => connection.Search(
|
||||
ldapOptions.SearchBase,
|
||||
LdapConnection.ScopeSub,
|
||||
filter,
|
||||
attrs: null,
|
||||
typesOnly: false),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!verificationResult.Succeeded || verificationResult.Identity is null)
|
||||
LdapEntry? entry = null;
|
||||
while (results.HasMore())
|
||||
{
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
LdapEntry next = results.Next();
|
||||
if (entry is not null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
entry = next;
|
||||
}
|
||||
|
||||
if (options.Value.Dashboard.RequireAdminScope
|
||||
&& !verificationResult.Identity.Scopes.Contains(GatewayScopes.Admin))
|
||||
{
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
return DashboardAuthenticationResult.Success(CreatePrincipal(verificationResult.Identity));
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static string FormatAuthorizationHeader(string apiKey)
|
||||
private static string? ReadAttribute(LdapEntry entry, string attributeName)
|
||||
{
|
||||
string trimmedApiKey = apiKey.Trim();
|
||||
|
||||
return trimmedApiKey.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
|
||||
? trimmedApiKey
|
||||
: $"Bearer {trimmedApiKey}";
|
||||
return ReadLdapAttribute(entry, attributeName)?.StringValue;
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(ApiKeyIdentity identity)
|
||||
private static IReadOnlyList<string> ReadAttributeValues(LdapEntry entry, string attributeName)
|
||||
{
|
||||
LdapAttribute? attribute = ReadLdapAttribute(entry, attributeName);
|
||||
return attribute?.StringValueArray ?? [];
|
||||
}
|
||||
|
||||
private static LdapAttribute? ReadLdapAttribute(LdapEntry entry, string attributeName)
|
||||
{
|
||||
return entry.GetAttribute(attributeName)
|
||||
?? entry.GetAttribute(attributeName.ToLowerInvariant())
|
||||
?? entry.GetAttribute(attributeName.ToUpperInvariant());
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(
|
||||
string username,
|
||||
string displayName,
|
||||
IEnumerable<string> groups)
|
||||
{
|
||||
List<Claim> claims =
|
||||
[
|
||||
new Claim(ClaimTypes.NameIdentifier, identity.KeyId),
|
||||
new Claim(ClaimTypes.Name, identity.DisplayName),
|
||||
new Claim(DashboardAuthenticationDefaults.KeyPrefixClaimType, identity.KeyPrefix)
|
||||
new Claim(ClaimTypes.NameIdentifier, username),
|
||||
new Claim(ClaimTypes.Name, displayName)
|
||||
];
|
||||
|
||||
claims.AddRange(identity.Scopes.Select(scope => new Claim(
|
||||
DashboardAuthenticationDefaults.ScopeClaimType,
|
||||
scope)));
|
||||
claims.AddRange(groups.Select(group => new Claim(
|
||||
DashboardAuthenticationDefaults.LdapGroupClaimType,
|
||||
group)));
|
||||
|
||||
ClaimsIdentity claimsIdentity = new(
|
||||
claims,
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||
ClaimTypes.Name,
|
||||
DashboardAuthenticationDefaults.ScopeClaimType);
|
||||
DashboardAuthenticationDefaults.LdapGroupClaimType);
|
||||
|
||||
return new ClaimsPrincipal(claimsIdentity);
|
||||
}
|
||||
|
||||
@@ -43,18 +43,17 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
dashboard.MapPost(
|
||||
"/logout",
|
||||
(HttpContext httpContext, IAntiforgery antiforgery) => PostLogoutAsync(httpContext, antiforgery, pathBase))
|
||||
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy)
|
||||
.AllowAnonymous()
|
||||
.WithName("DashboardLogout");
|
||||
|
||||
dashboard.MapGet("/denied", () => Results.Content(
|
||||
RenderPage("Access denied", "<p>The signed-in API key is not authorized for dashboard access.</p>"),
|
||||
RenderPage("Access denied", "<p>The signed-in user is not authorized for dashboard access.</p>"),
|
||||
"text/html"))
|
||||
.AllowAnonymous()
|
||||
.WithName("DashboardAccessDenied");
|
||||
|
||||
dashboard.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode()
|
||||
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy);
|
||||
.AddInteractiveServerRenderMode();
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
@@ -89,7 +88,10 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
pathBase);
|
||||
|
||||
DashboardAuthenticationResult result = await authenticator
|
||||
.AuthenticateAsync(form["apiKey"].ToString(), httpContext.RequestAborted)
|
||||
.AuthenticateAsync(
|
||||
form["username"].ToString(),
|
||||
form["password"].ToString(),
|
||||
httpContext.RequestAborted)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!result.Succeeded || result.Principal is null)
|
||||
@@ -131,7 +133,7 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
string requestToken = tokens.RequestToken ?? string.Empty;
|
||||
string alert = string.IsNullOrWhiteSpace(failureMessage)
|
||||
? string.Empty
|
||||
: $"<p role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
|
||||
: $"<p class=\"alert alert-danger\" role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
|
||||
|
||||
string body = $"""
|
||||
<section class="dashboard-login">
|
||||
@@ -141,8 +143,12 @@ public static class DashboardEndpointRouteBuilderExtensions
|
||||
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
|
||||
<input name="returnUrl" type="hidden" value="{HtmlEncoder.Default.Encode(returnUrl)}" />
|
||||
<div class="mb-3">
|
||||
<label for="apiKey" class="form-label">API key</label>
|
||||
<input id="apiKey" name="apiKey" type="password" autocomplete="off" class="form-control" />
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input id="username" name="username" type="text" autocomplete="username" class="form-control" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" class="form-control" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Sign in</button>
|
||||
</div>
|
||||
|
||||
@@ -2,101 +2,11 @@ using MxGateway.Server.Galaxy;
|
||||
|
||||
namespace MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Projects a <see cref="GalaxyHierarchyCacheEntry"/> into a
|
||||
/// <see cref="DashboardGalaxySummary"/> for the Blazor pages. Top-templates and
|
||||
/// per-category breakdowns are computed here rather than stored on the cache so the
|
||||
/// Galaxy namespace stays free of dashboard-presentation concepts.
|
||||
/// </summary>
|
||||
/// <summary>Projects Galaxy Repository cache entries to dashboard presentation format.</summary>
|
||||
/// <summary>Projects the precomputed Galaxy cache dashboard summary.</summary>
|
||||
internal static class DashboardGalaxyProjector
|
||||
{
|
||||
private const int TopTemplatesLimit = 10;
|
||||
|
||||
private static readonly IReadOnlyDictionary<int, string> CategoryNamesById = new Dictionary<int, string>
|
||||
{
|
||||
[1] = "WinPlatform",
|
||||
[3] = "AppEngine",
|
||||
[4] = "InTouchViewApp",
|
||||
[10] = "UserDefined",
|
||||
[11] = "FieldReference",
|
||||
[13] = "Area",
|
||||
[17] = "DIObject",
|
||||
[24] = "DDESuiteLinkClient",
|
||||
[26] = "OPCClient",
|
||||
};
|
||||
|
||||
/// <summary>Projects a Galaxy Repository cache entry to a dashboard summary.</summary>
|
||||
/// <param name="entry">Galaxy cache entry to project.</param>
|
||||
/// <returns>Dashboard-formatted Galaxy summary.</returns>
|
||||
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
|
||||
{
|
||||
DashboardGalaxyStatus status = entry.Status switch
|
||||
{
|
||||
GalaxyCacheStatus.Healthy => DashboardGalaxyStatus.Healthy,
|
||||
GalaxyCacheStatus.Stale => DashboardGalaxyStatus.Stale,
|
||||
GalaxyCacheStatus.Unavailable => DashboardGalaxyStatus.Unavailable,
|
||||
_ => DashboardGalaxyStatus.Unknown,
|
||||
};
|
||||
|
||||
IReadOnlyList<DashboardGalaxyTemplateUsage> topTemplates;
|
||||
IReadOnlyList<DashboardGalaxyCategoryCount> objectCategories;
|
||||
|
||||
if (entry.Hierarchy.Count == 0)
|
||||
{
|
||||
topTemplates = Array.Empty<DashboardGalaxyTemplateUsage>();
|
||||
objectCategories = Array.Empty<DashboardGalaxyCategoryCount>();
|
||||
}
|
||||
else
|
||||
{
|
||||
Dictionary<int, int> objectsByCategory = new();
|
||||
Dictionary<string, int> templateUsage = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (GalaxyHierarchyRow row in entry.Hierarchy)
|
||||
{
|
||||
objectsByCategory.TryGetValue(row.CategoryId, out int categoryCount);
|
||||
objectsByCategory[row.CategoryId] = categoryCount + 1;
|
||||
|
||||
if (row.TemplateChain.Count > 0)
|
||||
{
|
||||
string immediate = row.TemplateChain[0];
|
||||
if (!string.IsNullOrWhiteSpace(immediate))
|
||||
{
|
||||
templateUsage.TryGetValue(immediate, out int templateCount);
|
||||
templateUsage[immediate] = templateCount + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
topTemplates = templateUsage
|
||||
.OrderByDescending(entry => entry.Value)
|
||||
.ThenBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(TopTemplatesLimit)
|
||||
.Select(entry => new DashboardGalaxyTemplateUsage(entry.Key, entry.Value))
|
||||
.ToArray();
|
||||
|
||||
objectCategories = objectsByCategory
|
||||
.OrderByDescending(entry => entry.Value)
|
||||
.ThenBy(entry => entry.Key)
|
||||
.Select(entry => new DashboardGalaxyCategoryCount(
|
||||
entry.Key,
|
||||
CategoryNamesById.TryGetValue(entry.Key, out string? name) ? name : $"Category {entry.Key}",
|
||||
entry.Value))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return new DashboardGalaxySummary(
|
||||
Status: status,
|
||||
LastQueriedAt: entry.LastQueriedAt,
|
||||
LastSuccessAt: entry.LastSuccessAt,
|
||||
LastDeployTime: entry.LastDeployTime,
|
||||
LastError: entry.LastError,
|
||||
ObjectCount: entry.ObjectCount,
|
||||
AreaCount: entry.AreaCount,
|
||||
AttributeCount: entry.AttributeCount,
|
||||
HistorizedAttributeCount: entry.HistorizedAttributeCount,
|
||||
AlarmAttributeCount: entry.AlarmAttributeCount,
|
||||
TopTemplates: topTemplates,
|
||||
ObjectCategories: objectCategories);
|
||||
return entry.DashboardSummary;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ public static class DashboardServiceCollectionExtensions
|
||||
{
|
||||
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
|
||||
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
||||
services.AddSingleton<DashboardApiKeyAuthorization>();
|
||||
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddAntiforgery();
|
||||
services.AddCascadingAuthenticationState();
|
||||
|
||||
@@ -12,5 +12,6 @@ public sealed record DashboardSnapshot(
|
||||
IReadOnlyList<DashboardWorkerSummary> Workers,
|
||||
IReadOnlyList<DashboardMetricSummary> Metrics,
|
||||
IReadOnlyList<DashboardFaultSummary> Faults,
|
||||
IReadOnlyList<DashboardApiKeySummary> ApiKeys,
|
||||
EffectiveGatewayConfiguration Configuration,
|
||||
DashboardGalaxySummary Galaxy);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MxGateway.Server.Configuration;
|
||||
using MxGateway.Server.Galaxy;
|
||||
using MxGateway.Server.Metrics;
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
using MxGateway.Server.Sessions;
|
||||
using MxGateway.Server.Workers;
|
||||
|
||||
@@ -16,11 +19,16 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
||||
private readonly GatewayMetrics _metrics;
|
||||
private readonly IGatewayConfigurationProvider _configurationProvider;
|
||||
private readonly IGalaxyHierarchyCache _galaxyHierarchyCache;
|
||||
private readonly IApiKeyAdminStore _apiKeyAdminStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly DateTimeOffset _gatewayStartedAt;
|
||||
private readonly TimeSpan _snapshotInterval;
|
||||
private readonly TimeSpan _apiKeySummaryRefreshTimeout = TimeSpan.FromSeconds(2);
|
||||
private readonly int _recentFaultLimit;
|
||||
private readonly int _recentSessionLimit;
|
||||
private readonly ILogger<DashboardSnapshotService> _logger;
|
||||
private readonly SemaphoreSlim _apiKeySummaryRefreshGate = new(1, 1);
|
||||
private IReadOnlyList<DashboardApiKeySummary> _apiKeySummaries = Array.Empty<DashboardApiKeySummary>();
|
||||
|
||||
/// <summary>Initializes a new instance of the DashboardSnapshotService class.</summary>
|
||||
/// <param name="sessionRegistry">Registry of active gateway sessions.</param>
|
||||
@@ -34,13 +42,16 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
||||
GatewayMetrics metrics,
|
||||
IGatewayConfigurationProvider configurationProvider,
|
||||
IGalaxyHierarchyCache galaxyHierarchyCache,
|
||||
IApiKeyAdminStore apiKeyAdminStore,
|
||||
IOptions<GatewayOptions> options,
|
||||
TimeProvider? timeProvider = null)
|
||||
TimeProvider? timeProvider = null,
|
||||
ILogger<DashboardSnapshotService>? logger = null)
|
||||
{
|
||||
_sessionRegistry = sessionRegistry ?? throw new ArgumentNullException(nameof(sessionRegistry));
|
||||
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
_configurationProvider = configurationProvider ?? throw new ArgumentNullException(nameof(configurationProvider));
|
||||
_galaxyHierarchyCache = galaxyHierarchyCache ?? throw new ArgumentNullException(nameof(galaxyHierarchyCache));
|
||||
_apiKeyAdminStore = apiKeyAdminStore ?? throw new ArgumentNullException(nameof(apiKeyAdminStore));
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
@@ -48,6 +59,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
||||
_snapshotInterval = TimeSpan.FromMilliseconds(options.Value.Dashboard.SnapshotIntervalMilliseconds);
|
||||
_recentFaultLimit = options.Value.Dashboard.RecentFaultLimit;
|
||||
_recentSessionLimit = options.Value.Dashboard.RecentSessionLimit;
|
||||
_logger = logger ?? NullLogger<DashboardSnapshotService>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -80,6 +92,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
||||
Workers: workerSummaries,
|
||||
Metrics: CreateMetricSummaries(metricsSnapshot),
|
||||
Faults: CreateFaultSummaries(sessions, generatedAt),
|
||||
ApiKeys: Volatile.Read(ref _apiKeySummaries),
|
||||
Configuration: _configurationProvider.GetEffectiveConfiguration(),
|
||||
Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current));
|
||||
}
|
||||
@@ -97,6 +110,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
||||
yield break;
|
||||
}
|
||||
|
||||
await RefreshApiKeySummariesAsync(cancellationToken).ConfigureAwait(false);
|
||||
yield return GetSnapshot();
|
||||
|
||||
using PeriodicTimer timer = new(_snapshotInterval, _timeProvider);
|
||||
@@ -117,6 +131,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
||||
yield break;
|
||||
}
|
||||
|
||||
await RefreshApiKeySummariesAsync(cancellationToken).ConfigureAwait(false);
|
||||
yield return GetSnapshot();
|
||||
}
|
||||
}
|
||||
@@ -208,6 +223,51 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task RefreshApiKeySummariesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await _apiKeySummaryRefreshGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(_apiKeySummaryRefreshTimeout);
|
||||
IReadOnlyList<DashboardApiKeySummary> summaries = (await _apiKeyAdminStore.ListAsync(timeout.Token)
|
||||
.ConfigureAwait(false))
|
||||
.Select(key => new DashboardApiKeySummary(
|
||||
KeyId: key.KeyId,
|
||||
DisplayName: key.DisplayName,
|
||||
Scopes: key.Scopes,
|
||||
Constraints: key.Constraints,
|
||||
CreatedUtc: key.CreatedUtc,
|
||||
LastUsedUtc: key.LastUsedUtc,
|
||||
RevokedUtc: key.RevokedUtc))
|
||||
.ToArray();
|
||||
|
||||
Volatile.Write(ref _apiKeySummaries, summaries);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Timed out refreshing dashboard API key summaries after {Timeout}.",
|
||||
_apiKeySummaryRefreshTimeout);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_logger.LogWarning("Failed to refresh dashboard API key summaries.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_apiKeySummaryRefreshGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasFault(GatewaySession session)
|
||||
{
|
||||
return session.State == MxGateway.Contracts.Proto.SessionState.Faulted
|
||||
|
||||
@@ -11,6 +11,7 @@ public interface IDashboardAuthenticator
|
||||
/// <param name="apiKey">The API key to authenticate.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
Task<DashboardAuthenticationResult> AuthenticateAsync(
|
||||
string? apiKey,
|
||||
string? username,
|
||||
string? password,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using Google.Protobuf.WellKnownTypes;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
using MxGateway.Server.Dashboard;
|
||||
using MxGateway.Server.Grpc;
|
||||
|
||||
namespace MxGateway.Server.Galaxy;
|
||||
@@ -49,7 +50,16 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
||||
{
|
||||
GalaxyHierarchyCacheEntry snapshot = Volatile.Read(ref _current);
|
||||
GalaxyCacheStatus projected = ProjectStatus(snapshot);
|
||||
return projected == snapshot.Status ? snapshot : snapshot with { Status = projected };
|
||||
return projected == snapshot.Status
|
||||
? snapshot
|
||||
: snapshot with
|
||||
{
|
||||
Status = projected,
|
||||
DashboardSummary = snapshot.DashboardSummary with
|
||||
{
|
||||
Status = MapDashboardStatus(projected),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +111,14 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
||||
LastQueriedAt = queriedAt,
|
||||
LastSuccessAt = queriedAt,
|
||||
LastError = null,
|
||||
DashboardSummary = previous.DashboardSummary with
|
||||
{
|
||||
Status = DashboardGalaxyStatus.Healthy,
|
||||
LastQueriedAt = queriedAt,
|
||||
LastSuccessAt = queriedAt,
|
||||
LastDeployTime = deployTime,
|
||||
LastError = null,
|
||||
},
|
||||
};
|
||||
Volatile.Write(ref _current, refreshed);
|
||||
_firstLoad.TrySetResult();
|
||||
@@ -113,11 +131,24 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
||||
|
||||
List<GalaxyHierarchyRow> hierarchy = hierarchyTask.Result;
|
||||
List<GalaxyAttributeRow> attributes = attributesTask.Result;
|
||||
DiscoverHierarchyReply reply = BuildReply(hierarchy, attributes);
|
||||
IReadOnlyList<GalaxyObject> objects = BuildObjects(hierarchy, attributes);
|
||||
GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build(objects);
|
||||
|
||||
int areaCount = hierarchy.Count(row => row.IsArea);
|
||||
int historized = attributes.Count(row => row.IsHistorized);
|
||||
int alarms = attributes.Count(row => row.IsAlarm);
|
||||
DashboardGalaxySummary dashboardSummary = BuildDashboardSummary(
|
||||
status: GalaxyCacheStatus.Healthy,
|
||||
lastQueriedAt: queriedAt,
|
||||
lastSuccessAt: queriedAt,
|
||||
lastDeployTime: deployTime,
|
||||
lastError: null,
|
||||
hierarchy: hierarchy,
|
||||
objectCount: hierarchy.Count,
|
||||
areaCount: areaCount,
|
||||
attributeCount: attributes.Count,
|
||||
historizedAttributeCount: historized,
|
||||
alarmAttributeCount: alarms);
|
||||
|
||||
long nextSequence = previous.Sequence + 1;
|
||||
GalaxyHierarchyCacheEntry next = new(
|
||||
@@ -127,9 +158,9 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
||||
LastSuccessAt: queriedAt,
|
||||
LastDeployTime: deployTime,
|
||||
LastError: null,
|
||||
Hierarchy: hierarchy,
|
||||
Attributes: attributes,
|
||||
Reply: reply,
|
||||
Objects: objects,
|
||||
Index: index,
|
||||
DashboardSummary: dashboardSummary,
|
||||
ObjectCount: hierarchy.Count,
|
||||
AreaCount: areaCount,
|
||||
AttributeCount: attributes.Count,
|
||||
@@ -158,13 +189,19 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
||||
Status = previous.HasData ? GalaxyCacheStatus.Stale : GalaxyCacheStatus.Unavailable,
|
||||
LastQueriedAt = queriedAt,
|
||||
LastError = exception.Message,
|
||||
DashboardSummary = previous.DashboardSummary with
|
||||
{
|
||||
Status = MapDashboardStatus(previous.HasData ? GalaxyCacheStatus.Stale : GalaxyCacheStatus.Unavailable),
|
||||
LastQueriedAt = queriedAt,
|
||||
LastError = exception.Message,
|
||||
},
|
||||
};
|
||||
Volatile.Write(ref _current, failed);
|
||||
_firstLoad.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
private static DiscoverHierarchyReply BuildReply(
|
||||
private static IReadOnlyList<GalaxyObject> BuildObjects(
|
||||
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||
IReadOnlyList<GalaxyAttributeRow> attributes)
|
||||
{
|
||||
@@ -172,14 +209,110 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
||||
.GroupBy(a => a.GobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
DiscoverHierarchyReply reply = new();
|
||||
List<GalaxyObject> objects = new(hierarchy.Count);
|
||||
foreach (GalaxyHierarchyRow row in hierarchy)
|
||||
{
|
||||
reply.Objects.Add(GalaxyProtoMapper.MapObject(row, attributesByGobjectId));
|
||||
objects.Add(GalaxyProtoMapper.MapObject(row, attributesByGobjectId));
|
||||
}
|
||||
return reply;
|
||||
return objects;
|
||||
}
|
||||
|
||||
private static DashboardGalaxySummary BuildDashboardSummary(
|
||||
GalaxyCacheStatus status,
|
||||
DateTimeOffset? lastQueriedAt,
|
||||
DateTimeOffset? lastSuccessAt,
|
||||
DateTimeOffset? lastDeployTime,
|
||||
string? lastError,
|
||||
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||
int objectCount,
|
||||
int areaCount,
|
||||
int attributeCount,
|
||||
int historizedAttributeCount,
|
||||
int alarmAttributeCount)
|
||||
{
|
||||
IReadOnlyList<DashboardGalaxyTemplateUsage> topTemplates;
|
||||
IReadOnlyList<DashboardGalaxyCategoryCount> objectCategories;
|
||||
|
||||
if (hierarchy.Count == 0)
|
||||
{
|
||||
topTemplates = Array.Empty<DashboardGalaxyTemplateUsage>();
|
||||
objectCategories = Array.Empty<DashboardGalaxyCategoryCount>();
|
||||
}
|
||||
else
|
||||
{
|
||||
Dictionary<int, int> objectsByCategory = new();
|
||||
Dictionary<string, int> templateUsage = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (GalaxyHierarchyRow row in hierarchy)
|
||||
{
|
||||
objectsByCategory.TryGetValue(row.CategoryId, out int categoryCount);
|
||||
objectsByCategory[row.CategoryId] = categoryCount + 1;
|
||||
|
||||
if (row.TemplateChain.Count > 0)
|
||||
{
|
||||
string immediate = row.TemplateChain[0];
|
||||
if (!string.IsNullOrWhiteSpace(immediate))
|
||||
{
|
||||
templateUsage.TryGetValue(immediate, out int templateCount);
|
||||
templateUsage[immediate] = templateCount + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
topTemplates = templateUsage
|
||||
.OrderByDescending(entry => entry.Value)
|
||||
.ThenBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(10)
|
||||
.Select(entry => new DashboardGalaxyTemplateUsage(entry.Key, entry.Value))
|
||||
.ToArray();
|
||||
|
||||
objectCategories = objectsByCategory
|
||||
.OrderByDescending(entry => entry.Value)
|
||||
.ThenBy(entry => entry.Key)
|
||||
.Select(entry => new DashboardGalaxyCategoryCount(
|
||||
entry.Key,
|
||||
ResolveCategoryName(entry.Key),
|
||||
entry.Value))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return new DashboardGalaxySummary(
|
||||
Status: MapDashboardStatus(status),
|
||||
LastQueriedAt: lastQueriedAt,
|
||||
LastSuccessAt: lastSuccessAt,
|
||||
LastDeployTime: lastDeployTime,
|
||||
LastError: lastError,
|
||||
ObjectCount: objectCount,
|
||||
AreaCount: areaCount,
|
||||
AttributeCount: attributeCount,
|
||||
HistorizedAttributeCount: historizedAttributeCount,
|
||||
AlarmAttributeCount: alarmAttributeCount,
|
||||
TopTemplates: topTemplates,
|
||||
ObjectCategories: objectCategories);
|
||||
}
|
||||
|
||||
private static DashboardGalaxyStatus MapDashboardStatus(GalaxyCacheStatus status) => status switch
|
||||
{
|
||||
GalaxyCacheStatus.Healthy => DashboardGalaxyStatus.Healthy,
|
||||
GalaxyCacheStatus.Stale => DashboardGalaxyStatus.Stale,
|
||||
GalaxyCacheStatus.Unavailable => DashboardGalaxyStatus.Unavailable,
|
||||
_ => DashboardGalaxyStatus.Unknown,
|
||||
};
|
||||
|
||||
private static string ResolveCategoryName(int categoryId) => categoryId switch
|
||||
{
|
||||
1 => "WinPlatform",
|
||||
3 => "AppEngine",
|
||||
4 => "InTouchViewApp",
|
||||
10 => "UserDefined",
|
||||
11 => "FieldReference",
|
||||
13 => "Area",
|
||||
17 => "DIObject",
|
||||
24 => "DDESuiteLinkClient",
|
||||
26 => "OPCClient",
|
||||
_ => $"Category {categoryId}",
|
||||
};
|
||||
|
||||
private GalaxyCacheStatus ProjectStatus(GalaxyHierarchyCacheEntry snapshot)
|
||||
{
|
||||
if (snapshot.Status is GalaxyCacheStatus.Unknown or GalaxyCacheStatus.Unavailable)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
using MxGateway.Server.Dashboard;
|
||||
|
||||
namespace MxGateway.Server.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable snapshot of the Galaxy Repository browse data held by
|
||||
/// <see cref="GalaxyHierarchyCache"/>. Multiple gRPC clients share the same instance —
|
||||
/// the materialized <see cref="Reply"/> is produced once per refresh and reused.
|
||||
/// <see cref="GalaxyHierarchyCache"/>. Multiple gRPC clients share the same
|
||||
/// materialized object list and precomputed dashboard projection.
|
||||
/// </summary>
|
||||
public sealed record GalaxyHierarchyCacheEntry(
|
||||
GalaxyCacheStatus Status,
|
||||
@@ -14,9 +15,9 @@ public sealed record GalaxyHierarchyCacheEntry(
|
||||
DateTimeOffset? LastSuccessAt,
|
||||
DateTimeOffset? LastDeployTime,
|
||||
string? LastError,
|
||||
IReadOnlyList<GalaxyHierarchyRow> Hierarchy,
|
||||
IReadOnlyList<GalaxyAttributeRow> Attributes,
|
||||
DiscoverHierarchyReply? Reply,
|
||||
IReadOnlyList<GalaxyObject> Objects,
|
||||
GalaxyHierarchyIndex Index,
|
||||
DashboardGalaxySummary DashboardSummary,
|
||||
int ObjectCount,
|
||||
int AreaCount,
|
||||
int AttributeCount,
|
||||
@@ -31,9 +32,9 @@ public sealed record GalaxyHierarchyCacheEntry(
|
||||
LastSuccessAt: null,
|
||||
LastDeployTime: null,
|
||||
LastError: null,
|
||||
Hierarchy: Array.Empty<GalaxyHierarchyRow>(),
|
||||
Attributes: Array.Empty<GalaxyAttributeRow>(),
|
||||
Reply: null,
|
||||
Objects: Array.Empty<GalaxyObject>(),
|
||||
Index: GalaxyHierarchyIndex.Empty,
|
||||
DashboardSummary: DashboardGalaxySummary.Unknown,
|
||||
ObjectCount: 0,
|
||||
AreaCount: 0,
|
||||
AttributeCount: 0,
|
||||
|
||||
@@ -3,6 +3,8 @@ using Grpc.Core;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using MxGateway.Contracts.Proto.Galaxy;
|
||||
using GalaxyDb = MxGateway.Server.Galaxy;
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
using MxGateway.Server.Security.Authorization;
|
||||
using ProtoGalaxyRepository = MxGateway.Contracts.Proto.Galaxy.GalaxyRepository;
|
||||
|
||||
namespace MxGateway.Server.Grpc;
|
||||
@@ -18,9 +20,12 @@ public sealed class GalaxyRepositoryGrpcService(
|
||||
GalaxyDb.GalaxyRepository repository,
|
||||
GalaxyDb.IGalaxyHierarchyCache cache,
|
||||
GalaxyDb.IGalaxyDeployNotifier notifier,
|
||||
IGatewayRequestIdentityAccessor identityAccessor,
|
||||
ILogger<GalaxyRepositoryGrpcService> logger) : ProtoGalaxyRepository.GalaxyRepositoryBase
|
||||
{
|
||||
private static readonly TimeSpan FirstLoadWaitBudget = TimeSpan.FromSeconds(5);
|
||||
private const int DefaultDiscoverPageSize = 1000;
|
||||
private const int MaxDiscoverPageSize = 5000;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<TestConnectionReply> TestConnection(
|
||||
@@ -62,16 +67,44 @@ public sealed class GalaxyRepositoryGrpcService(
|
||||
await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false);
|
||||
GalaxyDb.GalaxyHierarchyCacheEntry entry = cache.Current;
|
||||
|
||||
if (!entry.HasData || entry.Reply is null)
|
||||
if (!entry.HasData)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.Unavailable,
|
||||
ResolveUnavailableMessage(entry)));
|
||||
}
|
||||
|
||||
// Same materialized reply is shared across all clients — gRPC serialization is
|
||||
// read-only and the entry is replaced atomically on the next refresh.
|
||||
return entry.Reply;
|
||||
int pageSize = ResolvePageSize(request.PageSize);
|
||||
IReadOnlyList<string> browseSubtrees = ResolveBrowseSubtrees();
|
||||
string filterSignature = GalaxyDb.GalaxyHierarchyProjector.ComputeFilterSignature(request, browseSubtrees);
|
||||
PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature);
|
||||
GalaxyDb.GalaxyHierarchyQueryResult query = GalaxyDb.GalaxyHierarchyProjector.Project(
|
||||
entry,
|
||||
request,
|
||||
browseSubtrees,
|
||||
pageToken.Offset,
|
||||
pageSize);
|
||||
int offset = pageToken.Offset;
|
||||
if (offset > query.TotalObjectCount)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"DiscoverHierarchy page_token is outside the current hierarchy."));
|
||||
}
|
||||
|
||||
DiscoverHierarchyReply reply = new()
|
||||
{
|
||||
TotalObjectCount = query.TotalObjectCount,
|
||||
};
|
||||
reply.Objects.Add(query.Objects);
|
||||
|
||||
int nextOffset = offset + query.Objects.Count;
|
||||
if (nextOffset < query.TotalObjectCount)
|
||||
{
|
||||
reply.NextPageToken = FormatPageToken(entry.Sequence, query.FilterSignature, nextOffset);
|
||||
}
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -96,7 +129,7 @@ public sealed class GalaxyRepositoryGrpcService(
|
||||
}
|
||||
lastSeen = null;
|
||||
|
||||
await responseStream.WriteAsync(MapDeployEvent(info), context.CancellationToken).ConfigureAwait(false);
|
||||
await responseStream.WriteAsync(MapDeployEvent(info, ResolveBrowseSubtrees()), context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,14 +157,28 @@ public sealed class GalaxyRepositoryGrpcService(
|
||||
}
|
||||
}
|
||||
|
||||
private static DeployEvent MapDeployEvent(GalaxyDb.GalaxyDeployEventInfo info)
|
||||
private DeployEvent MapDeployEvent(
|
||||
GalaxyDb.GalaxyDeployEventInfo info,
|
||||
IReadOnlyList<string> browseSubtrees)
|
||||
{
|
||||
int objectCount = info.ObjectCount;
|
||||
int attributeCount = info.AttributeCount;
|
||||
if (browseSubtrees.Count > 0 && cache.Current.HasData)
|
||||
{
|
||||
GalaxyDb.GalaxyHierarchyQueryResult scoped = GalaxyDb.GalaxyHierarchyProjector.Project(
|
||||
cache.Current,
|
||||
new DiscoverHierarchyRequest(),
|
||||
browseSubtrees);
|
||||
objectCount = scoped.TotalObjectCount;
|
||||
attributeCount = scoped.Objects.Sum(obj => obj.Attributes.Count);
|
||||
}
|
||||
|
||||
DeployEvent ev = new()
|
||||
{
|
||||
Sequence = (ulong)info.Sequence,
|
||||
ObservedAt = Timestamp.FromDateTimeOffset(info.ObservedAt),
|
||||
ObjectCount = info.ObjectCount,
|
||||
AttributeCount = info.AttributeCount,
|
||||
ObjectCount = objectCount,
|
||||
AttributeCount = attributeCount,
|
||||
TimeOfLastDeployPresent = info.TimeOfLastDeploy.HasValue,
|
||||
};
|
||||
if (info.TimeOfLastDeploy.HasValue)
|
||||
@@ -148,6 +195,80 @@ public sealed class GalaxyRepositoryGrpcService(
|
||||
_ => "Galaxy cache has no data available.",
|
||||
};
|
||||
|
||||
private static int ResolvePageSize(int requestedPageSize)
|
||||
{
|
||||
if (requestedPageSize < 0)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"DiscoverHierarchy page_size must be greater than zero when provided."));
|
||||
}
|
||||
|
||||
int pageSize = requestedPageSize == 0 ? DefaultDiscoverPageSize : requestedPageSize;
|
||||
return Math.Min(pageSize, MaxDiscoverPageSize);
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> ResolveBrowseSubtrees()
|
||||
{
|
||||
ApiKeyConstraints constraints = identityAccessor.Current?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
|
||||
return constraints.BrowseSubtrees;
|
||||
}
|
||||
|
||||
private static string FormatPageToken(long sequence, string filterSignature, int offset)
|
||||
{
|
||||
return string.Concat(
|
||||
sequence.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
":",
|
||||
filterSignature,
|
||||
":",
|
||||
offset.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
private static PageToken ParsePageToken(string pageToken, long currentSequence, string currentFilterSignature)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pageToken))
|
||||
{
|
||||
return new PageToken(currentSequence, currentFilterSignature, Offset: 0);
|
||||
}
|
||||
|
||||
string[] parts = pageToken.Split(':', count: 3);
|
||||
if (parts.Length != 3
|
||||
|| !long.TryParse(
|
||||
parts[0],
|
||||
System.Globalization.NumberStyles.None,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out long sequence)
|
||||
|| !int.TryParse(
|
||||
parts[2],
|
||||
System.Globalization.NumberStyles.None,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out int offset)
|
||||
|| offset < 0)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"DiscoverHierarchy page_token is invalid."));
|
||||
}
|
||||
|
||||
if (sequence != currentSequence)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"DiscoverHierarchy page_token is stale."));
|
||||
}
|
||||
|
||||
if (!string.Equals(parts[1], currentFilterSignature, StringComparison.Ordinal))
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"DiscoverHierarchy page_token does not match the current filters."));
|
||||
}
|
||||
|
||||
return new PageToken(sequence, parts[1], offset);
|
||||
}
|
||||
|
||||
private sealed record PageToken(long Sequence, string FilterSignature, int Offset);
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage(
|
||||
"Style",
|
||||
"IDE0051:Remove unused private members",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using System.Diagnostics;
|
||||
using Grpc.Core;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using MxGateway.Contracts;
|
||||
using MxGateway.Contracts.Proto;
|
||||
using MxGateway.Server.Metrics;
|
||||
using MxGateway.Server.Security.Authentication;
|
||||
using MxGateway.Server.Security.Authorization;
|
||||
using MxGateway.Server.Sessions;
|
||||
using MxGateway.Server.Workers;
|
||||
@@ -13,6 +15,7 @@ namespace MxGateway.Server.Grpc;
|
||||
public sealed class MxAccessGatewayService(
|
||||
ISessionManager sessionManager,
|
||||
IGatewayRequestIdentityAccessor identityAccessor,
|
||||
IConstraintEnforcer constraintEnforcer,
|
||||
MxAccessGrpcRequestValidator requestValidator,
|
||||
MxAccessGrpcMapper mapper,
|
||||
IEventStreamService eventStreamService,
|
||||
@@ -91,12 +94,35 @@ public sealed class MxAccessGatewayService(
|
||||
try
|
||||
{
|
||||
requestValidator.ValidateInvoke(request);
|
||||
WorkerCommand workerCommand = mapper.MapCommand(request);
|
||||
GatewaySession session = ResolveSession(request.SessionId);
|
||||
MxCommand command = request.Command;
|
||||
BulkConstraintPlan? bulkConstraintPlan = await ApplyConstraintsAsync(
|
||||
session,
|
||||
command,
|
||||
context.CancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
MxCommand commandToInvoke = bulkConstraintPlan?.Command ?? command;
|
||||
if (bulkConstraintPlan is { HasAllowedItems: false })
|
||||
{
|
||||
return CreateDeniedBulkReply(request, bulkConstraintPlan);
|
||||
}
|
||||
|
||||
MxCommandRequest invokeRequest = request.Clone();
|
||||
invokeRequest.Command = commandToInvoke;
|
||||
WorkerCommand workerCommand = mapper.MapCommand(invokeRequest);
|
||||
WorkerCommandReply workerReply = await sessionManager
|
||||
.InvokeAsync(request.SessionId, workerCommand, context.CancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return mapper.MapCommandReply(workerReply);
|
||||
MxCommandReply publicReply = mapper.MapCommandReply(workerReply);
|
||||
if (bulkConstraintPlan is not null)
|
||||
{
|
||||
publicReply = MergeDeniedBulkResults(publicReply, command.Kind, bulkConstraintPlan);
|
||||
}
|
||||
|
||||
session.TrackCommandReply(commandToInvoke, publicReply);
|
||||
return publicReply;
|
||||
}
|
||||
catch (Exception exception) when (exception is not RpcException)
|
||||
{
|
||||
@@ -134,6 +160,323 @@ public sealed class MxAccessGatewayService(
|
||||
return identityAccessor.Current?.DisplayName ?? identityAccessor.Current?.KeyId;
|
||||
}
|
||||
|
||||
private GatewaySession ResolveSession(string sessionId)
|
||||
{
|
||||
if (!sessionManager.TryGetSession(sessionId, out GatewaySession session))
|
||||
{
|
||||
throw new SessionManagerException(
|
||||
SessionManagerErrorCode.SessionNotFound,
|
||||
$"Session {sessionId} was not found.");
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
private async Task<BulkConstraintPlan?> ApplyConstraintsAsync(
|
||||
GatewaySession session,
|
||||
MxCommand command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ApiKeyIdentity? identity = identityAccessor.Current;
|
||||
switch (command.Kind)
|
||||
{
|
||||
case MxCommandKind.AddItem:
|
||||
await EnforceReadTagAsync(identity, command.Kind, command.AddItem.ItemDefinition, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return null;
|
||||
case MxCommandKind.AddItem2:
|
||||
await EnforceReadTagAsync(identity, command.Kind, command.AddItem2.ItemDefinition, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return null;
|
||||
case MxCommandKind.AddItemBulk:
|
||||
return await FilterTagBulkAsync(
|
||||
identity,
|
||||
command,
|
||||
command.AddItemBulk.ServerHandle,
|
||||
command.AddItemBulk.TagAddresses,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
case MxCommandKind.SubscribeBulk:
|
||||
return await FilterTagBulkAsync(
|
||||
identity,
|
||||
command,
|
||||
command.SubscribeBulk.ServerHandle,
|
||||
command.SubscribeBulk.TagAddresses,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
case MxCommandKind.AdviseItemBulk:
|
||||
return await FilterHandleBulkAsync(
|
||||
identity,
|
||||
session,
|
||||
command,
|
||||
command.AdviseItemBulk.ServerHandle,
|
||||
command.AdviseItemBulk.ItemHandles,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
case MxCommandKind.Write:
|
||||
await EnforceWriteHandleAsync(
|
||||
identity,
|
||||
session,
|
||||
command.Kind,
|
||||
command.Write.ServerHandle,
|
||||
command.Write.ItemHandle,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return null;
|
||||
case MxCommandKind.Write2:
|
||||
await EnforceWriteHandleAsync(
|
||||
identity,
|
||||
session,
|
||||
command.Kind,
|
||||
command.Write2.ServerHandle,
|
||||
command.Write2.ItemHandle,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return null;
|
||||
case MxCommandKind.WriteSecured:
|
||||
await EnforceWriteHandleAsync(
|
||||
identity,
|
||||
session,
|
||||
command.Kind,
|
||||
command.WriteSecured.ServerHandle,
|
||||
command.WriteSecured.ItemHandle,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return null;
|
||||
case MxCommandKind.WriteSecured2:
|
||||
await EnforceWriteHandleAsync(
|
||||
identity,
|
||||
session,
|
||||
command.Kind,
|
||||
command.WriteSecured2.ServerHandle,
|
||||
command.WriteSecured2.ItemHandle,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnforceReadTagAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
MxCommandKind commandKind,
|
||||
string tagAddress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ConstraintFailure? failure = await constraintEnforcer
|
||||
.CheckReadTagAsync(identity, tagAddress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (failure is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await constraintEnforcer.RecordDenialAsync(identity, commandKind.ToString(), tagAddress, failure, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
throw new RpcException(new Status(StatusCode.PermissionDenied, failure.Message));
|
||||
}
|
||||
|
||||
private async Task EnforceWriteHandleAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
MxCommandKind commandKind,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ConstraintFailure? failure = await constraintEnforcer
|
||||
.CheckWriteHandleAsync(identity, session, serverHandle, itemHandle, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (failure is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await constraintEnforcer.RecordDenialAsync(identity, commandKind.ToString(), itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture), failure, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
throw new RpcException(new Status(StatusCode.PermissionDenied, failure.Message));
|
||||
}
|
||||
|
||||
private async Task<BulkConstraintPlan?> FilterTagBulkAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
MxCommand command,
|
||||
int serverHandle,
|
||||
IReadOnlyList<string> tagAddresses,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Dictionary<int, SubscribeResult> denied = [];
|
||||
List<string> allowed = [];
|
||||
for (int index = 0; index < tagAddresses.Count; index++)
|
||||
{
|
||||
string tagAddress = tagAddresses[index];
|
||||
ConstraintFailure? failure = await constraintEnforcer
|
||||
.CheckReadTagAsync(identity, tagAddress, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (failure is null)
|
||||
{
|
||||
allowed.Add(tagAddress);
|
||||
continue;
|
||||
}
|
||||
|
||||
await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), tagAddress, failure, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
denied[index] = new SubscribeResult
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
TagAddress = tagAddress,
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = failure.Message,
|
||||
};
|
||||
}
|
||||
|
||||
if (denied.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
MxCommand filtered = command.Clone();
|
||||
if (filtered.Kind == MxCommandKind.AddItemBulk)
|
||||
{
|
||||
filtered.AddItemBulk.TagAddresses.Clear();
|
||||
filtered.AddItemBulk.TagAddresses.Add(allowed);
|
||||
}
|
||||
else
|
||||
{
|
||||
filtered.SubscribeBulk.TagAddresses.Clear();
|
||||
filtered.SubscribeBulk.TagAddresses.Add(allowed);
|
||||
}
|
||||
|
||||
return new BulkConstraintPlan(filtered, tagAddresses.Count, denied, allowed.Count > 0);
|
||||
}
|
||||
|
||||
private async Task<BulkConstraintPlan?> FilterHandleBulkAsync(
|
||||
ApiKeyIdentity? identity,
|
||||
GatewaySession session,
|
||||
MxCommand command,
|
||||
int serverHandle,
|
||||
IReadOnlyList<int> itemHandles,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Dictionary<int, SubscribeResult> denied = [];
|
||||
List<int> allowed = [];
|
||||
for (int index = 0; index < itemHandles.Count; index++)
|
||||
{
|
||||
int itemHandle = itemHandles[index];
|
||||
ConstraintFailure? failure = await constraintEnforcer
|
||||
.CheckReadHandleAsync(identity, session, serverHandle, itemHandle, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (failure is null)
|
||||
{
|
||||
allowed.Add(itemHandle);
|
||||
continue;
|
||||
}
|
||||
|
||||
await constraintEnforcer.RecordDenialAsync(identity, command.Kind.ToString(), itemHandle.ToString(System.Globalization.CultureInfo.InvariantCulture), failure, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
denied[index] = new SubscribeResult
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
WasSuccessful = false,
|
||||
ErrorMessage = failure.Message,
|
||||
};
|
||||
}
|
||||
|
||||
if (denied.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
MxCommand filtered = command.Clone();
|
||||
filtered.AdviseItemBulk.ItemHandles.Clear();
|
||||
filtered.AdviseItemBulk.ItemHandles.Add(allowed);
|
||||
|
||||
return new BulkConstraintPlan(filtered, itemHandles.Count, denied, allowed.Count > 0);
|
||||
}
|
||||
|
||||
private static MxCommandReply CreateDeniedBulkReply(
|
||||
MxCommandRequest request,
|
||||
BulkConstraintPlan plan)
|
||||
{
|
||||
MxCommandReply reply = new()
|
||||
{
|
||||
SessionId = request.SessionId,
|
||||
CorrelationId = request.ClientCorrelationId,
|
||||
Kind = request.Command.Kind,
|
||||
ProtocolStatus = MxAccessGrpcMapper.Ok(),
|
||||
};
|
||||
SetBulkPayload(reply, request.Command.Kind, BuildMergedBulkReply(new BulkSubscribeReply(), plan));
|
||||
return reply;
|
||||
}
|
||||
|
||||
private static MxCommandReply MergeDeniedBulkResults(
|
||||
MxCommandReply reply,
|
||||
MxCommandKind commandKind,
|
||||
BulkConstraintPlan plan)
|
||||
{
|
||||
BulkSubscribeReply allowed = GetBulkPayload(reply, commandKind) ?? new BulkSubscribeReply();
|
||||
SetBulkPayload(reply, commandKind, BuildMergedBulkReply(allowed, plan));
|
||||
return reply;
|
||||
}
|
||||
|
||||
private static BulkSubscribeReply BuildMergedBulkReply(
|
||||
BulkSubscribeReply allowed,
|
||||
BulkConstraintPlan plan)
|
||||
{
|
||||
Queue<SubscribeResult> allowedResults = new(allowed.Results);
|
||||
BulkSubscribeReply merged = new();
|
||||
for (int index = 0; index < plan.OriginalCount; index++)
|
||||
{
|
||||
if (plan.DeniedResults.TryGetValue(index, out SubscribeResult? denied))
|
||||
{
|
||||
merged.Results.Add(denied);
|
||||
}
|
||||
else if (allowedResults.TryDequeue(out SubscribeResult? allowedResult))
|
||||
{
|
||||
merged.Results.Add(allowedResult);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
private static BulkSubscribeReply? GetBulkPayload(MxCommandReply reply, MxCommandKind commandKind)
|
||||
{
|
||||
return commandKind switch
|
||||
{
|
||||
MxCommandKind.AddItemBulk => reply.AddItemBulk,
|
||||
MxCommandKind.AdviseItemBulk => reply.AdviseItemBulk,
|
||||
MxCommandKind.SubscribeBulk => reply.SubscribeBulk,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static void SetBulkPayload(
|
||||
MxCommandReply reply,
|
||||
MxCommandKind commandKind,
|
||||
BulkSubscribeReply payload)
|
||||
{
|
||||
switch (commandKind)
|
||||
{
|
||||
case MxCommandKind.AddItemBulk:
|
||||
reply.AddItemBulk = payload;
|
||||
break;
|
||||
case MxCommandKind.AdviseItemBulk:
|
||||
reply.AdviseItemBulk = payload;
|
||||
break;
|
||||
case MxCommandKind.SubscribeBulk:
|
||||
reply.SubscribeBulk = payload;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record BulkConstraintPlan(
|
||||
MxCommand Command,
|
||||
int OriginalCount,
|
||||
IReadOnlyDictionary<int, SubscribeResult> DeniedResults,
|
||||
bool HasAllowedItems);
|
||||
|
||||
private RpcException MapException(Exception exception)
|
||||
{
|
||||
if (exception is OperationCanceledException)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
|
||||
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
|
||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
|
||||
<PackageReference Include="Polly.Core" Version="8.6.6" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ public sealed class ApiKeyAdminCliRunner(
|
||||
SecretHash: hasher.HashSecret(secret),
|
||||
DisplayName: Required(command.DisplayName),
|
||||
Scopes: command.Scopes,
|
||||
Constraints: command.Constraints,
|
||||
CreatedUtc: DateTimeOffset.UtcNow),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
@@ -172,6 +173,7 @@ public sealed class ApiKeyAdminCliRunner(
|
||||
KeyPrefix: key.KeyPrefix,
|
||||
DisplayName: key.DisplayName,
|
||||
Scopes: key.Scopes,
|
||||
Constraints: key.Constraints,
|
||||
CreatedUtc: key.CreatedUtc,
|
||||
LastUsedUtc: key.LastUsedUtc,
|
||||
RevokedUtc: key.RevokedUtc);
|
||||
|
||||
@@ -7,4 +7,5 @@ public sealed record ApiKeyAdminCommand(
|
||||
string? Pepper,
|
||||
string? KeyId,
|
||||
string? DisplayName,
|
||||
IReadOnlySet<string> Scopes);
|
||||
IReadOnlySet<string> Scopes,
|
||||
ApiKeyConstraints Constraints);
|
||||
|
||||
@@ -22,7 +22,7 @@ public static class ApiKeyAdminCommandLineParser
|
||||
return ApiKeyAdminParseResult.Fail($"Unknown apikey subcommand '{args[1]}'.");
|
||||
}
|
||||
|
||||
Dictionary<string, string?> options = new(StringComparer.OrdinalIgnoreCase);
|
||||
Dictionary<string, List<string?>> options = new(StringComparer.OrdinalIgnoreCase);
|
||||
bool json = false;
|
||||
|
||||
for (int index = 2; index < args.Count; index++)
|
||||
@@ -52,18 +52,42 @@ public static class ApiKeyAdminCommandLineParser
|
||||
{
|
||||
if (index + 1 >= args.Count || args[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
return ApiKeyAdminParseResult.Fail($"Option '--{name}' requires a value.");
|
||||
if (IsBooleanConstraintFlag(name))
|
||||
{
|
||||
value = "true";
|
||||
}
|
||||
else
|
||||
{
|
||||
return ApiKeyAdminParseResult.Fail($"Option '--{name}' requires a value.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
value = args[++index];
|
||||
}
|
||||
|
||||
value = args[++index];
|
||||
}
|
||||
|
||||
options[name] = value;
|
||||
if (!options.TryGetValue(name, out List<string?>? values))
|
||||
{
|
||||
values = [];
|
||||
options[name] = values;
|
||||
}
|
||||
|
||||
values.Add(value);
|
||||
}
|
||||
|
||||
string? keyId = GetOption(options, "key-id");
|
||||
string? displayName = GetOption(options, "display-name");
|
||||
IReadOnlySet<string> scopes = ParseScopes(GetOption(options, "scopes"));
|
||||
ApiKeyConstraints constraints;
|
||||
try
|
||||
{
|
||||
constraints = ParseConstraints(options);
|
||||
}
|
||||
catch (FormatException exception)
|
||||
{
|
||||
return ApiKeyAdminParseResult.Fail(exception.Message);
|
||||
}
|
||||
|
||||
string? validationError = Validate(kind, keyId, displayName);
|
||||
if (validationError is not null)
|
||||
@@ -78,7 +102,8 @@ public static class ApiKeyAdminCommandLineParser
|
||||
Pepper: GetOption(options, "pepper"),
|
||||
KeyId: keyId,
|
||||
DisplayName: displayName,
|
||||
Scopes: scopes));
|
||||
Scopes: scopes,
|
||||
Constraints: constraints));
|
||||
}
|
||||
|
||||
private static bool TryParseKind(string value, out ApiKeyAdminCommandKind kind)
|
||||
@@ -147,9 +172,56 @@ public static class ApiKeyAdminCommandLineParser
|
||||
|| character is '.' or '-');
|
||||
}
|
||||
|
||||
private static string? GetOption(Dictionary<string, string?> options, string name)
|
||||
private static string? GetOption(Dictionary<string, List<string?>> options, string name)
|
||||
{
|
||||
return options.TryGetValue(name, out string? value) ? value : null;
|
||||
return options.TryGetValue(name, out List<string?>? values) && values.Count > 0 ? values[^1] : null;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetOptions(Dictionary<string, List<string?>> options, string name)
|
||||
{
|
||||
return options.TryGetValue(name, out List<string?>? values)
|
||||
? values.Where(value => !string.IsNullOrWhiteSpace(value)).Select(value => value!).ToArray()
|
||||
: Array.Empty<string>();
|
||||
}
|
||||
|
||||
private static bool HasFlag(Dictionary<string, List<string?>> options, string name)
|
||||
{
|
||||
return options.ContainsKey(name);
|
||||
}
|
||||
|
||||
private static bool IsBooleanConstraintFlag(string name)
|
||||
{
|
||||
return string.Equals(name, "read-alarm-only", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(name, "read-historized-only", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static ApiKeyConstraints ParseConstraints(Dictionary<string, List<string?>> options)
|
||||
{
|
||||
return new ApiKeyConstraints(
|
||||
ReadSubtrees: GetOptions(options, "read-subtree"),
|
||||
WriteSubtrees: GetOptions(options, "write-subtree"),
|
||||
ReadTagGlobs: GetOptions(options, "read-tag-glob"),
|
||||
WriteTagGlobs: GetOptions(options, "write-tag-glob"),
|
||||
MaxWriteClassification: ParseNullableInt(GetOption(options, "max-write-classification")),
|
||||
BrowseSubtrees: GetOptions(options, "browse-subtree"),
|
||||
ReadAlarmOnly: HasFlag(options, "read-alarm-only"),
|
||||
ReadHistorizedOnly: HasFlag(options, "read-historized-only"));
|
||||
}
|
||||
|
||||
private static int? ParseNullableInt(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return int.TryParse(
|
||||
value,
|
||||
System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out int parsed)
|
||||
? parsed
|
||||
: throw new FormatException("--max-write-classification must be an integer.");
|
||||
}
|
||||
|
||||
private static IReadOnlySet<string> ParseScopes(string? scopes)
|
||||
|
||||
@@ -5,6 +5,7 @@ public sealed record ApiKeyAdminListedKey(
|
||||
string KeyPrefix,
|
||||
string DisplayName,
|
||||
IReadOnlySet<string> Scopes,
|
||||
ApiKeyConstraints Constraints,
|
||||
DateTimeOffset CreatedUtc,
|
||||
DateTimeOffset? LastUsedUtc,
|
||||
DateTimeOffset? RevokedUtc);
|
||||
|
||||
@@ -6,4 +6,5 @@ public sealed record ApiKeyCreateRequest(
|
||||
byte[] SecretHash,
|
||||
string DisplayName,
|
||||
IReadOnlySet<string> Scopes,
|
||||
ApiKeyConstraints Constraints,
|
||||
DateTimeOffset CreatedUtc);
|
||||
|
||||
@@ -4,4 +4,8 @@ public sealed record ApiKeyIdentity(
|
||||
string KeyId,
|
||||
string KeyPrefix,
|
||||
string DisplayName,
|
||||
IReadOnlySet<string> Scopes);
|
||||
IReadOnlySet<string> Scopes,
|
||||
ApiKeyConstraints? Constraints = null)
|
||||
{
|
||||
public ApiKeyConstraints EffectiveConstraints => Constraints ?? ApiKeyConstraints.Empty;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ public sealed record ApiKeyRecord(
|
||||
byte[] SecretHash,
|
||||
string DisplayName,
|
||||
IReadOnlySet<string> Scopes,
|
||||
ApiKeyConstraints Constraints,
|
||||
DateTimeOffset CreatedUtc,
|
||||
DateTimeOffset? LastUsedUtc,
|
||||
DateTimeOffset? RevokedUtc);
|
||||
|
||||
@@ -16,9 +16,10 @@ public static class ApiKeyRecordReader
|
||||
SecretHash: (byte[])reader["secret_hash"],
|
||||
DisplayName: reader.GetString(3),
|
||||
Scopes: ApiKeyScopeSerializer.Deserialize(reader.GetString(4)),
|
||||
CreatedUtc: DateTimeOffset.Parse(reader.GetString(5), System.Globalization.CultureInfo.InvariantCulture),
|
||||
LastUsedUtc: ReadNullableDateTimeOffset(reader, 6),
|
||||
RevokedUtc: ReadNullableDateTimeOffset(reader, 7));
|
||||
Constraints: ApiKeyConstraintSerializer.Deserialize(reader.IsDBNull(5) ? null : reader.GetString(5)),
|
||||
CreatedUtc: DateTimeOffset.Parse(reader.GetString(6), System.Globalization.CultureInfo.InvariantCulture),
|
||||
LastUsedUtc: ReadNullableDateTimeOffset(reader, 7),
|
||||
RevokedUtc: ReadNullableDateTimeOffset(reader, 8));
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ReadNullableDateTimeOffset(SqliteDataReader reader, int ordinal)
|
||||
|
||||
@@ -58,6 +58,7 @@ public sealed class ApiKeyVerifier(
|
||||
KeyId: storedKey.KeyId,
|
||||
KeyPrefix: storedKey.KeyPrefix,
|
||||
DisplayName: storedKey.DisplayName,
|
||||
Scopes: storedKey.Scopes));
|
||||
Scopes: storedKey.Scopes,
|
||||
Constraints: storedKey.Constraints));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
|
||||
secret_hash,
|
||||
display_name,
|
||||
scopes,
|
||||
constraints,
|
||||
created_utc,
|
||||
last_used_utc,
|
||||
revoked_utc)
|
||||
@@ -30,6 +31,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
|
||||
$secret_hash,
|
||||
$display_name,
|
||||
$scopes,
|
||||
$constraints,
|
||||
$created_utc,
|
||||
NULL,
|
||||
NULL);
|
||||
@@ -47,7 +49,7 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
|
||||
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT key_id, key_prefix, secret_hash, display_name, scopes, created_utc, last_used_utc, revoked_utc
|
||||
SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc
|
||||
FROM api_keys
|
||||
ORDER BY key_id;
|
||||
""";
|
||||
@@ -118,6 +120,9 @@ public sealed class SqliteApiKeyAdminStore(AuthSqliteConnectionFactory connectio
|
||||
command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = request.SecretHash;
|
||||
command.Parameters.AddWithValue("$display_name", request.DisplayName);
|
||||
command.Parameters.AddWithValue("$scopes", ApiKeyScopeSerializer.Serialize(request.Scopes));
|
||||
command.Parameters.AddWithValue(
|
||||
"$constraints",
|
||||
(object?)ApiKeyConstraintSerializer.Serialize(request.Constraints) ?? DBNull.Value);
|
||||
command.Parameters.AddWithValue("$created_utc", request.CreatedUtc.ToString("O"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,12 +46,12 @@ public sealed class SqliteApiKeyStore(AuthSqliteConnectionFactory connectionFact
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.CommandText = requireActive
|
||||
? """
|
||||
SELECT key_id, key_prefix, secret_hash, display_name, scopes, created_utc, last_used_utc, revoked_utc
|
||||
SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc
|
||||
FROM api_keys
|
||||
WHERE key_id = $key_id AND revoked_utc IS NULL;
|
||||
"""
|
||||
: """
|
||||
SELECT key_id, key_prefix, secret_hash, display_name, scopes, created_utc, last_used_utc, revoked_utc
|
||||
SELECT key_id, key_prefix, secret_hash, display_name, scopes, constraints, created_utc, last_used_utc, revoked_utc
|
||||
FROM api_keys
|
||||
WHERE key_id = $key_id;
|
||||
""";
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace MxGateway.Server.Security.Authentication;
|
||||
|
||||
public static class SqliteAuthSchema
|
||||
{
|
||||
public const int CurrentVersion = 1;
|
||||
public const int CurrentVersion = 2;
|
||||
|
||||
public const string SchemaVersionTable = "schema_version";
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
|
||||
}
|
||||
|
||||
await ApplyVersionOneAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
||||
await ApplyVersionTwoAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
||||
await WriteSchemaVersionAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
@@ -85,6 +87,7 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
|
||||
secret_hash BLOB NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
scopes TEXT NOT NULL,
|
||||
constraints TEXT NULL,
|
||||
created_utc TEXT NOT NULL,
|
||||
last_used_utc TEXT NULL,
|
||||
revoked_utc TEXT NULL
|
||||
@@ -107,6 +110,34 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
|
||||
""",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
}
|
||||
|
||||
private static async Task ApplyVersionTwoAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (await ColumnExistsAsync(connection, transaction, SqliteAuthSchema.ApiKeysTable, "constraints", cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await ExecuteNonQueryAsync(
|
||||
connection,
|
||||
transaction,
|
||||
"""
|
||||
ALTER TABLE api_keys
|
||||
ADD COLUMN constraints TEXT NULL;
|
||||
""",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task WriteSchemaVersionAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteCommand versionCommand = connection.CreateCommand();
|
||||
versionCommand.Transaction = transaction;
|
||||
versionCommand.CommandText = """
|
||||
@@ -122,6 +153,31 @@ public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connecti
|
||||
await versionCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task<bool> ColumnExistsAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
string tableName,
|
||||
string columnName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using SqliteCommand command = connection.CreateCommand();
|
||||
command.Transaction = transaction;
|
||||
command.CommandText = $"PRAGMA table_info({tableName});";
|
||||
|
||||
await using SqliteDataReader reader = await command.ExecuteReaderAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (string.Equals(reader.GetString(1), columnName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static async Task ExecuteNonQueryAsync(
|
||||
SqliteConnection connection,
|
||||
SqliteTransaction transaction,
|
||||
|
||||
+12
@@ -1,4 +1,6 @@
|
||||
using Grpc.Core.Interceptors;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using MxGateway.Server.Configuration;
|
||||
|
||||
namespace MxGateway.Server.Security.Authorization;
|
||||
|
||||
@@ -15,7 +17,17 @@ public static class GrpcAuthorizationServiceCollectionExtensions
|
||||
{
|
||||
services.AddSingleton<GatewayGrpcScopeResolver>();
|
||||
services.AddSingleton<IGatewayRequestIdentityAccessor, GatewayRequestIdentityAccessor>();
|
||||
services.AddSingleton<IConstraintEnforcer, ConstraintEnforcer>();
|
||||
services.AddSingleton<GatewayGrpcAuthorizationInterceptor>();
|
||||
services
|
||||
.AddOptions<global::Grpc.AspNetCore.Server.GrpcServiceOptions>()
|
||||
.Configure<IConfiguration>((grpcOptions, configuration) =>
|
||||
{
|
||||
ProtocolOptions protocolOptions = new();
|
||||
configuration.GetSection("MxGateway:Protocol").Bind(protocolOptions);
|
||||
grpcOptions.MaxReceiveMessageSize = protocolOptions.MaxGrpcMessageBytes;
|
||||
grpcOptions.MaxSendMessageSize = protocolOptions.MaxGrpcMessageBytes;
|
||||
});
|
||||
services.AddGrpc(options => options.Interceptors.Add<GatewayGrpcAuthorizationInterceptor>());
|
||||
|
||||
return services;
|
||||
|
||||
@@ -14,6 +14,7 @@ public sealed class GatewaySession
|
||||
private DateTimeOffset? _leaseExpiresAt;
|
||||
private bool _closeStarted;
|
||||
private int _activeEventSubscriberCount;
|
||||
private readonly Dictionary<(int ServerHandle, int ItemHandle), SessionItemRegistration> _items = [];
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a gateway session with session metadata and timeout configuration.
|
||||
@@ -41,6 +42,35 @@ public sealed class GatewaySession
|
||||
TimeSpan startupTimeout,
|
||||
TimeSpan shutdownTimeout,
|
||||
DateTimeOffset openedAt)
|
||||
: this(
|
||||
sessionId,
|
||||
backendName,
|
||||
pipeName,
|
||||
nonce,
|
||||
clientIdentity,
|
||||
clientSessionName,
|
||||
clientCorrelationId,
|
||||
commandTimeout,
|
||||
startupTimeout,
|
||||
shutdownTimeout,
|
||||
TimeSpan.FromMinutes(30),
|
||||
openedAt)
|
||||
{
|
||||
}
|
||||
|
||||
public GatewaySession(
|
||||
string sessionId,
|
||||
string backendName,
|
||||
string pipeName,
|
||||
string nonce,
|
||||
string? clientIdentity,
|
||||
string? clientSessionName,
|
||||
string? clientCorrelationId,
|
||||
TimeSpan commandTimeout,
|
||||
TimeSpan startupTimeout,
|
||||
TimeSpan shutdownTimeout,
|
||||
TimeSpan leaseDuration,
|
||||
DateTimeOffset openedAt)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
@@ -72,8 +102,10 @@ public sealed class GatewaySession
|
||||
CommandTimeout = commandTimeout;
|
||||
StartupTimeout = startupTimeout;
|
||||
ShutdownTimeout = shutdownTimeout;
|
||||
LeaseDuration = leaseDuration;
|
||||
OpenedAt = openedAt;
|
||||
_lastClientActivityAt = openedAt;
|
||||
_leaseExpiresAt = openedAt + leaseDuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -126,6 +158,8 @@ public sealed class GatewaySession
|
||||
/// </summary>
|
||||
public TimeSpan ShutdownTimeout { get; }
|
||||
|
||||
public TimeSpan LeaseDuration { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when the session opened.
|
||||
/// </summary>
|
||||
@@ -282,6 +316,7 @@ public sealed class GatewaySession
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_lastClientActivityAt = activityAt;
|
||||
_leaseExpiresAt = activityAt + LeaseDuration;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +340,9 @@ public sealed class GatewaySession
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
return _leaseExpiresAt is not null && _leaseExpiresAt <= now;
|
||||
return _activeEventSubscriberCount == 0
|
||||
&& _leaseExpiresAt is not null
|
||||
&& _leaseExpiresAt <= now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,6 +388,58 @@ public sealed class GatewaySession
|
||||
return await workerClient.InvokeAsync(command, CommandTimeout, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public bool TryGetItemRegistration(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
out SessionItemRegistration registration)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
return _items.TryGetValue((serverHandle, itemHandle), out registration!);
|
||||
}
|
||||
}
|
||||
|
||||
public void TrackCommandReply(
|
||||
MxCommand command,
|
||||
MxCommandReply reply)
|
||||
{
|
||||
if (reply.ProtocolStatus?.Code is not ProtocolStatusCode.Ok)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_syncRoot)
|
||||
{
|
||||
switch (command.Kind)
|
||||
{
|
||||
case MxCommandKind.AddItem when reply.AddItem is not null:
|
||||
TrackItem(command.AddItem.ServerHandle, reply.AddItem.ItemHandle, command.AddItem.ItemDefinition);
|
||||
break;
|
||||
case MxCommandKind.AddItem2 when reply.AddItem2 is not null:
|
||||
TrackItem(command.AddItem2.ServerHandle, reply.AddItem2.ItemHandle, command.AddItem2.ItemDefinition);
|
||||
break;
|
||||
case MxCommandKind.AddBufferedItem when reply.AddBufferedItem is not null:
|
||||
TrackItem(command.AddBufferedItem.ServerHandle, reply.AddBufferedItem.ItemHandle, command.AddBufferedItem.ItemDefinition);
|
||||
break;
|
||||
case MxCommandKind.AddItemBulk when reply.AddItemBulk is not null:
|
||||
TrackBulkItems(reply.AddItemBulk);
|
||||
break;
|
||||
case MxCommandKind.SubscribeBulk when reply.SubscribeBulk is not null:
|
||||
TrackBulkItems(reply.SubscribeBulk);
|
||||
break;
|
||||
case MxCommandKind.RemoveItem:
|
||||
_items.Remove((command.RemoveItem.ServerHandle, command.RemoveItem.ItemHandle));
|
||||
break;
|
||||
case MxCommandKind.RemoveItemBulk:
|
||||
RemoveItems(command.RemoveItemBulk.ServerHandle, command.RemoveItemBulk.ItemHandles);
|
||||
break;
|
||||
case MxCommandKind.UnsubscribeBulk:
|
||||
RemoveItems(command.UnsubscribeBulk.ServerHandle, command.UnsubscribeBulk.ItemHandles);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes a bulk add-item command for the specified server and tag addresses.
|
||||
/// </summary>
|
||||
@@ -641,6 +730,40 @@ public sealed class GatewaySession
|
||||
}
|
||||
}
|
||||
|
||||
private void TrackItem(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
string tagAddress)
|
||||
{
|
||||
if (itemHandle == 0 || string.IsNullOrWhiteSpace(tagAddress))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_items[(serverHandle, itemHandle)] = new SessionItemRegistration(serverHandle, itemHandle, tagAddress);
|
||||
}
|
||||
|
||||
private void TrackBulkItems(BulkSubscribeReply reply)
|
||||
{
|
||||
foreach (SubscribeResult result in reply.Results)
|
||||
{
|
||||
if (result.WasSuccessful)
|
||||
{
|
||||
TrackItem(result.ServerHandle, result.ItemHandle, result.TagAddress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveItems(
|
||||
int serverHandle,
|
||||
IEnumerable<int> itemHandles)
|
||||
{
|
||||
foreach (int itemHandle in itemHandles)
|
||||
{
|
||||
_items.Remove((serverHandle, itemHandle));
|
||||
}
|
||||
}
|
||||
|
||||
private void DetachEventSubscriber()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
|
||||
@@ -339,6 +339,7 @@ public sealed class SessionManager : ISessionManager
|
||||
TimeSpan commandTimeout = ResolveCommandTimeout(request.CommandTimeout);
|
||||
TimeSpan startupTimeout = TimeSpan.FromSeconds(_options.Worker.StartupTimeoutSeconds);
|
||||
TimeSpan shutdownTimeout = TimeSpan.FromSeconds(_options.Worker.ShutdownTimeoutSeconds);
|
||||
TimeSpan leaseDuration = TimeSpan.FromSeconds(_options.Sessions.DefaultLeaseSeconds);
|
||||
string pipeName = $"mxaccess-gateway-{Environment.ProcessId}-{sessionId}";
|
||||
string nonce = CreateNonce();
|
||||
DateTimeOffset openedAt = _timeProvider.GetUtcNow();
|
||||
@@ -355,6 +356,7 @@ public sealed class SessionManager : ISessionManager
|
||||
commandTimeout,
|
||||
startupTimeout,
|
||||
shutdownTimeout,
|
||||
leaseDuration,
|
||||
openedAt);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ public static class SessionServiceCollectionExtensions
|
||||
services.AddSingleton<ISessionRegistry, SessionRegistry>();
|
||||
services.AddSingleton<ISessionWorkerClientFactory, SessionWorkerClientFactory>();
|
||||
services.AddSingleton<ISessionManager, SessionManager>();
|
||||
services.AddHostedService<SessionLeaseMonitorHostedService>();
|
||||
services.AddHostedService<SessionShutdownHostedService>();
|
||||
|
||||
return services;
|
||||
|
||||
@@ -254,11 +254,17 @@ public sealed class WorkerClient : IWorkerClient
|
||||
}
|
||||
|
||||
WorkerClientState state = State;
|
||||
if (state is WorkerClientState.Closed or WorkerClientState.Faulted)
|
||||
if (state == WorkerClientState.Closed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (state == WorkerClientState.Faulted)
|
||||
{
|
||||
KillOwnedProcess("ShutdownFaulted");
|
||||
return;
|
||||
}
|
||||
|
||||
MarkClosing();
|
||||
await EnqueueAsync(CreateShutdownEnvelope(timeout, "gateway-shutdown"), cancellationToken).ConfigureAwait(false);
|
||||
_outboundEnvelopes.Writer.TryComplete();
|
||||
@@ -288,8 +294,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
public void Kill(string reason)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
_connection.ProcessHandle?.Process.Kill(entireProcessTree: true);
|
||||
_metrics?.WorkerKilled(reason);
|
||||
KillOwnedProcess(reason);
|
||||
SetFaulted(
|
||||
WorkerClientErrorCode.WorkerFaulted,
|
||||
$"Worker was killed by the gateway: {reason}.",
|
||||
@@ -305,6 +310,7 @@ public sealed class WorkerClient : IWorkerClient
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
KillOwnedProcess("Dispose");
|
||||
_stopCts.Cancel();
|
||||
_outboundEnvelopes.Writer.TryComplete();
|
||||
_events.Writer.TryComplete();
|
||||
@@ -666,12 +672,39 @@ public sealed class WorkerClient : IWorkerClient
|
||||
_stopCts.Cancel();
|
||||
_outboundEnvelopes.Writer.TryComplete(fault);
|
||||
_events.Writer.TryComplete(fault);
|
||||
KillOwnedProcess(errorCode.ToString());
|
||||
CompletePendingCommands(fault);
|
||||
RecordWorkerStoppedOnce(errorCode.ToString());
|
||||
_metrics?.Fault(errorCode.ToString());
|
||||
_logger.LogWarning(exception, "Worker client faulted for session {SessionId}: {Message}", SessionId, message);
|
||||
}
|
||||
|
||||
private void KillOwnedProcess(string reason)
|
||||
{
|
||||
WorkerProcessHandle? processHandle = _connection.ProcessHandle;
|
||||
if (processHandle is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!processHandle.Process.HasExited)
|
||||
{
|
||||
processHandle.Process.Kill(entireProcessTree: true);
|
||||
_metrics?.WorkerKilled(reason);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
exception,
|
||||
"Failed to kill worker process {ProcessId} for session {SessionId}.",
|
||||
processHandle.ProcessId,
|
||||
SessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records worker stopped metric only once.</summary>
|
||||
/// <param name="reason">Reason for stopping.</param>
|
||||
private void RecordWorkerStoppedOnce(string reason)
|
||||
|
||||
@@ -13,6 +13,20 @@
|
||||
"PepperSecretName": "MxGateway:ApiKeyPepper",
|
||||
"RunMigrationsOnStartup": true
|
||||
},
|
||||
"Ldap": {
|
||||
"Enabled": true,
|
||||
"Server": "localhost",
|
||||
"Port": 3893,
|
||||
"UseTls": false,
|
||||
"AllowInsecureLdap": true,
|
||||
"SearchBase": "dc=lmxopcua,dc=local",
|
||||
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
|
||||
"ServiceAccountPassword": "serviceaccount123",
|
||||
"UserNameAttribute": "cn",
|
||||
"DisplayNameAttribute": "cn",
|
||||
"GroupAttribute": "memberOf",
|
||||
"RequiredGroup": "GwAdmin"
|
||||
},
|
||||
"Worker": {
|
||||
"ExecutablePath": "src\\MxGateway.Worker\\bin\\x86\\Release\\MxGateway.Worker.exe",
|
||||
"RequiredArchitecture": "X86",
|
||||
@@ -25,6 +39,9 @@
|
||||
"Sessions": {
|
||||
"DefaultCommandTimeoutSeconds": 30,
|
||||
"MaxSessions": 64,
|
||||
"MaxPendingCommandsPerSession": 128,
|
||||
"DefaultLeaseSeconds": 1800,
|
||||
"LeaseSweepIntervalSeconds": 30,
|
||||
"AllowMultipleEventSubscribers": false
|
||||
},
|
||||
"Events": {
|
||||
@@ -42,7 +59,8 @@
|
||||
"ShowTagValues": false
|
||||
},
|
||||
"Protocol": {
|
||||
"WorkerProtocolVersion": 1
|
||||
"WorkerProtocolVersion": 1,
|
||||
"MaxGrpcMessageBytes": 16777216
|
||||
},
|
||||
"Galaxy": {
|
||||
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
|
||||
|
||||
@@ -122,6 +122,33 @@
|
||||
border-radius: .375rem;
|
||||
}
|
||||
|
||||
.api-key-management-grid {
|
||||
display: grid;
|
||||
gap: .75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
||||
}
|
||||
|
||||
.scope-grid {
|
||||
display: grid;
|
||||
gap: .35rem .75rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
|
||||
}
|
||||
|
||||
.one-time-secret {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.api-key-create-modal {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.api-key-create-modal .modal-body {
|
||||
max-height: min(70vh, 44rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.dashboard-content {
|
||||
padding: .75rem;
|
||||
|
||||
Reference in New Issue
Block a user