save progress

This commit is contained in:
StellaOps Bot
2026-01-03 00:47:24 +02:00
parent 3f197814c5
commit ca578801fd
319 changed files with 32478 additions and 2202 deletions

View File

@@ -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
{

View File

@@ -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);
}

View File

@@ -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';
}

View File

@@ -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;

View File

@@ -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)

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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. |