Merge origin/main with local pending work and update AGENTS.md references

- Resolve 14 conflicts from popping local stash on top of origin's
  eed1e88 + 8d3352f doc-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 in 4731ab5)
- 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:
Joseph Doherty
2026-04-30 14:13:33 -04:00
parent 8d3352f2c6
commit ddad573b75
101 changed files with 6053 additions and 621 deletions
@@ -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);
}