Add unit tests for SBOM ingestion and transformation
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
- Implement `SbomIngestServiceCollectionExtensionsTests` to verify the SBOM ingestion pipeline exports snapshots correctly. - Create `SbomIngestTransformerTests` to ensure the transformation produces expected nodes and edges, including deduplication of license nodes and normalization of timestamps. - Add `SbomSnapshotExporterTests` to test the export functionality for manifest, adjacency, nodes, and edges. - Introduce `VexOverlayTransformerTests` to validate the transformation of VEX nodes and edges. - Set up project file for the test project with necessary dependencies and configurations. - Include JSON fixture files for testing purposes.
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
using System.Security.Claims;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap.Claims;
|
||||
|
||||
internal sealed class LdapClaimsEnricher : IClaimsEnricher
|
||||
{
|
||||
public ValueTask EnrichAsync(
|
||||
ClaimsIdentity identity,
|
||||
AuthorityClaimsEnrichmentContext context,
|
||||
CancellationToken cancellationToken)
|
||||
=> ValueTask.CompletedTask;
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Security.Authentication;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Plugin.Ldap.Monitoring;
|
||||
using StellaOps.Authority.Plugin.Ldap.Security;
|
||||
using System.DirectoryServices.Protocols;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap.Connections;
|
||||
|
||||
internal sealed class DirectoryServicesLdapConnectionFactory : ILdapConnectionFactory
|
||||
{
|
||||
private readonly string pluginName;
|
||||
private readonly IOptionsMonitor<LdapPluginOptions> optionsMonitor;
|
||||
private readonly ILogger<DirectoryServicesLdapConnectionFactory> logger;
|
||||
private readonly LdapMetrics metrics;
|
||||
|
||||
public DirectoryServicesLdapConnectionFactory(
|
||||
string pluginName,
|
||||
IOptionsMonitor<LdapPluginOptions> optionsMonitor,
|
||||
ILogger<DirectoryServicesLdapConnectionFactory> logger,
|
||||
LdapMetrics metrics)
|
||||
{
|
||||
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
}
|
||||
|
||||
public ValueTask<ILdapConnectionHandle> CreateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var options = optionsMonitor.Get(pluginName);
|
||||
var identifier = new LdapDirectoryIdentifier(options.Connection.Host!, options.Connection.Port, fullyQualifiedDnsHostName: false, connectionless: false);
|
||||
var connection = new LdapConnection(identifier)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(10)
|
||||
};
|
||||
|
||||
ConfigureCertificateValidation(connection, options);
|
||||
ConfigureClientCertificate(connection, options);
|
||||
|
||||
if (options.Connection.UseStartTls)
|
||||
{
|
||||
connection.SessionOptions.StartTransportLayerSecurity(null);
|
||||
}
|
||||
else if (options.Connection.Port == 636)
|
||||
{
|
||||
connection.SessionOptions.SecureSocketLayer = true;
|
||||
}
|
||||
|
||||
return ValueTask.FromResult<ILdapConnectionHandle>(new DirectoryServicesLdapConnectionHandle(connection, logger, metrics));
|
||||
}
|
||||
|
||||
private static void ConfigureCertificateValidation(LdapConnection connection, LdapPluginOptions options)
|
||||
{
|
||||
if (!options.Connection.ValidateCertificates)
|
||||
{
|
||||
connection.SessionOptions.VerifyServerCertificate += (_, _) => true;
|
||||
return;
|
||||
}
|
||||
|
||||
X509Certificate2Collection? customRoots = null;
|
||||
if (options.Connection.TrustStore.Mode == LdapTrustStoreMode.Bundle && !string.IsNullOrWhiteSpace(options.Connection.TrustStore.BundlePath))
|
||||
{
|
||||
customRoots = LoadBundle(options.Connection.TrustStore.BundlePath!);
|
||||
}
|
||||
|
||||
connection.SessionOptions.VerifyServerCertificate += (_, certificate) =>
|
||||
{
|
||||
if (certificate is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var cert2 = new X509Certificate2(certificate);
|
||||
using var chain = new X509Chain
|
||||
{
|
||||
ChainPolicy =
|
||||
{
|
||||
RevocationMode = X509RevocationMode.NoCheck,
|
||||
VerificationFlags = X509VerificationFlags.NoFlag
|
||||
}
|
||||
};
|
||||
|
||||
if (customRoots is not null)
|
||||
{
|
||||
foreach (var root in customRoots)
|
||||
{
|
||||
chain.ChainPolicy.CustomTrustStore.Add(root);
|
||||
}
|
||||
|
||||
chain.ChainPolicy.VerificationFlags |= X509VerificationFlags.AllowUnknownCertificateAuthority;
|
||||
}
|
||||
|
||||
return chain.Build(cert2);
|
||||
};
|
||||
}
|
||||
|
||||
private static void ConfigureClientCertificate(LdapConnection connection, LdapPluginOptions options)
|
||||
{
|
||||
var clientCertificateOptions = options.Connection.ClientCertificate;
|
||||
if (clientCertificateOptions is null || !clientCertificateOptions.IsConfigured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(clientCertificateOptions.PfxPath))
|
||||
{
|
||||
throw new InvalidOperationException("Client certificate PFX path must be configured when enabling client certificates.");
|
||||
}
|
||||
|
||||
var password = LdapSecretResolver.Resolve(clientCertificateOptions.PasswordSecret);
|
||||
var certificate = X509CertificateLoader.LoadPkcs12FromFile(
|
||||
clientCertificateOptions.PfxPath,
|
||||
password,
|
||||
X509KeyStorageFlags.EphemeralKeySet);
|
||||
connection.ClientCertificates.Add(certificate);
|
||||
}
|
||||
|
||||
private static X509Certificate2Collection LoadBundle(string path)
|
||||
{
|
||||
var collection = new X509Certificate2Collection();
|
||||
if (path.EndsWith(".pem", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".crt", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
collection.ImportFromPemFile(path);
|
||||
}
|
||||
else
|
||||
{
|
||||
var certificate = X509CertificateLoader.LoadPkcs12FromFile(path, password: null, X509KeyStorageFlags.EphemeralKeySet);
|
||||
collection.Add(certificate);
|
||||
}
|
||||
|
||||
return collection;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class DirectoryServicesLdapConnectionHandle : ILdapConnectionHandle
|
||||
{
|
||||
private readonly LdapConnection connection;
|
||||
private readonly ILogger logger;
|
||||
private readonly LdapMetrics metrics;
|
||||
private const int InvalidCredentialsResultCode = 49;
|
||||
private const int ServerDownResultCode = 81;
|
||||
private const int TimeLimitExceededResultCode = 3;
|
||||
private const int BusyResultCode = 51;
|
||||
private const int UnavailableResultCode = 52;
|
||||
|
||||
public DirectoryServicesLdapConnectionHandle(
|
||||
LdapConnection connection,
|
||||
ILogger logger,
|
||||
LdapMetrics metrics)
|
||||
{
|
||||
this.connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
}
|
||||
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
connection.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask BindAsync(string distinguishedName, string password, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
metrics.RecordBindAttempt();
|
||||
|
||||
try
|
||||
{
|
||||
connection.Bind(new NetworkCredential(distinguishedName, password));
|
||||
metrics.RecordBindSuccess();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
catch (LdapException ex) when (IsInvalidCredentials(ex))
|
||||
{
|
||||
metrics.RecordBindFailure();
|
||||
throw new LdapAuthenticationException($"Invalid credentials for '{distinguishedName}'.", ex);
|
||||
}
|
||||
catch (LdapException ex) when (IsTransient(ex))
|
||||
{
|
||||
metrics.RecordBindFailure();
|
||||
throw new LdapTransientException($"Transient bind failure for '{distinguishedName}'.", ex);
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
metrics.RecordBindFailure();
|
||||
throw new LdapOperationException($"LDAP bind failure ({FormatResult(ex.ErrorCode)}).", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<LdapSearchEntry?> FindEntryAsync(string baseDn, string filter, IReadOnlyCollection<string> attributes, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
metrics.RecordSearchAttempt();
|
||||
|
||||
try
|
||||
{
|
||||
var request = new SearchRequest(baseDn, filter, SearchScope.Subtree, attributes?.ToArray());
|
||||
var response = (SearchResponse)connection.SendRequest(request);
|
||||
|
||||
if (response.Entries.Count == 0)
|
||||
{
|
||||
metrics.RecordSearchMiss();
|
||||
return ValueTask.FromResult<LdapSearchEntry?>(null);
|
||||
}
|
||||
|
||||
var entry = response.Entries[0];
|
||||
var attributeDictionary = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (string attributeName in entry.Attributes.AttributeNames)
|
||||
{
|
||||
var attribute = entry.Attributes[attributeName];
|
||||
var values = attribute?.GetValues(typeof(string))?.Cast<string>().ToArray() ?? Array.Empty<string>();
|
||||
attributeDictionary[attributeName] = values;
|
||||
}
|
||||
|
||||
metrics.RecordSearchHit();
|
||||
return ValueTask.FromResult<LdapSearchEntry?>(new LdapSearchEntry(entry.DistinguishedName, attributeDictionary));
|
||||
}
|
||||
catch (LdapException ex) when (IsTransient(ex))
|
||||
{
|
||||
metrics.RecordSearchFailure();
|
||||
throw new LdapTransientException("Transient LDAP search failure.", ex);
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
metrics.RecordSearchFailure();
|
||||
logger.LogWarning(ex, "LDAP search failure ({Result}).", FormatResult(ex.ErrorCode));
|
||||
throw new LdapOperationException($"LDAP search failure ({FormatResult(ex.ErrorCode)}).", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsInvalidCredentials(LdapException ex)
|
||||
=> ex.ErrorCode == InvalidCredentialsResultCode;
|
||||
|
||||
private static bool IsTransient(LdapException ex)
|
||||
=> ex.ErrorCode is ServerDownResultCode
|
||||
or TimeLimitExceededResultCode
|
||||
or BusyResultCode
|
||||
or UnavailableResultCode;
|
||||
|
||||
private static string FormatResult(int errorCode)
|
||||
=> errorCode switch
|
||||
{
|
||||
InvalidCredentialsResultCode => "InvalidCredentials (49)",
|
||||
ServerDownResultCode => "ServerDown (81)",
|
||||
TimeLimitExceededResultCode => "TimeLimitExceeded (3)",
|
||||
BusyResultCode => "Busy (51)",
|
||||
UnavailableResultCode => "Unavailable (52)",
|
||||
_ => errorCode.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap.Connections;
|
||||
|
||||
internal interface ILdapConnectionFactory
|
||||
{
|
||||
ValueTask<ILdapConnectionHandle> CreateAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal interface ILdapConnectionHandle : IAsyncDisposable
|
||||
{
|
||||
ValueTask BindAsync(string distinguishedName, string password, CancellationToken cancellationToken);
|
||||
|
||||
ValueTask<LdapSearchEntry?> FindEntryAsync(string baseDn, string filter, IReadOnlyCollection<string> attributes, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed record LdapSearchEntry(string DistinguishedName, IReadOnlyDictionary<string, IReadOnlyList<string>> Attributes);
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap.Connections;
|
||||
|
||||
internal class LdapAuthenticationException : Exception
|
||||
{
|
||||
public LdapAuthenticationException(string message, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal class LdapTransientException : Exception
|
||||
{
|
||||
public LdapTransientException(string message, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal class LdapOperationException : Exception
|
||||
{
|
||||
public LdapOperationException(string message, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Ldap.Connections;
|
||||
using StellaOps.Authority.Plugin.Ldap.Monitoring;
|
||||
using StellaOps.Authority.Plugin.Ldap.Security;
|
||||
using StellaOps.Cryptography.Audit;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap.Credentials;
|
||||
|
||||
internal sealed class LdapCredentialStore : IUserCredentialStore
|
||||
{
|
||||
private static readonly TimeSpan BaseDelay = TimeSpan.FromMilliseconds(150);
|
||||
private const int MaxAttempts = 3;
|
||||
|
||||
private readonly string pluginName;
|
||||
private readonly IOptionsMonitor<LdapPluginOptions> optionsMonitor;
|
||||
private readonly ILdapConnectionFactory connectionFactory;
|
||||
private readonly ILogger<LdapCredentialStore> logger;
|
||||
private readonly LdapMetrics metrics;
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> delayAsync;
|
||||
|
||||
public LdapCredentialStore(
|
||||
string pluginName,
|
||||
IOptionsMonitor<LdapPluginOptions> optionsMonitor,
|
||||
ILdapConnectionFactory connectionFactory,
|
||||
ILogger<LdapCredentialStore> logger,
|
||||
LdapMetrics metrics,
|
||||
Func<TimeSpan, CancellationToken, Task>? delayAsync = null)
|
||||
{
|
||||
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
this.metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
this.delayAsync = delayAsync ?? ((delay, token) => Task.Delay(delay, token));
|
||||
}
|
||||
|
||||
public async ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(
|
||||
string username,
|
||||
string password,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var auditProperties = new List<AuthEventProperty>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password))
|
||||
{
|
||||
return AuthorityCredentialVerificationResult.Failure(
|
||||
AuthorityCredentialFailureCode.InvalidCredentials,
|
||||
"Invalid credentials.",
|
||||
auditProperties: auditProperties);
|
||||
}
|
||||
|
||||
var normalizedUsername = NormalizeUsername(username);
|
||||
var options = optionsMonitor.Get(pluginName);
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = await connectionFactory.CreateAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await EnsureServiceBindAsync(connection, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var userEntry = await ResolveUserEntryAsync(
|
||||
connection,
|
||||
options,
|
||||
normalizedUsername,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (userEntry is null)
|
||||
{
|
||||
logger.LogWarning("LDAP plugin {Plugin} could not find user {Username}.", pluginName, normalizedUsername);
|
||||
return AuthorityCredentialVerificationResult.Failure(
|
||||
AuthorityCredentialFailureCode.InvalidCredentials,
|
||||
"Invalid credentials.",
|
||||
auditProperties: auditProperties);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await ExecuteWithRetryAsync<bool>(
|
||||
"user_bind",
|
||||
async ct =>
|
||||
{
|
||||
await connection.BindAsync(userEntry.DistinguishedName, password, ct).ConfigureAwait(false);
|
||||
return true;
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (LdapAuthenticationException)
|
||||
{
|
||||
logger.LogWarning("LDAP plugin {Plugin} received invalid credentials for {Username}.", pluginName, normalizedUsername);
|
||||
return AuthorityCredentialVerificationResult.Failure(
|
||||
AuthorityCredentialFailureCode.InvalidCredentials,
|
||||
"Invalid credentials.",
|
||||
auditProperties: auditProperties);
|
||||
}
|
||||
|
||||
var descriptor = BuildDescriptor(userEntry, normalizedUsername, passwordRequiresReset: false);
|
||||
return AuthorityCredentialVerificationResult.Success(descriptor, auditProperties: auditProperties);
|
||||
}
|
||||
catch (LdapTransientException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "LDAP plugin {Plugin} experienced transient failure when verifying user {Username}.", pluginName, normalizedUsername);
|
||||
return AuthorityCredentialVerificationResult.Failure(
|
||||
AuthorityCredentialFailureCode.UnknownError,
|
||||
"Authentication service temporarily unavailable.",
|
||||
retryAfter: TimeSpan.FromSeconds(5),
|
||||
auditProperties: auditProperties);
|
||||
}
|
||||
catch (LdapOperationException ex)
|
||||
{
|
||||
logger.LogError(ex, "LDAP plugin {Plugin} failed to verify user {Username} due to an LDAP error.", pluginName, normalizedUsername);
|
||||
return AuthorityCredentialVerificationResult.Failure(
|
||||
AuthorityCredentialFailureCode.UnknownError,
|
||||
"Authentication service error.",
|
||||
auditProperties: auditProperties);
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(
|
||||
AuthorityUserRegistration registration,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return ValueTask.FromResult(AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure(
|
||||
"not_supported",
|
||||
"LDAP identity provider does not support provisioning users."));
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = subjectId;
|
||||
_ = cancellationToken;
|
||||
return ValueTask.FromResult<AuthorityUserDescriptor?>(null);
|
||||
}
|
||||
|
||||
private async Task EnsureServiceBindAsync(ILdapConnectionHandle connection, LdapPluginOptions options, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Connection.BindDn))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var secret = LdapSecretResolver.Resolve(options.Connection.BindPasswordSecret);
|
||||
await ExecuteWithRetryAsync<bool>(
|
||||
"service_bind",
|
||||
async ct =>
|
||||
{
|
||||
await connection.BindAsync(options.Connection.BindDn!, secret, ct).ConfigureAwait(false);
|
||||
return true;
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<LdapSearchEntry?> ResolveUserEntryAsync(
|
||||
ILdapConnectionHandle connection,
|
||||
LdapPluginOptions options,
|
||||
string normalizedUsername,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.Connection.UserDnFormat))
|
||||
{
|
||||
var dn = BuildUserDistinguishedName(options.Connection.UserDnFormat!, normalizedUsername);
|
||||
return new LdapSearchEntry(dn, new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var searchBase = options.Connection.SearchBase;
|
||||
var usernameAttribute = options.Connection.UsernameAttribute;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(searchBase) || string.IsNullOrWhiteSpace(usernameAttribute))
|
||||
{
|
||||
logger.LogError(
|
||||
"LDAP plugin {Plugin} missing searchBase/usernameAttribute configuration for user {Username} lookup.",
|
||||
pluginName,
|
||||
normalizedUsername);
|
||||
return null;
|
||||
}
|
||||
|
||||
var filter = BuildUserFilter(options, normalizedUsername);
|
||||
var attributes = options.Queries.Attributes.Length > 0
|
||||
? options.Queries.Attributes
|
||||
: new[] { "displayName", "cn", "mail" };
|
||||
|
||||
return await ExecuteWithRetryAsync(
|
||||
"lookup",
|
||||
ct => connection.FindEntryAsync(searchBase, filter, attributes, ct),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<T> ExecuteWithRetryAsync<T>(
|
||||
string operation,
|
||||
Func<CancellationToken, ValueTask<T>> action,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var attempt = 0;
|
||||
Exception? lastException = null;
|
||||
|
||||
while (attempt < MaxAttempts)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
return await action(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (LdapTransientException ex)
|
||||
{
|
||||
lastException = ex;
|
||||
attempt++;
|
||||
metrics.RecordRetry();
|
||||
|
||||
if (attempt >= MaxAttempts)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var delay = TimeSpan.FromMilliseconds(BaseDelay.TotalMilliseconds * Math.Pow(2, attempt - 1));
|
||||
logger.LogWarning(ex, "LDAP operation {Operation} transient failure (attempt {Attempt}/{MaxAttempts}).", operation, attempt, MaxAttempts);
|
||||
await delayAsync(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
throw new LdapTransientException($"LDAP operation '{operation}' failed after {MaxAttempts} attempts.", lastException);
|
||||
}
|
||||
|
||||
private static string NormalizeUsername(string username)
|
||||
=> username.Trim().ToLowerInvariant();
|
||||
|
||||
private static string BuildUserDistinguishedName(string template, string username)
|
||||
=> template.Replace("{username}", EscapeDnValue(username), StringComparison.Ordinal);
|
||||
|
||||
private static string EscapeDnValue(string value)
|
||||
{
|
||||
var needsEscape = value.Any(static ch => ch is ',' or '+' or '"' or '\\' or '<' or '>' or ';' or '#' or '=' || char.IsWhiteSpace(ch));
|
||||
if (!needsEscape)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
return value.Replace("\\", "\\\\", StringComparison.Ordinal)
|
||||
.Replace(",", "\\,", StringComparison.Ordinal)
|
||||
.Replace("+", "\\+", StringComparison.Ordinal)
|
||||
.Replace("\"", "\\\"", StringComparison.Ordinal)
|
||||
.Replace("<", "\\<", StringComparison.Ordinal)
|
||||
.Replace(">", "\\>", StringComparison.Ordinal)
|
||||
.Replace(";", "\\;", StringComparison.Ordinal)
|
||||
.Replace("#", "\\#", StringComparison.Ordinal)
|
||||
.Replace("=", "\\=", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static string BuildUserFilter(LdapPluginOptions options, string username)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.Queries.UserFilter))
|
||||
{
|
||||
return options.Queries.UserFilter.Replace("{username}", EscapeFilterValue(username), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var attribute = options.Connection.UsernameAttribute ?? "uid";
|
||||
return $"({attribute}={EscapeFilterValue(username)})";
|
||||
}
|
||||
|
||||
private static string EscapeFilterValue(string value)
|
||||
{
|
||||
Span<char> buffer = stackalloc char[value.Length * 3];
|
||||
var index = 0;
|
||||
|
||||
foreach (var ch in value)
|
||||
{
|
||||
switch (ch)
|
||||
{
|
||||
case '\\':
|
||||
buffer[index++] = '\\';
|
||||
buffer[index++] = '5';
|
||||
buffer[index++] = 'c';
|
||||
break;
|
||||
case '*':
|
||||
buffer[index++] = '\\';
|
||||
buffer[index++] = '2';
|
||||
buffer[index++] = 'a';
|
||||
break;
|
||||
case '(':
|
||||
buffer[index++] = '\\';
|
||||
buffer[index++] = '2';
|
||||
buffer[index++] = '8';
|
||||
break;
|
||||
case ')':
|
||||
buffer[index++] = '\\';
|
||||
buffer[index++] = '2';
|
||||
buffer[index++] = '9';
|
||||
break;
|
||||
case '\0':
|
||||
buffer[index++] = '\\';
|
||||
buffer[index++] = '0';
|
||||
buffer[index++] = '0';
|
||||
break;
|
||||
default:
|
||||
buffer[index++] = ch;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new string(buffer[..index]);
|
||||
}
|
||||
|
||||
private AuthorityUserDescriptor BuildDescriptor(LdapSearchEntry entry, string normalizedUsername, bool passwordRequiresReset)
|
||||
{
|
||||
var attributes = entry.Attributes;
|
||||
string? displayName = null;
|
||||
|
||||
if (attributes.TryGetValue("displayName", out var displayValues) && displayValues.Count > 0)
|
||||
{
|
||||
displayName = displayValues[0];
|
||||
}
|
||||
else if (attributes.TryGetValue("cn", out var cnValues) && cnValues.Count > 0)
|
||||
{
|
||||
displayName = cnValues[0];
|
||||
}
|
||||
|
||||
var attributeSnapshot = attributes.ToDictionary(
|
||||
pair => pair.Key,
|
||||
pair => (string?)string.Join(",", pair.Value),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return new AuthorityUserDescriptor(
|
||||
entry.DistinguishedName,
|
||||
normalizedUsername,
|
||||
displayName,
|
||||
passwordRequiresReset,
|
||||
Array.Empty<string>(),
|
||||
attributeSnapshot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Ldap.Claims;
|
||||
using StellaOps.Authority.Plugin.Ldap.Connections;
|
||||
using StellaOps.Authority.Plugin.Ldap.Credentials;
|
||||
using StellaOps.Authority.Plugin.Ldap.Security;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap;
|
||||
|
||||
internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
{
|
||||
private readonly AuthorityPluginContext pluginContext;
|
||||
private readonly LdapCredentialStore credentialStore;
|
||||
private readonly LdapClaimsEnricher claimsEnricher;
|
||||
private readonly ILdapConnectionFactory connectionFactory;
|
||||
private readonly IOptionsMonitor<LdapPluginOptions> optionsMonitor;
|
||||
private readonly ILogger<LdapIdentityProviderPlugin> logger;
|
||||
|
||||
private readonly AuthorityIdentityProviderCapabilities capabilities = new(true, false, false);
|
||||
|
||||
public LdapIdentityProviderPlugin(
|
||||
AuthorityPluginContext pluginContext,
|
||||
LdapCredentialStore credentialStore,
|
||||
LdapClaimsEnricher claimsEnricher,
|
||||
ILdapConnectionFactory connectionFactory,
|
||||
IOptionsMonitor<LdapPluginOptions> optionsMonitor,
|
||||
ILogger<LdapIdentityProviderPlugin> logger)
|
||||
{
|
||||
this.pluginContext = pluginContext ?? throw new ArgumentNullException(nameof(pluginContext));
|
||||
this.credentialStore = credentialStore ?? throw new ArgumentNullException(nameof(credentialStore));
|
||||
this.claimsEnricher = claimsEnricher ?? throw new ArgumentNullException(nameof(claimsEnricher));
|
||||
this.connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public string Name => pluginContext.Manifest.Name;
|
||||
|
||||
public string Type => pluginContext.Manifest.Type;
|
||||
|
||||
public AuthorityPluginContext Context => pluginContext;
|
||||
|
||||
public IUserCredentialStore Credentials => credentialStore;
|
||||
|
||||
public IClaimsEnricher ClaimsEnricher => claimsEnricher;
|
||||
|
||||
public IClientProvisioningStore? ClientProvisioning => null;
|
||||
|
||||
public AuthorityIdentityProviderCapabilities Capabilities => capabilities;
|
||||
|
||||
public async ValueTask<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = await connectionFactory.CreateAsync(cancellationToken).ConfigureAwait(false);
|
||||
var options = optionsMonitor.Get(Name);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.Connection.BindDn))
|
||||
{
|
||||
var secret = LdapSecretResolver.Resolve(options.Connection.BindPasswordSecret);
|
||||
await connection.BindAsync(options.Connection.BindDn!, secret, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return AuthorityPluginHealthResult.Healthy();
|
||||
}
|
||||
catch (LdapAuthenticationException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "LDAP plugin {Plugin} service bind failed during health check.", Name);
|
||||
return AuthorityPluginHealthResult.Degraded("Service bind failed: check credentials.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "LDAP plugin {Plugin} health check failed.", Name);
|
||||
return AuthorityPluginHealthResult.Degraded(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap;
|
||||
|
||||
internal sealed class LdapPluginOptions
|
||||
{
|
||||
public LdapConnectionOptions Connection { get; set; } = new();
|
||||
|
||||
public LdapSecurityOptions Security { get; set; } = new();
|
||||
|
||||
public LdapQueryOptions Queries { get; set; } = new();
|
||||
|
||||
public void Normalize(string configPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configPath);
|
||||
|
||||
Connection.Normalize(configPath);
|
||||
Security.Normalize();
|
||||
Queries.Normalize();
|
||||
}
|
||||
|
||||
public void Validate(string pluginName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginName);
|
||||
|
||||
Connection.Validate(pluginName);
|
||||
Security.Validate(pluginName);
|
||||
Queries.Validate(pluginName);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LdapConnectionOptions
|
||||
{
|
||||
public string? Host { get; set; }
|
||||
|
||||
public int Port { get; set; } = 636;
|
||||
|
||||
public bool UseStartTls { get; set; }
|
||||
|
||||
public bool ValidateCertificates { get; set; } = true;
|
||||
|
||||
public LdapClientCertificateOptions? ClientCertificate { get; set; }
|
||||
|
||||
public LdapTrustStoreOptions TrustStore { get; set; } = new();
|
||||
|
||||
public string? SearchBase { get; set; }
|
||||
|
||||
public string? UsernameAttribute { get; set; }
|
||||
|
||||
public string? UserDnFormat { get; set; }
|
||||
|
||||
public string? BindDn { get; set; }
|
||||
|
||||
public string? BindPasswordSecret { get; set; }
|
||||
|
||||
internal void Normalize(string configPath)
|
||||
{
|
||||
Host = NormalizeString(Host);
|
||||
SearchBase = NormalizeString(SearchBase);
|
||||
UsernameAttribute = NormalizeString(UsernameAttribute);
|
||||
UserDnFormat = NormalizeString(UserDnFormat);
|
||||
BindDn = NormalizeString(BindDn);
|
||||
BindPasswordSecret = NormalizeString(BindPasswordSecret);
|
||||
|
||||
if (ClientCertificate is { })
|
||||
{
|
||||
ClientCertificate.Normalize(configPath);
|
||||
}
|
||||
|
||||
TrustStore.Normalize(configPath);
|
||||
}
|
||||
|
||||
internal void Validate(string pluginName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Host))
|
||||
{
|
||||
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires connection.host to be configured.");
|
||||
}
|
||||
|
||||
if (Port <= 0 || Port > 65535)
|
||||
{
|
||||
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires connection.port to be between 1 and 65535.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(BindDn))
|
||||
{
|
||||
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires connection.bindDn to be configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(BindPasswordSecret))
|
||||
{
|
||||
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires connection.bindPasswordSecret to be configured.");
|
||||
}
|
||||
|
||||
var hasUserDnFormat = !string.IsNullOrWhiteSpace(UserDnFormat);
|
||||
var hasSearchBase = !string.IsNullOrWhiteSpace(SearchBase);
|
||||
var hasUsernameAttribute = !string.IsNullOrWhiteSpace(UsernameAttribute);
|
||||
|
||||
if (!hasUserDnFormat && (!hasSearchBase || !hasUsernameAttribute))
|
||||
{
|
||||
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires either connection.userDnFormat or both connection.searchBase and connection.usernameAttribute to be configured.");
|
||||
}
|
||||
|
||||
if (ClientCertificate is { } certificate && certificate.IsConfigured)
|
||||
{
|
||||
certificate.Validate(pluginName);
|
||||
}
|
||||
|
||||
TrustStore.Validate(pluginName, ValidateCertificates);
|
||||
}
|
||||
|
||||
private static string? NormalizeString(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
internal sealed class LdapClientCertificateOptions
|
||||
{
|
||||
public string? PfxPath { get; set; }
|
||||
|
||||
public string? PasswordSecret { get; set; }
|
||||
|
||||
public bool SendChain { get; set; } = true;
|
||||
|
||||
public bool IsConfigured =>
|
||||
!string.IsNullOrWhiteSpace(PfxPath) || !string.IsNullOrWhiteSpace(PasswordSecret);
|
||||
|
||||
internal void Normalize(string configPath)
|
||||
{
|
||||
PfxPath = LdapPathUtilities.NormalizePath(PfxPath, configPath);
|
||||
PasswordSecret = string.IsNullOrWhiteSpace(PasswordSecret) ? null : PasswordSecret.Trim();
|
||||
}
|
||||
|
||||
internal void Validate(string pluginName)
|
||||
{
|
||||
if (!IsConfigured)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(PfxPath))
|
||||
{
|
||||
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires connection.clientCertificate.pfxPath when client certificates are enabled.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(PasswordSecret))
|
||||
{
|
||||
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires connection.clientCertificate.passwordSecret when client certificates are enabled.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LdapTrustStoreOptions
|
||||
{
|
||||
public LdapTrustStoreMode Mode { get; set; } = LdapTrustStoreMode.System;
|
||||
|
||||
public string? BundlePath { get; set; }
|
||||
|
||||
internal void Normalize(string configPath)
|
||||
{
|
||||
if (Mode == LdapTrustStoreMode.Bundle)
|
||||
{
|
||||
BundlePath = LdapPathUtilities.NormalizePath(BundlePath, configPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
BundlePath = null;
|
||||
}
|
||||
}
|
||||
|
||||
internal void Validate(string pluginName, bool validateCertificates)
|
||||
{
|
||||
if (!validateCertificates)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Mode == LdapTrustStoreMode.Bundle && string.IsNullOrWhiteSpace(BundlePath))
|
||||
{
|
||||
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires connection.trustStore.bundlePath when trustStore.mode is 'bundle'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal enum LdapTrustStoreMode
|
||||
{
|
||||
System,
|
||||
Bundle
|
||||
}
|
||||
|
||||
internal sealed class LdapSecurityOptions
|
||||
{
|
||||
private const string AllowInsecureVariable = "STELLAOPS_LDAP_ALLOW_INSECURE";
|
||||
|
||||
public bool RequireTls { get; set; } = true;
|
||||
|
||||
public bool AllowInsecureWithEnvToggle { get; set; }
|
||||
|
||||
public bool ReferralChasing { get; set; }
|
||||
|
||||
public string[] AllowedCipherSuites { get; set; } = Array.Empty<string>();
|
||||
|
||||
internal void Normalize()
|
||||
{
|
||||
AllowedCipherSuites = AllowedCipherSuites?
|
||||
.Where(static suite => !string.IsNullOrWhiteSpace(suite))
|
||||
.Select(static suite => suite.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray() ?? Array.Empty<string>();
|
||||
}
|
||||
|
||||
internal void Validate(string pluginName)
|
||||
{
|
||||
if (RequireTls)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!AllowInsecureWithEnvToggle)
|
||||
{
|
||||
throw new InvalidOperationException($"LDAP plugin '{pluginName}' cannot disable TLS unless security.allowInsecureWithEnvToggle is true and environment variable {AllowInsecureVariable}=true.");
|
||||
}
|
||||
|
||||
var envValue = Environment.GetEnvironmentVariable(AllowInsecureVariable);
|
||||
if (!string.Equals(envValue, "true", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires environment variable {AllowInsecureVariable}=true to allow insecure connections.");
|
||||
}
|
||||
}
|
||||
|
||||
public static string AllowInsecureEnvironmentVariable => AllowInsecureVariable;
|
||||
}
|
||||
|
||||
internal static class LdapPathUtilities
|
||||
{
|
||||
public static string? NormalizePath(string? path, string configPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var trimmed = path.Trim();
|
||||
|
||||
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) && uri.IsFile)
|
||||
{
|
||||
trimmed = uri.LocalPath;
|
||||
}
|
||||
else if (trimmed.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (Uri.TryCreate(trimmed, UriKind.Absolute, out var fileUri))
|
||||
{
|
||||
trimmed = fileUri.LocalPath;
|
||||
}
|
||||
else
|
||||
{
|
||||
trimmed = trimmed["file:".Length..].TrimStart('/');
|
||||
}
|
||||
}
|
||||
|
||||
var expanded = Environment.ExpandEnvironmentVariables(trimmed);
|
||||
string candidate;
|
||||
|
||||
if (Path.IsPathRooted(expanded))
|
||||
{
|
||||
candidate = expanded;
|
||||
}
|
||||
else
|
||||
{
|
||||
var baseDirectory = Path.GetDirectoryName(configPath);
|
||||
if (string.IsNullOrEmpty(baseDirectory))
|
||||
{
|
||||
baseDirectory = Directory.GetCurrentDirectory();
|
||||
}
|
||||
|
||||
candidate = Path.Combine(baseDirectory, expanded);
|
||||
}
|
||||
|
||||
return Path.GetFullPath(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LdapQueryOptions
|
||||
{
|
||||
public string? UserFilter { get; set; }
|
||||
|
||||
public string[] Attributes { get; set; } = Array.Empty<string>();
|
||||
|
||||
internal void Normalize()
|
||||
{
|
||||
Attributes = Attributes?
|
||||
.Where(static attribute => !string.IsNullOrWhiteSpace(attribute))
|
||||
.Select(static attribute => attribute.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray() ?? Array.Empty<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(UserFilter))
|
||||
{
|
||||
UserFilter = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
UserFilter = UserFilter.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
internal void Validate(string pluginName)
|
||||
{
|
||||
if (UserFilter is { Length: > 0 } && !UserFilter.Contains("{username}", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires queries.userFilter to include '{{username}}' placeholder when configured.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Authority.Plugins.Abstractions;
|
||||
using StellaOps.Authority.Plugin.Ldap.Claims;
|
||||
using StellaOps.Authority.Plugin.Ldap.Connections;
|
||||
using StellaOps.Authority.Plugin.Ldap.Credentials;
|
||||
using StellaOps.Authority.Plugin.Ldap.Monitoring;
|
||||
using StellaOps.Authority.Plugin.Ldap.Security;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap;
|
||||
|
||||
internal sealed class LdapPluginRegistrar : IAuthorityPluginRegistrar
|
||||
{
|
||||
public string PluginType => "ldap";
|
||||
|
||||
public void Register(AuthorityPluginRegistrationContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var pluginManifest = context.Plugin.Manifest;
|
||||
var pluginName = pluginManifest.Name;
|
||||
var configPath = pluginManifest.ConfigPath;
|
||||
|
||||
context.Services.AddOptions<LdapPluginOptions>(pluginName)
|
||||
.Bind(context.Plugin.Configuration)
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
options.Normalize(configPath);
|
||||
options.Validate(pluginName);
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
context.Services.AddSingleton(_ => new LdapMetrics(pluginName));
|
||||
|
||||
context.Services.AddSingleton<ILdapConnectionFactory>(sp => new DirectoryServicesLdapConnectionFactory(
|
||||
pluginName,
|
||||
sp.GetRequiredService<IOptionsMonitor<LdapPluginOptions>>(),
|
||||
sp.GetRequiredService<ILogger<DirectoryServicesLdapConnectionFactory>>(),
|
||||
sp.GetRequiredService<LdapMetrics>()));
|
||||
|
||||
context.Services.AddScoped(sp => new LdapCredentialStore(
|
||||
pluginName,
|
||||
sp.GetRequiredService<IOptionsMonitor<LdapPluginOptions>>(),
|
||||
sp.GetRequiredService<ILdapConnectionFactory>(),
|
||||
sp.GetRequiredService<ILogger<LdapCredentialStore>>(),
|
||||
sp.GetRequiredService<LdapMetrics>()));
|
||||
|
||||
context.Services.AddScoped<LdapClaimsEnricher>();
|
||||
context.Services.AddScoped<IClaimsEnricher>(sp => sp.GetRequiredService<LdapClaimsEnricher>());
|
||||
|
||||
context.Services.AddScoped<IUserCredentialStore>(sp => sp.GetRequiredService<LdapCredentialStore>());
|
||||
|
||||
context.Services.AddScoped<IIdentityProviderPlugin>(sp => new LdapIdentityProviderPlugin(
|
||||
context.Plugin,
|
||||
sp.GetRequiredService<LdapCredentialStore>(),
|
||||
sp.GetRequiredService<LdapClaimsEnricher>(),
|
||||
sp.GetRequiredService<ILdapConnectionFactory>(),
|
||||
sp.GetRequiredService<IOptionsMonitor<LdapPluginOptions>>(),
|
||||
sp.GetRequiredService<ILogger<LdapIdentityProviderPlugin>>()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap.Monitoring;
|
||||
|
||||
internal sealed class LdapMetrics
|
||||
{
|
||||
private const string MeterName = "StellaOps.Authority.Plugin.Ldap";
|
||||
|
||||
private static readonly Meter Meter = new(MeterName);
|
||||
|
||||
private readonly string pluginName;
|
||||
private readonly Counter<long> bindAttempts;
|
||||
private readonly Counter<long> bindFailures;
|
||||
private readonly Counter<long> searchAttempts;
|
||||
private readonly Counter<long> searchFailures;
|
||||
private readonly Counter<long> retryCounter;
|
||||
private readonly Counter<long> bindSuccesses;
|
||||
private readonly Counter<long> searchHits;
|
||||
private readonly Counter<long> searchMisses;
|
||||
|
||||
public LdapMetrics(string pluginName)
|
||||
{
|
||||
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
|
||||
bindAttempts = Meter.CreateCounter<long>("ldap.bind.attempts");
|
||||
bindSuccesses = Meter.CreateCounter<long>("ldap.bind.successes");
|
||||
bindFailures = Meter.CreateCounter<long>("ldap.bind.failures");
|
||||
searchAttempts = Meter.CreateCounter<long>("ldap.search.attempts");
|
||||
searchHits = Meter.CreateCounter<long>("ldap.search.hits");
|
||||
searchMisses = Meter.CreateCounter<long>("ldap.search.misses");
|
||||
searchFailures = Meter.CreateCounter<long>("ldap.search.failures");
|
||||
retryCounter = Meter.CreateCounter<long>("ldap.operation.retries");
|
||||
}
|
||||
|
||||
public void RecordBindAttempt() => bindAttempts.Add(1, KeyValuePair.Create<string, object?>("plugin", pluginName));
|
||||
|
||||
public void RecordBindSuccess() => bindSuccesses.Add(1, KeyValuePair.Create<string, object?>("plugin", pluginName));
|
||||
|
||||
public void RecordBindFailure() => bindFailures.Add(1, KeyValuePair.Create<string, object?>("plugin", pluginName));
|
||||
|
||||
public void RecordSearchAttempt() => searchAttempts.Add(1, KeyValuePair.Create<string, object?>("plugin", pluginName));
|
||||
|
||||
public void RecordSearchHit() => searchHits.Add(1, KeyValuePair.Create<string, object?>("plugin", pluginName));
|
||||
|
||||
public void RecordSearchMiss() => searchMisses.Add(1, KeyValuePair.Create<string, object?>("plugin", pluginName));
|
||||
|
||||
public void RecordSearchFailure() => searchFailures.Add(1, KeyValuePair.Create<string, object?>("plugin", pluginName));
|
||||
|
||||
public void RecordRetry() => retryCounter.Add(1, KeyValuePair.Create<string, object?>("plugin", pluginName));
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("StellaOps.Authority.Plugin.Ldap.Tests")]
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap.Security;
|
||||
|
||||
internal static class LdapSecretResolver
|
||||
{
|
||||
public static string Resolve(string? reference)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reference))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var trimmed = reference.Trim();
|
||||
|
||||
if (trimmed.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var path = trimmed[5..];
|
||||
return File.Exists(path) ? File.ReadAllText(path).Trim() : string.Empty;
|
||||
}
|
||||
|
||||
if (trimmed.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var variable = trimmed[4..];
|
||||
return Environment.GetEnvironmentVariable(variable)?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<IsAuthorityPlugin>true</IsAuthorityPlugin>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" />
|
||||
<PackageReference Include="System.DirectoryServices.Protocols" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\\StellaOps.Authority.Plugins.Abstractions\\StellaOps.Authority.Plugins.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\\StellaOps.Auth.Abstractions\\StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Plugin\\StellaOps.Plugin.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user