save progress
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Authority.Plugin.Ldap.Connections;
|
||||
using StellaOps.Authority.Plugin.Ldap.Security;
|
||||
@@ -9,8 +10,6 @@ namespace StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
|
||||
|
||||
internal sealed class LdapCapabilityProbe
|
||||
{
|
||||
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly string pluginName;
|
||||
private readonly ILdapConnectionFactory connectionFactory;
|
||||
private readonly ILogger logger;
|
||||
@@ -25,7 +24,12 @@ internal sealed class LdapCapabilityProbe
|
||||
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public LdapCapabilitySnapshot Evaluate(LdapPluginOptions options, bool checkClientProvisioning, bool checkBootstrap)
|
||||
public async ValueTask<LdapCapabilitySnapshot> EvaluateAsync(
|
||||
LdapPluginOptions options,
|
||||
bool checkClientProvisioning,
|
||||
bool checkBootstrap,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!checkClientProvisioning && !checkBootstrap)
|
||||
{
|
||||
@@ -37,35 +41,30 @@ internal sealed class LdapCapabilityProbe
|
||||
|
||||
try
|
||||
{
|
||||
using var timeoutCts = new CancellationTokenSource(DefaultTimeout);
|
||||
var cancellationToken = timeoutCts.Token;
|
||||
var connection = connectionFactory.CreateAsync(cancellationToken).GetAwaiter().GetResult();
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(timeout);
|
||||
var timeoutToken = timeoutCts.Token;
|
||||
|
||||
try
|
||||
await using var connection = await connectionFactory.CreateAsync(timeoutToken).ConfigureAwait(false);
|
||||
|
||||
await MaybeBindServiceAccountAsync(connection, options, timeoutToken).ConfigureAwait(false);
|
||||
|
||||
if (checkClientProvisioning)
|
||||
{
|
||||
MaybeBindServiceAccount(connection, options, cancellationToken);
|
||||
|
||||
if (checkClientProvisioning)
|
||||
{
|
||||
clientProvisioningWritable = TryProbeContainer(
|
||||
connection,
|
||||
options.ClientProvisioning.ContainerDn,
|
||||
options.ClientProvisioning.RdnAttribute,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (checkBootstrap)
|
||||
{
|
||||
bootstrapWritable = TryProbeContainer(
|
||||
connection,
|
||||
options.Bootstrap.ContainerDn,
|
||||
options.Bootstrap.RdnAttribute,
|
||||
cancellationToken);
|
||||
}
|
||||
clientProvisioningWritable = await TryProbeContainerAsync(
|
||||
connection,
|
||||
options.ClientProvisioning.ContainerDn,
|
||||
options.ClientProvisioning.RdnAttribute,
|
||||
timeoutToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
|
||||
if (checkBootstrap)
|
||||
{
|
||||
connection.DisposeAsync().GetAwaiter().GetResult();
|
||||
bootstrapWritable = await TryProbeContainerAsync(
|
||||
connection,
|
||||
options.Bootstrap.ContainerDn,
|
||||
options.Bootstrap.RdnAttribute,
|
||||
timeoutToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is LdapOperationException or LdapTransientException)
|
||||
@@ -87,7 +86,10 @@ internal sealed class LdapCapabilityProbe
|
||||
return new LdapCapabilitySnapshot(clientProvisioningWritable, bootstrapWritable);
|
||||
}
|
||||
|
||||
private void MaybeBindServiceAccount(ILdapConnectionHandle connection, LdapPluginOptions options, CancellationToken cancellationToken)
|
||||
private static async ValueTask MaybeBindServiceAccountAsync(
|
||||
ILdapConnectionHandle connection,
|
||||
LdapPluginOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.Connection.BindDn))
|
||||
{
|
||||
@@ -95,10 +97,10 @@ internal sealed class LdapCapabilityProbe
|
||||
}
|
||||
|
||||
var secret = LdapSecretResolver.Resolve(options.Connection.BindPasswordSecret);
|
||||
connection.BindAsync(options.Connection.BindDn!, secret, cancellationToken).GetAwaiter().GetResult();
|
||||
await connection.BindAsync(options.Connection.BindDn!, secret, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private bool TryProbeContainer(
|
||||
private async ValueTask<bool> TryProbeContainerAsync(
|
||||
ILdapConnectionHandle connection,
|
||||
string? containerDn,
|
||||
string rdnAttribute,
|
||||
@@ -125,8 +127,8 @@ internal sealed class LdapCapabilityProbe
|
||||
|
||||
try
|
||||
{
|
||||
connection.AddEntryAsync(distinguishedName, attributes, cancellationToken).GetAwaiter().GetResult();
|
||||
connection.DeleteEntryAsync(distinguishedName, cancellationToken).GetAwaiter().GetResult();
|
||||
await connection.AddEntryAsync(distinguishedName, attributes, cancellationToken).ConfigureAwait(false);
|
||||
await connection.DeleteEntryAsync(distinguishedName, cancellationToken).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
catch (LdapInsufficientAccessException ex)
|
||||
@@ -141,15 +143,15 @@ internal sealed class LdapCapabilityProbe
|
||||
}
|
||||
finally
|
||||
{
|
||||
TryDeleteProbeEntry(connection, distinguishedName, cancellationToken);
|
||||
await TryDeleteProbeEntryAsync(connection, distinguishedName, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDeleteProbeEntry(ILdapConnectionHandle connection, string dn, CancellationToken cancellationToken)
|
||||
private static async ValueTask TryDeleteProbeEntryAsync(ILdapConnectionHandle connection, string dn, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
connection.DeleteEntryAsync(dn, cancellationToken).GetAwaiter().GetResult();
|
||||
await connection.DeleteEntryAsync(dn, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
|
||||
|
||||
@@ -7,12 +9,62 @@ internal sealed record LdapCapabilitySnapshot(bool ClientProvisioningWritable, b
|
||||
|
||||
internal static class LdapCapabilitySnapshotCache
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, LdapCapabilitySnapshot> Cache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly ConcurrentDictionary<string, CacheEntry> Cache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static LdapCapabilitySnapshot GetOrAdd(string pluginName, Func<LdapCapabilitySnapshot> factory)
|
||||
public static bool TryGet(string pluginName, string fingerprint, DateTimeOffset now, out LdapCapabilitySnapshot snapshot)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginName);
|
||||
ArgumentNullException.ThrowIfNull(factory);
|
||||
return Cache.GetOrAdd(pluginName, _ => factory());
|
||||
|
||||
if (Cache.TryGetValue(pluginName, out var entry) &&
|
||||
string.Equals(entry.Fingerprint, fingerprint, StringComparison.Ordinal) &&
|
||||
entry.ExpiresAt > now)
|
||||
{
|
||||
snapshot = entry.Snapshot;
|
||||
return true;
|
||||
}
|
||||
|
||||
snapshot = new LdapCapabilitySnapshot(false, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void Set(string pluginName, string fingerprint, DateTimeOffset now, TimeSpan ttl, LdapCapabilitySnapshot snapshot)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(pluginName);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(fingerprint);
|
||||
|
||||
var entry = new CacheEntry(snapshot, fingerprint, now.Add(ttl));
|
||||
Cache.AddOrUpdate(pluginName, entry, (_, _) => entry);
|
||||
}
|
||||
|
||||
public static string ComputeFingerprint(LdapPluginOptions options, bool checkClientProvisioning, bool checkBootstrap)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
var builder = new StringBuilder();
|
||||
Append(builder, options.Connection.Host);
|
||||
Append(builder, options.Connection.Port.ToString());
|
||||
Append(builder, options.Connection.UseStartTls.ToString());
|
||||
Append(builder, options.Connection.BindDn);
|
||||
Append(builder, options.Connection.BindPasswordSecret);
|
||||
Append(builder, options.ClientProvisioning.Enabled.ToString());
|
||||
Append(builder, options.ClientProvisioning.ContainerDn);
|
||||
Append(builder, options.ClientProvisioning.RdnAttribute);
|
||||
Append(builder, options.Bootstrap.Enabled.ToString());
|
||||
Append(builder, options.Bootstrap.ContainerDn);
|
||||
Append(builder, options.Bootstrap.RdnAttribute);
|
||||
Append(builder, checkClientProvisioning.ToString());
|
||||
Append(builder, checkBootstrap.ToString());
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
|
||||
var hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static void Append(StringBuilder builder, string? value)
|
||||
{
|
||||
builder.Append(value ?? string.Empty);
|
||||
builder.Append('|');
|
||||
}
|
||||
|
||||
private sealed record CacheEntry(LdapCapabilitySnapshot Snapshot, string Fingerprint, DateTimeOffset ExpiresAt);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
|
||||
|
||||
@@ -58,6 +60,39 @@ internal static class LdapDistinguishedNameHelper
|
||||
.Replace("\0", "\\00", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public static string UnescapeRdnValue(string value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value) || !value.Contains('\\'))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(value.Length);
|
||||
for (var i = 0; i < value.Length; i++)
|
||||
{
|
||||
var ch = value[i];
|
||||
if (ch != '\\' || i == value.Length - 1)
|
||||
{
|
||||
builder.Append(ch);
|
||||
continue;
|
||||
}
|
||||
|
||||
var next = value[i + 1];
|
||||
if (i + 2 < value.Length && IsHex(next) && IsHex(value[i + 2]))
|
||||
{
|
||||
var hex = $"{next}{value[i + 2]}";
|
||||
builder.Append((char)int.Parse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture));
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Append(next);
|
||||
i++;
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static bool HasSpecial(ReadOnlySpan<char> chars)
|
||||
{
|
||||
foreach (var c in chars)
|
||||
@@ -70,4 +105,9 @@ internal static class LdapDistinguishedNameHelper
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsHex(char value)
|
||||
=> value is >= '0' and <= '9'
|
||||
or >= 'a' and <= 'f'
|
||||
or >= 'A' and <= 'F';
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ internal sealed class DirectoryServicesLdapConnectionFactory : ILdapConnectionFa
|
||||
var identifier = new LdapDirectoryIdentifier(connectionOptions.Host!, connectionOptions.Port, fullyQualifiedDnsHostName: false, connectionless: false);
|
||||
var connection = new LdapConnection(identifier)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(10)
|
||||
Timeout = TimeSpan.FromSeconds(connectionOptions.TimeoutSeconds)
|
||||
};
|
||||
|
||||
connection.SessionOptions.ProtocolVersion = 3;
|
||||
|
||||
@@ -239,11 +239,50 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
|
||||
}
|
||||
}
|
||||
|
||||
public ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)
|
||||
public async ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = subjectId;
|
||||
_ = cancellationToken;
|
||||
return ValueTask.FromResult<AuthorityUserDescriptor?>(null);
|
||||
if (string.IsNullOrWhiteSpace(subjectId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var options = optionsMonitor.Get(pluginName);
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = await connectionFactory.CreateAsync(cancellationToken).ConfigureAwait(false);
|
||||
await EnsureServiceBindAsync(connection, options, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var attributes = BuildLookupAttributes(options);
|
||||
var entry = await ExecuteWithRetryAsync(
|
||||
"subject_lookup",
|
||||
ct => connection.FindEntryAsync(subjectId, "(objectClass=*)", attributes, ct),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resolvedUsername = ResolveUsername(entry, options, subjectId);
|
||||
if (string.IsNullOrWhiteSpace(resolvedUsername))
|
||||
{
|
||||
resolvedUsername = subjectId;
|
||||
}
|
||||
|
||||
return BuildDescriptor(entry, NormalizeUsername(resolvedUsername), passwordRequiresReset: false);
|
||||
}
|
||||
catch (LdapTransientException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "LDAP plugin {Plugin} transient failure while resolving subject {SubjectId}.", pluginName, subjectId);
|
||||
return null;
|
||||
}
|
||||
catch (LdapOperationException ex)
|
||||
{
|
||||
logger.LogError(ex, "LDAP plugin {Plugin} failed to resolve subject {SubjectId}.", pluginName, subjectId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureServiceBindAsync(ILdapConnectionHandle connection, LdapPluginOptions options, CancellationToken cancellationToken)
|
||||
@@ -358,79 +397,59 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
|
||||
=> 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);
|
||||
}
|
||||
=> template.Replace("{username}", LdapDistinguishedNameHelper.EscapeRdnValue(username), 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);
|
||||
return options.Queries.UserFilter.Replace("{username}", LdapDistinguishedNameHelper.EscapeFilterValue(username), StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
var attribute = options.Connection.UsernameAttribute ?? "uid";
|
||||
return $"({attribute}={EscapeFilterValue(username)})";
|
||||
return $"({attribute}={LdapDistinguishedNameHelper.EscapeFilterValue(username)})";
|
||||
}
|
||||
|
||||
private static string EscapeFilterValue(string value)
|
||||
|
||||
private static IReadOnlyCollection<string> BuildLookupAttributes(LdapPluginOptions options)
|
||||
{
|
||||
Span<char> buffer = stackalloc char[value.Length * 3];
|
||||
var index = 0;
|
||||
var attributes = options.Queries.Attributes.Length > 0
|
||||
? new List<string>(options.Queries.Attributes)
|
||||
: new List<string> { "displayName", "cn", "mail" };
|
||||
|
||||
foreach (var ch in value)
|
||||
if (!string.IsNullOrWhiteSpace(options.Connection.UsernameAttribute) &&
|
||||
!attributes.Any(attribute => string.Equals(attribute, options.Connection.UsernameAttribute, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
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;
|
||||
}
|
||||
attributes.Add(options.Connection.UsernameAttribute!);
|
||||
}
|
||||
|
||||
return new string(buffer[..index]);
|
||||
return attributes;
|
||||
}
|
||||
|
||||
private static string? ResolveUsername(LdapSearchEntry entry, LdapPluginOptions options, string subjectId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(options.Connection.UsernameAttribute) &&
|
||||
entry.Attributes.TryGetValue(options.Connection.UsernameAttribute!, out var values) &&
|
||||
values.Count > 0 &&
|
||||
!string.IsNullOrWhiteSpace(values[0]))
|
||||
{
|
||||
return values[0];
|
||||
}
|
||||
|
||||
return TryExtractRdnValue(subjectId);
|
||||
}
|
||||
|
||||
private static string? TryExtractRdnValue(string subjectId)
|
||||
{
|
||||
var commaIndex = subjectId.IndexOf(',', StringComparison.Ordinal);
|
||||
var rdn = commaIndex >= 0 ? subjectId[..commaIndex] : subjectId;
|
||||
var equalsIndex = rdn.IndexOf('=', StringComparison.Ordinal);
|
||||
if (equalsIndex <= 0 || equalsIndex >= rdn.Length - 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var value = rdn[(equalsIndex + 1)..];
|
||||
return LdapDistinguishedNameHelper.UnescapeRdnValue(value);
|
||||
}
|
||||
|
||||
private AuthorityUserDescriptor BuildDescriptor(LdapSearchEntry entry, string normalizedUsername, bool passwordRequiresReset)
|
||||
|
||||
@@ -22,10 +22,14 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
private readonly IOptionsMonitor<LdapPluginOptions> optionsMonitor;
|
||||
private readonly LdapClientProvisioningStore clientProvisioningStore;
|
||||
private readonly ILogger<LdapIdentityProviderPlugin> logger;
|
||||
private readonly AuthorityIdentityProviderCapabilities capabilities;
|
||||
private readonly bool clientProvisioningActive;
|
||||
private readonly bool bootstrapActive;
|
||||
private readonly LdapCapabilityProbe capabilityProbe;
|
||||
private readonly AuthorityIdentityProviderCapabilities manifestCapabilities;
|
||||
private readonly SemaphoreSlim capabilityGate = new(1, 1);
|
||||
private AuthorityIdentityProviderCapabilities capabilities;
|
||||
private bool clientProvisioningActive;
|
||||
private bool bootstrapActive;
|
||||
private bool loggedProvisioningDegrade;
|
||||
private bool loggedBootstrapDegrade;
|
||||
|
||||
public LdapIdentityProviderPlugin(
|
||||
AuthorityPluginContext pluginContext,
|
||||
@@ -46,7 +50,7 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
|
||||
capabilityProbe = new LdapCapabilityProbe(pluginContext.Manifest.Name, connectionFactory, logger);
|
||||
|
||||
var manifestCapabilities = AuthorityIdentityProviderCapabilities.FromCapabilities(pluginContext.Manifest.Capabilities);
|
||||
manifestCapabilities = AuthorityIdentityProviderCapabilities.FromCapabilities(pluginContext.Manifest.Capabilities);
|
||||
var pluginOptions = optionsMonitor.Get(pluginContext.Manifest.Name);
|
||||
var provisioningOptions = pluginOptions.ClientProvisioning;
|
||||
var bootstrapOptions = pluginOptions.Bootstrap;
|
||||
@@ -65,40 +69,7 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
pluginContext.Manifest.Name);
|
||||
}
|
||||
|
||||
var snapshot = LdapCapabilitySnapshotCache.GetOrAdd(
|
||||
pluginContext.Manifest.Name,
|
||||
() => capabilityProbe.Evaluate(
|
||||
pluginOptions,
|
||||
manifestCapabilities.SupportsClientProvisioning && provisioningOptions.Enabled,
|
||||
manifestCapabilities.SupportsBootstrap && bootstrapOptions.Enabled));
|
||||
|
||||
clientProvisioningActive = manifestCapabilities.SupportsClientProvisioning
|
||||
&& provisioningOptions.Enabled
|
||||
&& snapshot.ClientProvisioningWritable;
|
||||
|
||||
bootstrapActive = manifestCapabilities.SupportsBootstrap
|
||||
&& bootstrapOptions.Enabled
|
||||
&& snapshot.BootstrapWritable;
|
||||
|
||||
if (manifestCapabilities.SupportsClientProvisioning && provisioningOptions.Enabled && !clientProvisioningActive)
|
||||
{
|
||||
this.logger.LogWarning(
|
||||
"LDAP plugin '{PluginName}' degraded client provisioning capability because LDAP write permissions could not be validated.",
|
||||
pluginContext.Manifest.Name);
|
||||
}
|
||||
|
||||
if (manifestCapabilities.SupportsBootstrap && bootstrapOptions.Enabled && !bootstrapActive)
|
||||
{
|
||||
this.logger.LogWarning(
|
||||
"LDAP plugin '{PluginName}' degraded bootstrap capability because LDAP write permissions could not be validated.",
|
||||
pluginContext.Manifest.Name);
|
||||
}
|
||||
|
||||
capabilities = new AuthorityIdentityProviderCapabilities(
|
||||
SupportsPassword: true,
|
||||
SupportsMfa: manifestCapabilities.SupportsMfa,
|
||||
SupportsClientProvisioning: clientProvisioningActive,
|
||||
SupportsBootstrap: bootstrapActive);
|
||||
InitializeCapabilities(pluginOptions);
|
||||
}
|
||||
|
||||
public string Name => pluginContext.Manifest.Name;
|
||||
@@ -119,6 +90,7 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
{
|
||||
try
|
||||
{
|
||||
await RefreshCapabilitiesAsync(cancellationToken).ConfigureAwait(false);
|
||||
await using var connection = await connectionFactory.CreateAsync(cancellationToken).ConfigureAwait(false);
|
||||
var options = optionsMonitor.Get(Name);
|
||||
|
||||
@@ -129,14 +101,13 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
}
|
||||
|
||||
var degradeReasons = new List<string>();
|
||||
var latestOptions = optionsMonitor.Get(Name);
|
||||
|
||||
if (latestOptions.ClientProvisioning.Enabled && !clientProvisioningActive)
|
||||
if (options.ClientProvisioning.Enabled && !clientProvisioningActive)
|
||||
{
|
||||
degradeReasons.Add("clientProvisioningDisabled");
|
||||
}
|
||||
|
||||
if (latestOptions.Bootstrap.Enabled && !bootstrapActive)
|
||||
if (options.Bootstrap.Enabled && !bootstrapActive)
|
||||
{
|
||||
degradeReasons.Add("bootstrapDisabled");
|
||||
}
|
||||
@@ -164,4 +135,102 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
|
||||
return AuthorityPluginHealthResult.Degraded(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeCapabilities(LdapPluginOptions options)
|
||||
{
|
||||
var checkProvisioning = manifestCapabilities.SupportsClientProvisioning && options.ClientProvisioning.Enabled;
|
||||
var checkBootstrap = manifestCapabilities.SupportsBootstrap && options.Bootstrap.Enabled;
|
||||
var fingerprint = LdapCapabilitySnapshotCache.ComputeFingerprint(options, checkProvisioning, checkBootstrap);
|
||||
|
||||
if (LdapCapabilitySnapshotCache.TryGet(Name, fingerprint, DateTimeOffset.UtcNow, out var snapshot))
|
||||
{
|
||||
UpdateCapabilities(snapshot, checkProvisioning, checkBootstrap, logDegrade: true);
|
||||
}
|
||||
else
|
||||
{
|
||||
UpdateCapabilities(new LdapCapabilitySnapshot(false, false), checkProvisioning, checkBootstrap, logDegrade: false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshCapabilitiesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var options = optionsMonitor.Get(Name);
|
||||
var checkProvisioning = manifestCapabilities.SupportsClientProvisioning && options.ClientProvisioning.Enabled;
|
||||
var checkBootstrap = manifestCapabilities.SupportsBootstrap && options.Bootstrap.Enabled;
|
||||
var fingerprint = LdapCapabilitySnapshotCache.ComputeFingerprint(options, checkProvisioning, checkBootstrap);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
if (LdapCapabilitySnapshotCache.TryGet(Name, fingerprint, now, out var cached))
|
||||
{
|
||||
UpdateCapabilities(cached, checkProvisioning, checkBootstrap, logDegrade: true);
|
||||
return;
|
||||
}
|
||||
|
||||
await capabilityGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (LdapCapabilitySnapshotCache.TryGet(Name, fingerprint, DateTimeOffset.UtcNow, out cached))
|
||||
{
|
||||
UpdateCapabilities(cached, checkProvisioning, checkBootstrap, logDegrade: true);
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = await capabilityProbe.EvaluateAsync(
|
||||
options,
|
||||
checkProvisioning,
|
||||
checkBootstrap,
|
||||
options.CapabilityProbe.Timeout,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
LdapCapabilitySnapshotCache.Set(Name, fingerprint, DateTimeOffset.UtcNow, options.CapabilityProbe.CacheTtl, snapshot);
|
||||
UpdateCapabilities(snapshot, checkProvisioning, checkBootstrap, logDegrade: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
capabilityGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateCapabilities(LdapCapabilitySnapshot snapshot, bool checkProvisioning, bool checkBootstrap, bool logDegrade)
|
||||
{
|
||||
clientProvisioningActive = checkProvisioning && snapshot.ClientProvisioningWritable;
|
||||
bootstrapActive = checkBootstrap && snapshot.BootstrapWritable;
|
||||
|
||||
if (logDegrade && checkProvisioning && !clientProvisioningActive)
|
||||
{
|
||||
if (!loggedProvisioningDegrade)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"LDAP plugin '{PluginName}' degraded client provisioning capability because LDAP write permissions could not be validated.",
|
||||
pluginContext.Manifest.Name);
|
||||
loggedProvisioningDegrade = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
loggedProvisioningDegrade = false;
|
||||
}
|
||||
|
||||
if (logDegrade && checkBootstrap && !bootstrapActive)
|
||||
{
|
||||
if (!loggedBootstrapDegrade)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"LDAP plugin '{PluginName}' degraded bootstrap capability because LDAP write permissions could not be validated.",
|
||||
pluginContext.Manifest.Name);
|
||||
loggedBootstrapDegrade = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
loggedBootstrapDegrade = false;
|
||||
}
|
||||
|
||||
capabilities = new AuthorityIdentityProviderCapabilities(
|
||||
SupportsPassword: true,
|
||||
SupportsMfa: manifestCapabilities.SupportsMfa,
|
||||
SupportsClientProvisioning: clientProvisioningActive,
|
||||
SupportsBootstrap: bootstrapActive);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ internal sealed class LdapPluginOptions
|
||||
|
||||
public LdapBootstrapOptions Bootstrap { get; set; } = new();
|
||||
|
||||
public LdapCapabilityProbeOptions CapabilityProbe { get; set; } = new();
|
||||
|
||||
public void Normalize(string configPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(configPath);
|
||||
@@ -30,6 +32,7 @@ internal sealed class LdapPluginOptions
|
||||
Claims.Normalize();
|
||||
ClientProvisioning.Normalize();
|
||||
Bootstrap.Normalize();
|
||||
CapabilityProbe.Normalize();
|
||||
}
|
||||
|
||||
public void Validate(string pluginName)
|
||||
@@ -42,6 +45,7 @@ internal sealed class LdapPluginOptions
|
||||
Claims.Validate(pluginName);
|
||||
ClientProvisioning.Validate(pluginName);
|
||||
Bootstrap.Validate(pluginName);
|
||||
CapabilityProbe.Validate(pluginName);
|
||||
|
||||
EnsureSecurityRequirements(pluginName);
|
||||
}
|
||||
@@ -73,6 +77,8 @@ internal sealed class LdapConnectionOptions
|
||||
|
||||
public int Port { get; set; } = 636;
|
||||
|
||||
public int TimeoutSeconds { get; set; } = 10;
|
||||
|
||||
public bool UseStartTls { get; set; }
|
||||
|
||||
public bool ValidateCertificates { get; set; } = true;
|
||||
@@ -132,6 +138,11 @@ internal sealed class LdapConnectionOptions
|
||||
}
|
||||
|
||||
TrustStore.Normalize(configPath);
|
||||
|
||||
if (TimeoutSeconds <= 0)
|
||||
{
|
||||
TimeoutSeconds = 10;
|
||||
}
|
||||
}
|
||||
|
||||
internal void Validate(string pluginName)
|
||||
@@ -146,6 +157,11 @@ internal sealed class LdapConnectionOptions
|
||||
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires connection.port to be between 1 and 65535.");
|
||||
}
|
||||
|
||||
if (TimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires connection.timeoutSeconds to be greater than zero.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(BindDn))
|
||||
{
|
||||
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires connection.bindDn to be configured.");
|
||||
@@ -728,3 +744,40 @@ internal sealed class LdapBootstrapOptions
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
internal sealed class LdapCapabilityProbeOptions
|
||||
{
|
||||
public int TimeoutSeconds { get; set; } = 5;
|
||||
|
||||
public int CacheTtlSeconds { get; set; } = 300;
|
||||
|
||||
internal void Normalize()
|
||||
{
|
||||
if (TimeoutSeconds <= 0)
|
||||
{
|
||||
TimeoutSeconds = 5;
|
||||
}
|
||||
|
||||
if (CacheTtlSeconds <= 0)
|
||||
{
|
||||
CacheTtlSeconds = 300;
|
||||
}
|
||||
}
|
||||
|
||||
internal void Validate(string pluginName)
|
||||
{
|
||||
if (TimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires capabilityProbe.timeoutSeconds to be greater than zero.");
|
||||
}
|
||||
|
||||
if (CacheTtlSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires capabilityProbe.cacheTtlSeconds to be greater than zero.");
|
||||
}
|
||||
}
|
||||
|
||||
public TimeSpan Timeout => TimeSpan.FromSeconds(TimeoutSeconds);
|
||||
|
||||
public TimeSpan CacheTtl => TimeSpan.FromSeconds(CacheTtlSeconds);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
|
||||
| --- | --- | --- |
|
||||
| AUDIT-0090-M | DONE | Maintainability audit for StellaOps.Authority.Plugin.Ldap. |
|
||||
| AUDIT-0090-T | DONE | Test coverage audit for StellaOps.Authority.Plugin.Ldap. |
|
||||
| AUDIT-0090-A | TODO | Pending approval for changes. |
|
||||
| AUDIT-0090-A | DONE | Applied LDAP plugin updates, tests, and docs. |
|
||||
|
||||
Reference in New Issue
Block a user