Add LDAP Distinguished Name Helper and Credential Audit Context
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled

- Implemented LdapDistinguishedNameHelper for escaping RDN and filter values.
- Created AuthorityCredentialAuditContext and IAuthorityCredentialAuditContextAccessor for managing credential audit context.
- Developed StandardCredentialAuditLogger with tests for success, failure, and lockout events.
- Introduced AuthorityAuditSink for persisting audit records with structured logging.
- Added CryptoPro related classes for certificate resolution and signing operations.
This commit is contained in:
master
2025-11-09 12:21:38 +02:00
parent ba4c935182
commit 75c2bcafce
385 changed files with 7354 additions and 7344 deletions

View File

@@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
namespace StellaOps.Authority.Plugin.Ldap.Bootstrap;
internal sealed class LdapBootstrapAuditDocument
{
[BsonId]
public ObjectId Id { get; set; } = ObjectId.GenerateNewId();
[BsonElement("plugin")]
public string Plugin { get; set; } = string.Empty;
[BsonElement("username")]
public string Username { get; set; } = string.Empty;
[BsonElement("dn")]
public string DistinguishedName { get; set; } = string.Empty;
[BsonElement("operation")]
public string Operation { get; set; } = string.Empty;
[BsonElement("secretHash")]
[BsonIgnoreIfNull]
public string? SecretHash { get; set; }
[BsonElement("timestamp")]
public DateTimeOffset Timestamp { get; set; }
[BsonElement("metadata")]
public Dictionary<string, string?> Metadata { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Authority.Plugin.Ldap.Claims;
internal interface ILdapClaimsCache
{
ValueTask<LdapCachedClaims?> GetAsync(string subjectId, CancellationToken cancellationToken);
ValueTask SetAsync(string subjectId, LdapCachedClaims claims, CancellationToken cancellationToken);
}
internal sealed record LdapCachedClaims(IReadOnlyList<string> Roles, IReadOnlyDictionary<string, string> Attributes);
internal sealed class DisabledLdapClaimsCache : ILdapClaimsCache
{
public static DisabledLdapClaimsCache Instance { get; } = new();
private DisabledLdapClaimsCache()
{
}
public ValueTask<LdapCachedClaims?> GetAsync(string subjectId, CancellationToken cancellationToken)
=> ValueTask.FromResult<LdapCachedClaims?>(null);
public ValueTask SetAsync(string subjectId, LdapCachedClaims claims, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
}

View File

@@ -1,15 +1,248 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugins.Abstractions;
namespace StellaOps.Authority.Plugin.Ldap.Claims;
internal sealed class LdapClaimsEnricher : IClaimsEnricher
{
public ValueTask EnrichAsync(
private static readonly Regex PlaceholderRegex = new("{(?<name>[^}]+)}", RegexOptions.Compiled | RegexOptions.CultureInvariant);
private readonly string pluginName;
private readonly ILdapConnectionFactory connectionFactory;
private readonly IOptionsMonitor<LdapPluginOptions> optionsMonitor;
private readonly ILdapClaimsCache claimsCache;
private readonly TimeProvider timeProvider;
private readonly ILogger<LdapClaimsEnricher> logger;
public LdapClaimsEnricher(
string pluginName,
ILdapConnectionFactory connectionFactory,
IOptionsMonitor<LdapPluginOptions> optionsMonitor,
ILdapClaimsCache claimsCache,
TimeProvider timeProvider,
ILogger<LdapClaimsEnricher> logger)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
this.connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.claimsCache = claimsCache ?? throw new ArgumentNullException(nameof(claimsCache));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async ValueTask EnrichAsync(
ClaimsIdentity identity,
AuthorityClaimsEnrichmentContext context,
CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
{
ArgumentNullException.ThrowIfNull(identity);
ArgumentNullException.ThrowIfNull(context);
var user = context.User;
if (user?.SubjectId is null)
{
return;
}
var options = optionsMonitor.Get(pluginName).Claims;
if (!HasWork(options))
{
return;
}
var cached = await claimsCache.GetAsync(user.SubjectId, cancellationToken).ConfigureAwait(false);
if (cached is not null)
{
ApplyRoleClaims(identity, cached.Roles);
ApplyAttributeClaims(identity, cached.Attributes);
return;
}
var attributes = BuildAttributeProjection(options);
if (attributes.Count == 0)
{
return;
}
try
{
await using var connection = await connectionFactory.CreateAsync(cancellationToken).ConfigureAwait(false);
var entry = await connection
.FindEntryAsync(user.SubjectId, "(objectClass=*)", attributes, cancellationToken)
.ConfigureAwait(false);
if (entry is null)
{
logger.LogWarning("LDAP claims enrichment could not locate subject {Subject} for plugin {Plugin}.", user.SubjectId, pluginName);
return;
}
var roles = ResolveRoles(options, entry.Attributes);
var extraClaims = ResolveExtraAttributes(options, entry.Attributes);
ApplyRoleClaims(identity, roles);
ApplyAttributeClaims(identity, extraClaims);
if (roles.Count > 0 || extraClaims.Count > 0)
{
await claimsCache.SetAsync(
user.SubjectId,
new LdapCachedClaims(roles, extraClaims),
cancellationToken).ConfigureAwait(false);
}
}
catch (LdapTransientException ex)
{
logger.LogWarning(ex, "LDAP claims enrichment transient failure for plugin {Plugin}.", pluginName);
}
catch (LdapOperationException ex)
{
logger.LogWarning(ex, "LDAP claims enrichment failed for plugin {Plugin}.", pluginName);
}
}
private static bool HasWork(LdapClaimsOptions options)
=> !string.IsNullOrWhiteSpace(options.GroupAttribute)
|| options.GroupToRoleMap.Count > 0
|| options.RegexMappings.Count > 0
|| options.ExtraAttributes.Count > 0;
private static IReadOnlyCollection<string> BuildAttributeProjection(LdapClaimsOptions options)
{
var attributes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(options.GroupAttribute))
{
attributes.Add(options.GroupAttribute!);
}
foreach (var attribute in options.ExtraAttributes.Values)
{
if (!string.IsNullOrWhiteSpace(attribute))
{
attributes.Add(attribute);
}
}
return attributes;
}
private static IReadOnlyList<string> ResolveRoles(
LdapClaimsOptions options,
IReadOnlyDictionary<string, IReadOnlyList<string>> attributes)
{
var roles = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(options.GroupAttribute) &&
attributes.TryGetValue(options.GroupAttribute!, out var groupValues))
{
foreach (var group in groupValues)
{
var normalized = group?.Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
continue;
}
if (options.GroupToRoleMap.TryGetValue(normalized, out var mappedRole))
{
AddRole(roles, mappedRole);
}
foreach (var regex in options.RegexMappings)
{
var match = Regex.Match(normalized, regex.Pattern!, RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
if (!match.Success)
{
continue;
}
var formatted = PlaceholderRegex.Replace(regex.RoleFormat!, placeholder =>
{
var groupName = placeholder.Groups["name"].Value;
return match.Groups[groupName]?.Value ?? string.Empty;
});
AddRole(roles, formatted);
}
}
}
return roles.ToArray();
}
private static void AddRole(ISet<string> roles, string? value)
{
var normalized = value?.Trim();
if (!string.IsNullOrWhiteSpace(normalized))
{
roles.Add(normalized);
}
}
private static IReadOnlyDictionary<string, string> ResolveExtraAttributes(
LdapClaimsOptions options,
IReadOnlyDictionary<string, IReadOnlyList<string>> attributes)
{
if (options.ExtraAttributes.Count == 0)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
var result = new SortedDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var mapping in options.ExtraAttributes)
{
if (string.IsNullOrWhiteSpace(mapping.Key) || string.IsNullOrWhiteSpace(mapping.Value))
{
continue;
}
if (!attributes.TryGetValue(mapping.Value, out var values) || values.Count == 0)
{
continue;
}
var attributeValue = values[0];
if (!string.IsNullOrWhiteSpace(attributeValue))
{
result[mapping.Key] = attributeValue;
}
}
return result;
}
private static void ApplyRoleClaims(ClaimsIdentity identity, IEnumerable<string> roles)
{
foreach (var role in roles)
{
if (!identity.HasClaim(ClaimTypes.Role, role))
{
identity.AddClaim(new Claim(ClaimTypes.Role, role));
}
}
}
private static void ApplyAttributeClaims(ClaimsIdentity identity, IReadOnlyDictionary<string, string> attributes)
{
foreach (var pair in attributes)
{
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
{
continue;
}
if (!identity.HasClaim(pair.Key, pair.Value))
{
identity.AddClaim(new Claim(pair.Key, pair.Value));
}
}
}
}

View File

@@ -0,0 +1,181 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
namespace StellaOps.Authority.Plugin.Ldap.Claims;
internal sealed class MongoLdapClaimsCache : ILdapClaimsCache
{
private readonly string pluginName;
private readonly IMongoCollection<LdapClaimsCacheDocument> collection;
private readonly LdapClaimsCacheOptions options;
private readonly TimeProvider timeProvider;
private readonly ILogger<MongoLdapClaimsCache> logger;
private readonly TimeSpan entryLifetime;
public MongoLdapClaimsCache(
string pluginName,
IMongoDatabase database,
LdapClaimsCacheOptions cacheOptions,
TimeProvider timeProvider,
ILogger<MongoLdapClaimsCache> logger)
{
ArgumentNullException.ThrowIfNull(pluginName);
ArgumentNullException.ThrowIfNull(database);
ArgumentNullException.ThrowIfNull(cacheOptions);
this.pluginName = pluginName;
options = cacheOptions;
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
entryLifetime = TimeSpan.FromSeconds(cacheOptions.TtlSeconds);
var collectionName = cacheOptions.ResolveCollectionName(pluginName);
collection = database.GetCollection<LdapClaimsCacheDocument>(collectionName);
EnsureIndexes();
}
public async ValueTask<LdapCachedClaims?> GetAsync(string subjectId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(subjectId);
var document = await collection
.Find(doc => doc.Id == BuildDocumentId(subjectId))
.FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false);
if (document is null)
{
return null;
}
if (document.ExpiresAt <= timeProvider.GetUtcNow())
{
await collection.DeleteOneAsync(doc => doc.Id == document.Id, cancellationToken).ConfigureAwait(false);
return null;
}
IReadOnlyList<string> roles = document.Roles is { Count: > 0 }
? document.Roles.AsReadOnly()
: Array.Empty<string>();
var attributes = document.Attributes is { Count: > 0 }
? new Dictionary<string, string>(document.Attributes, StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
return new LdapCachedClaims(roles, attributes);
}
public async ValueTask SetAsync(string subjectId, LdapCachedClaims claims, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(subjectId);
ArgumentNullException.ThrowIfNull(claims);
if (options.MaxEntries > 0)
{
await EnforceCapacityAsync(options.MaxEntries, cancellationToken).ConfigureAwait(false);
}
var now = timeProvider.GetUtcNow();
var document = new LdapClaimsCacheDocument
{
Id = BuildDocumentId(subjectId),
Plugin = pluginName,
SubjectId = subjectId,
CachedAt = now,
ExpiresAt = now + entryLifetime,
Roles = claims.Roles?.ToList() ?? new List<string>(),
Attributes = claims.Attributes?.ToDictionary(
pair => pair.Key,
pair => pair.Value,
StringComparer.OrdinalIgnoreCase) ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
};
await collection.ReplaceOneAsync(
existing => existing.Id == document.Id,
document,
new ReplaceOptions { IsUpsert = true },
cancellationToken).ConfigureAwait(false);
}
private string BuildDocumentId(string subjectId)
=> $"{pluginName}:{subjectId}".ToLowerInvariant();
private async Task EnforceCapacityAsync(int maxEntries, CancellationToken cancellationToken)
{
var total = await collection.CountDocumentsAsync(
Builders<LdapClaimsCacheDocument>.Filter.Empty,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (total < maxEntries)
{
return;
}
var surplus = (int)(total - maxEntries + 1);
var ids = await collection
.Find(Builders<LdapClaimsCacheDocument>.Filter.Empty)
.SortBy(doc => doc.CachedAt)
.Limit(surplus)
.Project(doc => doc.Id)
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (ids.Count == 0)
{
return;
}
var deleteFilter = Builders<LdapClaimsCacheDocument>.Filter.In(doc => doc.Id, ids);
await collection.DeleteManyAsync(deleteFilter, cancellationToken).ConfigureAwait(false);
}
private void EnsureIndexes()
{
var expiresIndex = Builders<LdapClaimsCacheDocument>.IndexKeys.Ascending(doc => doc.ExpiresAt);
var indexModel = new CreateIndexModel<LdapClaimsCacheDocument>(
expiresIndex,
new CreateIndexOptions
{
Name = "idx_expires_at",
ExpireAfter = TimeSpan.Zero
});
try
{
collection.Indexes.CreateOne(indexModel);
}
catch (MongoCommandException ex) when (ex.CodeName.Equals("IndexOptionsConflict", StringComparison.OrdinalIgnoreCase))
{
logger.LogDebug(ex, "LDAP claims cache index already exists for plugin {Plugin}.", pluginName);
}
}
}
internal sealed class LdapClaimsCacheDocument
{
[BsonId]
public string Id { get; set; } = string.Empty;
[BsonElement("plugin")]
public string Plugin { get; set; } = string.Empty;
[BsonElement("subjectId")]
public string SubjectId { get; set; } = string.Empty;
[BsonElement("roles")]
public List<string> Roles { get; set; } = new();
[BsonElement("attributes")]
public Dictionary<string, string> Attributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
[BsonElement("cachedAt")]
public DateTimeOffset CachedAt { get; set; }
[BsonElement("expiresAt")]
public DateTimeOffset ExpiresAt { get; set; }
}

View File

@@ -0,0 +1,18 @@
using System;
using System.Collections.Concurrent;
namespace StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
internal sealed record LdapCapabilitySnapshot(bool ClientProvisioningWritable, bool BootstrapWritable);
internal static class LdapCapabilitySnapshotCache
{
private static readonly ConcurrentDictionary<string, LdapCapabilitySnapshot> Cache = new(StringComparer.OrdinalIgnoreCase);
public static LdapCapabilitySnapshot GetOrAdd(string pluginName, Func<LdapCapabilitySnapshot> factory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pluginName);
ArgumentNullException.ThrowIfNull(factory);
return Cache.GetOrAdd(pluginName, _ => factory());
}
}

View File

@@ -0,0 +1,467 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using MongoDB.Driver;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Security;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
internal sealed class LdapClientProvisioningStore : IClientProvisioningStore
{
private static readonly IReadOnlyCollection<string> DefaultObjectClasses = new[]
{
"top",
"person",
"organizationalPerson",
"inetOrgPerson"
};
private readonly string pluginName;
private readonly IAuthorityClientStore clientStore;
private readonly IAuthorityRevocationStore revocationStore;
private readonly ILdapConnectionFactory connectionFactory;
private readonly IOptionsMonitor<LdapPluginOptions> optionsMonitor;
private readonly IMongoDatabase mongoDatabase;
private readonly TimeProvider clock;
private readonly ILogger<LdapClientProvisioningStore> logger;
public LdapClientProvisioningStore(
string pluginName,
IAuthorityClientStore clientStore,
IAuthorityRevocationStore revocationStore,
ILdapConnectionFactory connectionFactory,
IOptionsMonitor<LdapPluginOptions> optionsMonitor,
IMongoDatabase mongoDatabase,
TimeProvider clock,
ILogger<LdapClientProvisioningStore> logger)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.revocationStore = revocationStore ?? throw new ArgumentNullException(nameof(revocationStore));
this.connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.mongoDatabase = mongoDatabase ?? throw new ArgumentNullException(nameof(mongoDatabase));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
private bool ProvisioningEnabled => GetProvisioningOptions().Enabled;
public async ValueTask<AuthorityPluginOperationResult<AuthorityClientDescriptor>> CreateOrUpdateAsync(
AuthorityClientRegistration registration,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(registration);
if (!ProvisioningEnabled)
{
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Failure("disabled", "Client provisioning is disabled for this plugin.");
}
if (registration.Confidential && string.IsNullOrWhiteSpace(registration.ClientSecret))
{
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Failure("secret_required", "Confidential clients require a client secret.");
}
var document = await clientStore.FindByClientIdAsync(registration.ClientId, cancellationToken).ConfigureAwait(false)
?? new AuthorityClientDocument { ClientId = registration.ClientId, CreatedAt = clock.GetUtcNow() };
ApplyRegistration(document, registration);
await clientStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
var options = GetProvisioningOptions();
try
{
await SyncLdapAsync(registration, options, cancellationToken).ConfigureAwait(false);
await WriteAuditRecordAsync("upsert", document, options, cancellationToken).ConfigureAwait(false);
}
catch (LdapInsufficientAccessException ex)
{
logger.LogError(ex, "LDAP provisioning denied for client {ClientId}.", registration.ClientId);
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Failure("ldap_permission_denied", ex.Message);
}
catch (Exception ex) when (ex is LdapOperationException or LdapTransientException)
{
logger.LogError(ex, "LDAP provisioning failed for client {ClientId}.", registration.ClientId);
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Failure("ldap_error", ex.Message);
}
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Success(ToDescriptor(document));
}
public async ValueTask<AuthorityClientDescriptor?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
{
var document = await clientStore.FindByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
return document is null ? null : ToDescriptor(document);
}
public async ValueTask<AuthorityPluginOperationResult> DeleteAsync(string clientId, CancellationToken cancellationToken)
{
if (!ProvisioningEnabled)
{
return AuthorityPluginOperationResult.Failure("disabled", "Client provisioning is disabled for this plugin.");
}
var deleted = await clientStore.DeleteByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
if (!deleted)
{
return AuthorityPluginOperationResult.Failure("not_found", "Client was not found.");
}
var options = GetProvisioningOptions();
var distinguishedName = BuildDistinguishedName(clientId, options);
try
{
await RemoveLdapEntryAsync(distinguishedName, cancellationToken).ConfigureAwait(false);
await WriteAuditRecordAsync("delete", new AuthorityClientDocument
{
ClientId = clientId,
Properties = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase),
Plugin = pluginName,
SenderConstraint = null
}, options, cancellationToken).ConfigureAwait(false);
}
catch (LdapInsufficientAccessException ex)
{
logger.LogWarning(ex, "LDAP delete denied for client {ClientId}. Continuing with revocation.", clientId);
}
catch (Exception ex) when (ex is LdapOperationException or LdapTransientException)
{
logger.LogWarning(ex, "LDAP delete failed for client {ClientId}. Continuing with revocation.", clientId);
}
await RecordRevocationAsync(clientId, cancellationToken).ConfigureAwait(false);
return AuthorityPluginOperationResult.Success();
}
private async Task SyncLdapAsync(
AuthorityClientRegistration registration,
LdapClientProvisioningOptions options,
CancellationToken cancellationToken)
{
var connectionOptions = optionsMonitor.Get(pluginName).Connection;
var bindSecret = LdapSecretResolver.Resolve(connectionOptions.BindPasswordSecret);
await using var connection = await connectionFactory.CreateAsync(cancellationToken).ConfigureAwait(false);
await connection.BindAsync(connectionOptions.BindDn!, bindSecret, cancellationToken).ConfigureAwait(false);
var distinguishedName = BuildDistinguishedName(registration.ClientId, options);
var attributes = BuildAttributes(registration, options);
var filter = $"({options.RdnAttribute}={LdapDistinguishedNameHelper.EscapeFilterValue(registration.ClientId)})";
var existing = await connection.FindEntryAsync(options.ContainerDn!, filter, Array.Empty<string>(), cancellationToken).ConfigureAwait(false);
if (existing is null)
{
await connection.AddEntryAsync(distinguishedName, attributes, cancellationToken).ConfigureAwait(false);
}
else
{
await connection.ModifyEntryAsync(distinguishedName, attributes, cancellationToken).ConfigureAwait(false);
}
}
private async Task RemoveLdapEntryAsync(string distinguishedName, CancellationToken cancellationToken)
{
var connectionOptions = optionsMonitor.Get(pluginName).Connection;
var bindSecret = LdapSecretResolver.Resolve(connectionOptions.BindPasswordSecret);
await using var connection = await connectionFactory.CreateAsync(cancellationToken).ConfigureAwait(false);
await connection.BindAsync(connectionOptions.BindDn!, bindSecret, cancellationToken).ConfigureAwait(false);
await connection.DeleteEntryAsync(distinguishedName, cancellationToken).ConfigureAwait(false);
}
private async Task WriteAuditRecordAsync(
string operation,
AuthorityClientDocument document,
LdapClientProvisioningOptions options,
CancellationToken cancellationToken)
{
if (!options.AuditMirror.Enabled)
{
return;
}
var collectionName = options.ResolveAuditCollectionName(pluginName);
var collection = mongoDatabase.GetCollection<LdapClientProvisioningAuditDocument>(collectionName);
var record = new LdapClientProvisioningAuditDocument
{
Plugin = pluginName,
ClientId = document.ClientId,
DistinguishedName = BuildDistinguishedName(document.ClientId, options),
Operation = operation,
SecretHash = document.SecretHash,
Tenant = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenant) ? tenant : null,
Project = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Project, out var project) ? project : null,
Timestamp = clock.GetUtcNow(),
Metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["senderConstraint"] = document.SenderConstraint,
["plugin"] = pluginName
}
};
await collection.InsertOneAsync(record, cancellationToken: cancellationToken).ConfigureAwait(false);
}
private async Task RecordRevocationAsync(string clientId, CancellationToken cancellationToken)
{
var now = clock.GetUtcNow();
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["plugin"] = pluginName
};
var revocation = new AuthorityRevocationDocument
{
Category = "client",
RevocationId = clientId,
ClientId = clientId,
Reason = "operator_request",
ReasonDescription = $"Client '{clientId}' deleted via plugin '{pluginName}'.",
RevokedAt = now,
EffectiveAt = now,
Metadata = metadata
};
try
{
await revocationStore.UpsertAsync(revocation, cancellationToken).ConfigureAwait(false);
}
catch
{
// Revocation export should proceed even if metadata write fails.
}
}
private void ApplyRegistration(AuthorityClientDocument document, AuthorityClientRegistration registration)
{
document.Plugin = pluginName;
document.ClientType = registration.Confidential ? "confidential" : "public";
document.DisplayName = registration.DisplayName;
document.SecretHash = registration.Confidential && registration.ClientSecret is not null
? AuthoritySecretHasher.ComputeHash(registration.ClientSecret)
: null;
document.UpdatedAt = clock.GetUtcNow();
document.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList();
document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList();
document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = JoinValues(registration.AllowedGrantTypes);
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = JoinValues(registration.AllowedScopes);
document.Properties[AuthorityClientMetadataKeys.Audiences] = JoinValues(registration.AllowedAudiences);
var tenant = NormalizeTenant(registration.Tenant);
if (!string.IsNullOrWhiteSpace(tenant))
{
document.Properties[AuthorityClientMetadataKeys.Tenant] = tenant;
}
document.Properties[AuthorityClientMetadataKeys.Project] = registration.Project ?? StellaOpsTenancyDefaults.AnyProject;
if (registration.Properties.TryGetValue(AuthorityClientMetadataKeys.SenderConstraint, out var senderConstraintRaw))
{
var normalizedConstraint = NormalizeSenderConstraint(senderConstraintRaw);
if (!string.IsNullOrWhiteSpace(normalizedConstraint))
{
document.SenderConstraint = normalizedConstraint;
document.Properties[AuthorityClientMetadataKeys.SenderConstraint] = normalizedConstraint;
}
else
{
document.SenderConstraint = null;
document.Properties.Remove(AuthorityClientMetadataKeys.SenderConstraint);
}
}
document.CertificateBindings = registration.CertificateBindings.Count == 0
? new List<AuthorityClientCertificateBinding>()
: registration.CertificateBindings.Select(binding => MapCertificateBinding(binding, clock.GetUtcNow())).ToList();
}
private static AuthorityClientDescriptor ToDescriptor(AuthorityClientDocument document)
{
var allowedGrantTypes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedGrantTypes);
var allowedScopes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedScopes);
var redirectUris = document.RedirectUris
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
.Where(static uri => uri is not null)
.Cast<Uri>()
.ToArray();
var postLogoutUris = document.PostLogoutRedirectUris
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
.Where(static uri => uri is not null)
.Cast<Uri>()
.ToArray();
var audiences = Split(document.Properties, AuthorityClientMetadataKeys.Audiences);
return new AuthorityClientDescriptor(
document.ClientId,
document.DisplayName,
string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase),
allowedGrantTypes,
allowedScopes,
audiences,
redirectUris,
postLogoutUris,
document.Properties);
}
private static IReadOnlyCollection<string> Split(IReadOnlyDictionary<string, string?> properties, string key)
{
if (!properties.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
{
return Array.Empty<string>();
}
return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static string JoinValues(IReadOnlyCollection<string> values)
{
if (values is null || values.Count == 0)
{
return string.Empty;
}
return string.Join(
" ",
values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.OrderBy(static value => value, StringComparer.Ordinal));
}
private static string? NormalizeTenant(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
private static AuthorityClientCertificateBinding MapCertificateBinding(
AuthorityClientCertificateBindingRegistration registration,
DateTimeOffset now)
{
var subjectAlternativeNames = registration.SubjectAlternativeNames.Count == 0
? new List<string>()
: registration.SubjectAlternativeNames
.Select(name => name.Trim())
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
return new AuthorityClientCertificateBinding
{
Thumbprint = registration.Thumbprint,
SerialNumber = registration.SerialNumber,
Subject = registration.Subject,
Issuer = registration.Issuer,
SubjectAlternativeNames = subjectAlternativeNames,
NotBefore = registration.NotBefore,
NotAfter = registration.NotAfter,
Label = registration.Label,
CreatedAt = now,
UpdatedAt = now
};
}
private static string? NormalizeSenderConstraint(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim() switch
{
{ Length: 0 } => null,
var constraint when string.Equals(constraint, "dpop", StringComparison.OrdinalIgnoreCase) => "dpop",
var constraint when string.Equals(constraint, "mtls", StringComparison.OrdinalIgnoreCase) => "mtls",
_ => null
};
}
private IReadOnlyDictionary<string, IReadOnlyCollection<string>> BuildAttributes(
AuthorityClientRegistration registration,
LdapClientProvisioningOptions options)
{
var displayName = string.IsNullOrWhiteSpace(registration.DisplayName) ? registration.ClientId : registration.DisplayName!.Trim();
var attributes = new Dictionary<string, IReadOnlyCollection<string>>(StringComparer.OrdinalIgnoreCase)
{
["objectClass"] = DefaultObjectClasses.ToArray(),
[options.RdnAttribute] = new[] { registration.ClientId },
["sn"] = new[] { registration.ClientId },
["displayName"] = new[] { displayName },
["description"] = new[] { $"StellaOps client {registration.ClientId}" }
};
if (!string.IsNullOrWhiteSpace(options.SecretAttribute) &&
registration.Confidential &&
!string.IsNullOrWhiteSpace(registration.ClientSecret))
{
attributes[options.SecretAttribute!] = new[] { registration.ClientSecret! };
}
return attributes;
}
private static string BuildDistinguishedName(string clientId, LdapClientProvisioningOptions options)
{
var escapedValue = LdapDistinguishedNameHelper.EscapeRdnValue(clientId);
return $"{options.RdnAttribute}={escapedValue},{options.ContainerDn}";
}
private LdapClientProvisioningOptions GetProvisioningOptions()
=> optionsMonitor.Get(pluginName).ClientProvisioning;
}
internal sealed class LdapClientProvisioningAuditDocument
{
[BsonId]
public ObjectId Id { get; set; } = ObjectId.GenerateNewId();
[BsonElement("plugin")]
public string Plugin { get; set; } = string.Empty;
[BsonElement("clientId")]
public string ClientId { get; set; } = string.Empty;
[BsonElement("dn")]
public string DistinguishedName { get; set; } = string.Empty;
[BsonElement("operation")]
public string Operation { get; set; } = string.Empty;
[BsonElement("secretHash")]
[BsonIgnoreIfNull]
public string? SecretHash { get; set; }
[BsonElement("tenant")]
[BsonIgnoreIfNull]
public string? Tenant { get; set; }
[BsonElement("project")]
[BsonIgnoreIfNull]
public string? Project { get; set; }
[BsonElement("timestamp")]
public DateTimeOffset Timestamp { get; set; }
[BsonElement("metadata")]
public Dictionary<string, string?> Metadata { get; set; } = new(StringComparer.OrdinalIgnoreCase);
}

View File

@@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
internal static class LdapDistinguishedNameHelper
{
public static string EscapeRdnValue(string value)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
var chars = value.ToCharArray();
var needsEscaping = chars[0] == ' ' || chars[0] == '#'
|| chars[^1] == ' '
|| HasSpecial(chars);
if (!needsEscaping)
{
return value;
}
var buffer = new List<char>(value.Length * 2);
for (var i = 0; i < chars.Length; i++)
{
var c = chars[i];
var escape = c is ',' or '+' or '"' or '\\' or '<' or '>' or ';' or '=';
if ((i == 0 && (c == ' ' || c == '#')) || (i == chars.Length - 1 && c == ' '))
{
escape = true;
}
if (escape)
{
buffer.Add('\\');
}
buffer.Add(c);
}
return new string(buffer.ToArray());
}
public static string EscapeFilterValue(string value)
{
if (string.IsNullOrEmpty(value))
{
return value;
}
return value
.Replace("\\", "\\5c", StringComparison.Ordinal)
.Replace("*", "\\2a", StringComparison.Ordinal)
.Replace("(", "\\28", StringComparison.Ordinal)
.Replace(")", "\\29", StringComparison.Ordinal)
.Replace("\0", "\\00", StringComparison.Ordinal);
}
private static bool HasSpecial(ReadOnlySpan<char> chars)
{
foreach (var c in chars)
{
if (c is ',' or '+' or '"' or '\\' or '<' or '>' or ';' or '=')
{
return true;
}
}
return false;
}
}

View File

@@ -170,6 +170,9 @@ internal sealed class DirectoryServicesLdapConnectionHandle : ILdapConnectionHan
private readonly ILogger logger;
private readonly LdapMetrics metrics;
private const int InvalidCredentialsResultCode = 49;
private const int NoSuchObjectResultCode = 32;
private const int AlreadyExistsResultCode = 68;
private const int InsufficientAccessRightsResultCode = 50;
private const int ServerDownResultCode = 81;
private const int TimeLimitExceededResultCode = 3;
private const int BusyResultCode = 51;
@@ -260,6 +263,115 @@ internal sealed class DirectoryServicesLdapConnectionHandle : ILdapConnectionHan
}
}
public ValueTask AddEntryAsync(string distinguishedName, IReadOnlyDictionary<string, IReadOnlyCollection<string>> attributes, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var request = new AddRequest(distinguishedName);
foreach (var attribute in attributes)
{
if (attribute.Value is null || attribute.Value.Count == 0)
{
continue;
}
request.Attributes.Add(new DirectoryAttribute(attribute.Key, attribute.Value.ToArray()));
}
connection.SendRequest(request);
return ValueTask.CompletedTask;
}
catch (DirectoryOperationException ex) when (ex.Response.ResultCode == ResultCode.EntryAlreadyExists)
{
throw new LdapOperationException($"LDAP entry '{distinguishedName}' already exists.", ex);
}
catch (LdapException ex) when (ex.ErrorCode == AlreadyExistsResultCode)
{
throw new LdapOperationException($"LDAP entry '{distinguishedName}' already exists.", ex);
}
catch (LdapException ex) when (ex.ErrorCode == InsufficientAccessRightsResultCode)
{
throw new LdapInsufficientAccessException($"LDAP bind user lacks permissions to add '{distinguishedName}'.", ex);
}
catch (LdapException ex)
{
throw new LdapOperationException($"LDAP add failure ({FormatResult(ex.ErrorCode)}).", ex);
}
}
public ValueTask ModifyEntryAsync(string distinguishedName, IReadOnlyDictionary<string, IReadOnlyCollection<string>> attributes, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var request = new ModifyRequest(distinguishedName);
foreach (var attribute in attributes)
{
var modification = new DirectoryAttributeModification
{
Name = attribute.Key
};
if (attribute.Value is null || attribute.Value.Count == 0)
{
modification.Operation = DirectoryAttributeOperation.Delete;
}
else
{
modification.Operation = DirectoryAttributeOperation.Replace;
foreach (var value in attribute.Value)
{
modification.Add(value);
}
}
request.Modifications.Add(modification);
}
connection.SendRequest(request);
return ValueTask.CompletedTask;
}
catch (LdapException ex) when (ex.ErrorCode == NoSuchObjectResultCode)
{
throw new LdapOperationException($"LDAP entry '{distinguishedName}' was not found.", ex);
}
catch (LdapException ex) when (ex.ErrorCode == InsufficientAccessRightsResultCode)
{
throw new LdapInsufficientAccessException($"LDAP bind user lacks permissions to modify '{distinguishedName}'.", ex);
}
catch (LdapException ex)
{
throw new LdapOperationException($"LDAP modify failure ({FormatResult(ex.ErrorCode)}).", ex);
}
}
public ValueTask<bool> DeleteEntryAsync(string distinguishedName, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var request = new DeleteRequest(distinguishedName);
connection.SendRequest(request);
return ValueTask.FromResult(true);
}
catch (LdapException ex) when (ex.ErrorCode == NoSuchObjectResultCode)
{
return ValueTask.FromResult(false);
}
catch (LdapException ex) when (ex.ErrorCode == InsufficientAccessRightsResultCode)
{
throw new LdapInsufficientAccessException($"LDAP bind user lacks permissions to delete '{distinguishedName}'.", ex);
}
catch (LdapException ex)
{
throw new LdapOperationException($"LDAP delete failure ({FormatResult(ex.ErrorCode)}).", ex);
}
}
private static bool IsInvalidCredentials(LdapException ex)
=> ex.ErrorCode == InvalidCredentialsResultCode;
@@ -276,6 +388,9 @@ internal sealed class DirectoryServicesLdapConnectionHandle : ILdapConnectionHan
ServerDownResultCode => "ServerDown (81)",
TimeLimitExceededResultCode => "TimeLimitExceeded (3)",
BusyResultCode => "Busy (51)",
AlreadyExistsResultCode => "EntryAlreadyExists (68)",
InsufficientAccessRightsResultCode => "InsufficientAccess (50)",
NoSuchObjectResultCode => "NoSuchObject (32)",
UnavailableResultCode => "Unavailable (52)",
_ => errorCode.ToString(CultureInfo.InvariantCulture)
};

View File

@@ -15,6 +15,12 @@ internal interface ILdapConnectionHandle : IAsyncDisposable
ValueTask BindAsync(string distinguishedName, string password, CancellationToken cancellationToken);
ValueTask<LdapSearchEntry?> FindEntryAsync(string baseDn, string filter, IReadOnlyCollection<string> attributes, CancellationToken cancellationToken);
ValueTask AddEntryAsync(string distinguishedName, IReadOnlyDictionary<string, IReadOnlyCollection<string>> attributes, CancellationToken cancellationToken);
ValueTask ModifyEntryAsync(string distinguishedName, IReadOnlyDictionary<string, IReadOnlyCollection<string>> attributes, CancellationToken cancellationToken);
ValueTask<bool> DeleteEntryAsync(string distinguishedName, CancellationToken cancellationToken);
}
internal sealed record LdapSearchEntry(string DistinguishedName, IReadOnlyDictionary<string, IReadOnlyList<string>> Attributes);

View File

@@ -25,3 +25,11 @@ internal class LdapOperationException : Exception
{
}
}
internal sealed class LdapInsufficientAccessException : LdapOperationException
{
public LdapInsufficientAccessException(string message, Exception? innerException = null)
: base(message, innerException)
{
}
}

View File

@@ -6,7 +6,9 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Ldap.Bootstrap;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Monitoring;
using StellaOps.Authority.Plugin.Ldap.Security;
@@ -24,6 +26,8 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
private readonly ILdapConnectionFactory connectionFactory;
private readonly ILogger<LdapCredentialStore> logger;
private readonly LdapMetrics metrics;
private readonly IMongoDatabase mongoDatabase;
private readonly TimeProvider timeProvider;
private readonly Func<TimeSpan, CancellationToken, Task> delayAsync;
public LdapCredentialStore(
@@ -32,6 +36,8 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
ILdapConnectionFactory connectionFactory,
ILogger<LdapCredentialStore> logger,
LdapMetrics metrics,
IMongoDatabase mongoDatabase,
TimeProvider timeProvider,
Func<TimeSpan, CancellationToken, Task>? delayAsync = null)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
@@ -39,6 +45,8 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
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.mongoDatabase = mongoDatabase ?? throw new ArgumentNullException(nameof(mongoDatabase));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.delayAsync = delayAsync ?? ((delay, token) => Task.Delay(delay, token));
}
@@ -185,13 +193,49 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
}
}
public ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(
public async ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(
AuthorityUserRegistration registration,
CancellationToken cancellationToken)
{
return ValueTask.FromResult(AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure(
"not_supported",
"LDAP identity provider does not support provisioning users."));
ArgumentNullException.ThrowIfNull(registration);
var pluginOptions = optionsMonitor.Get(pluginName);
var bootstrapOptions = pluginOptions.Bootstrap;
if (!bootstrapOptions.Enabled)
{
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure(
"not_supported",
"LDAP identity provider does not support bootstrap provisioning.");
}
if (string.IsNullOrWhiteSpace(registration.Username) || string.IsNullOrWhiteSpace(registration.Password))
{
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure(
"invalid_request",
"Bootstrap provisioning requires a username and password.");
}
try
{
var descriptor = await ProvisionBootstrapUserAsync(registration, pluginOptions, bootstrapOptions, cancellationToken).ConfigureAwait(false);
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Success(descriptor);
}
catch (LdapInsufficientAccessException ex)
{
logger.LogError(ex, "LDAP bootstrap provisioning denied for user {Username}.", registration.Username);
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure("ldap_permission_denied", ex.Message);
}
catch (LdapTransientException ex)
{
logger.LogWarning(ex, "LDAP bootstrap provisioning transient failure for user {Username}.", registration.Username);
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure("ldap_transient_error", ex.Message);
}
catch (LdapOperationException ex)
{
logger.LogError(ex, "LDAP bootstrap provisioning failed for user {Username}.", registration.Username);
return AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure("ldap_error", ex.Message);
}
}
public ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)

View File

@@ -4,6 +4,7 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
using StellaOps.Authority.Plugin.Ldap.Claims;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Credentials;
@@ -18,9 +19,10 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
private readonly LdapClaimsEnricher claimsEnricher;
private readonly ILdapConnectionFactory connectionFactory;
private readonly IOptionsMonitor<LdapPluginOptions> optionsMonitor;
private readonly LdapClientProvisioningStore clientProvisioningStore;
private readonly ILogger<LdapIdentityProviderPlugin> logger;
private readonly AuthorityIdentityProviderCapabilities capabilities = new(true, false, false);
private readonly AuthorityIdentityProviderCapabilities capabilities;
private readonly bool supportsClientProvisioning;
public LdapIdentityProviderPlugin(
AuthorityPluginContext pluginContext,
@@ -28,6 +30,7 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
LdapClaimsEnricher claimsEnricher,
ILdapConnectionFactory connectionFactory,
IOptionsMonitor<LdapPluginOptions> optionsMonitor,
LdapClientProvisioningStore clientProvisioningStore,
ILogger<LdapIdentityProviderPlugin> logger)
{
this.pluginContext = pluginContext ?? throw new ArgumentNullException(nameof(pluginContext));
@@ -35,7 +38,32 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
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.clientProvisioningStore = clientProvisioningStore ?? throw new ArgumentNullException(nameof(clientProvisioningStore));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
var manifestCapabilities = AuthorityIdentityProviderCapabilities.FromCapabilities(pluginContext.Manifest.Capabilities);
var provisioningOptions = optionsMonitor.Get(pluginContext.Manifest.Name).ClientProvisioning;
supportsClientProvisioning = manifestCapabilities.SupportsClientProvisioning && provisioningOptions.Enabled;
if (manifestCapabilities.SupportsClientProvisioning && !provisioningOptions.Enabled)
{
this.logger.LogWarning(
"LDAP plugin '{PluginName}' manifest declares clientProvisioning, but configuration disabled it. Capability will be advertised as false.",
pluginContext.Manifest.Name);
}
if (manifestCapabilities.SupportsBootstrap)
{
this.logger.LogInformation(
"LDAP plugin '{PluginName}' manifest declares bootstrap capability, but it is not implemented yet. Capability will be advertised as false.",
pluginContext.Manifest.Name);
}
capabilities = new AuthorityIdentityProviderCapabilities(
SupportsPassword: true,
SupportsMfa: manifestCapabilities.SupportsMfa,
SupportsClientProvisioning: supportsClientProvisioning,
SupportsBootstrap: false);
}
public string Name => pluginContext.Manifest.Name;
@@ -48,7 +76,7 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
public IClaimsEnricher ClaimsEnricher => claimsEnricher;
public IClientProvisioningStore? ClientProvisioning => null;
public IClientProvisioningStore? ClientProvisioning => supportsClientProvisioning ? clientProvisioningStore : null;
public AuthorityIdentityProviderCapabilities Capabilities => capabilities;

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
namespace StellaOps.Authority.Plugin.Ldap;
@@ -13,6 +14,12 @@ internal sealed class LdapPluginOptions
public LdapQueryOptions Queries { get; set; } = new();
public LdapClaimsOptions Claims { get; set; } = new();
public LdapClientProvisioningOptions ClientProvisioning { get; set; } = new();
public LdapBootstrapOptions Bootstrap { get; set; } = new();
public void Normalize(string configPath)
{
ArgumentNullException.ThrowIfNull(configPath);
@@ -20,6 +27,9 @@ internal sealed class LdapPluginOptions
Connection.Normalize(configPath);
Security.Normalize();
Queries.Normalize();
Claims.Normalize();
ClientProvisioning.Normalize();
Bootstrap.Normalize();
}
public void Validate(string pluginName)
@@ -29,6 +39,9 @@ internal sealed class LdapPluginOptions
Connection.Validate(pluginName);
Security.Validate(pluginName);
Queries.Validate(pluginName);
Claims.Validate(pluginName);
ClientProvisioning.Validate(pluginName);
Bootstrap.Validate(pluginName);
EnsureSecurityRequirements(pluginName);
}
@@ -364,3 +377,354 @@ internal sealed class LdapQueryOptions
}
}
}
internal sealed class LdapClaimsOptions
{
public string? GroupAttribute { get; set; } = "memberOf";
public Dictionary<string, string> GroupToRoleMap { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public List<LdapRegexMappingOptions> RegexMappings { get; set; } = new();
public Dictionary<string, string> ExtraAttributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public LdapClaimsCacheOptions Cache { get; set; } = new();
public void Normalize()
{
GroupAttribute = Normalize(GroupAttribute);
GroupToRoleMap = GroupToRoleMap?
.Where(pair => !string.IsNullOrWhiteSpace(pair.Key) && !string.IsNullOrWhiteSpace(pair.Value))
.ToDictionary(pair => pair.Key.Trim(), pair => pair.Value.Trim(), StringComparer.OrdinalIgnoreCase)
?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
RegexMappings = RegexMappings?
.Where(mapping => mapping is not null)
.Select(mapping =>
{
mapping!.Normalize();
return mapping;
})
.ToList() ?? new List<LdapRegexMappingOptions>();
ExtraAttributes = ExtraAttributes?
.Where(pair => !string.IsNullOrWhiteSpace(pair.Key) && !string.IsNullOrWhiteSpace(pair.Value))
.ToDictionary(pair => pair.Key.Trim(), pair => pair.Value.Trim(), StringComparer.OrdinalIgnoreCase)
?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
Cache ??= new LdapClaimsCacheOptions();
Cache.Normalize();
}
public void Validate(string pluginName)
{
if (string.IsNullOrWhiteSpace(GroupAttribute) &&
ExtraAttributes.Count == 0)
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' must configure claims.groupAttribute or claims.extraAttributes.");
}
for (var index = 0; index < RegexMappings.Count; index++)
{
var mapping = RegexMappings[index];
mapping.Validate(pluginName, index);
}
Cache.Validate(pluginName);
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
internal sealed class LdapClientProvisioningOptions
{
private const string DefaultRdnAttribute = "cn";
public bool Enabled { get; set; }
public string? ContainerDn { get; set; }
public string RdnAttribute { get; set; } = DefaultRdnAttribute;
public string? SecretAttribute { get; set; } = "userPassword";
public LdapClientProvisioningAuditOptions AuditMirror { get; set; } = new();
public void Normalize()
{
ContainerDn = Normalize(ContainerDn);
RdnAttribute = string.IsNullOrWhiteSpace(RdnAttribute) ? DefaultRdnAttribute : RdnAttribute.Trim();
SecretAttribute = string.IsNullOrWhiteSpace(SecretAttribute) ? null : SecretAttribute.Trim();
AuditMirror ??= new LdapClientProvisioningAuditOptions();
AuditMirror.Normalize();
}
public void Validate(string pluginName)
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(ContainerDn))
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires clientProvisioning.containerDn when enabled.");
}
if (string.IsNullOrWhiteSpace(RdnAttribute))
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires clientProvisioning.rdnAttribute when enabled.");
}
if (string.IsNullOrWhiteSpace(SecretAttribute))
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires clientProvisioning.secretAttribute when enabled.");
}
AuditMirror.Validate(pluginName);
}
public string ResolveAuditCollectionName(string pluginName)
=> AuditMirror.ResolveCollectionName(pluginName);
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
internal sealed class LdapClientProvisioningAuditOptions
{
public bool Enabled { get; set; } = true;
public string? CollectionName { get; set; }
public void Normalize()
{
CollectionName = string.IsNullOrWhiteSpace(CollectionName) ? null : CollectionName.Trim();
}
public void Validate(string pluginName)
{
if (!Enabled)
{
return;
}
var collection = ResolveCollectionName(pluginName);
if (string.IsNullOrWhiteSpace(collection))
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires clientProvisioning.auditMirror.collectionName when enabled.");
}
}
public string ResolveCollectionName(string pluginName)
{
if (!string.IsNullOrWhiteSpace(CollectionName))
{
return CollectionName!;
}
var normalized = pluginName
.Replace(':', '_')
.Replace('/', '_')
.Replace('\\', '_');
return $"ldap_client_provisioning_{normalized}".ToLowerInvariant();
}
}
internal sealed class LdapRegexMappingOptions
{
private static readonly Regex PythonNamedGroupRegex = new(@"\(\?P<(?<name>[^>]+)>", RegexOptions.Compiled | RegexOptions.CultureInvariant);
public string? Pattern { get; set; }
public string? RoleFormat { get; set; }
public void Normalize()
{
Pattern = string.IsNullOrWhiteSpace(Pattern) ? string.Empty : NormalizePattern(Pattern.Trim());
RoleFormat = string.IsNullOrWhiteSpace(RoleFormat) ? "{role}" : RoleFormat.Trim();
}
private static string NormalizePattern(string pattern)
{
if (pattern.Length == 0)
{
return pattern;
}
return PythonNamedGroupRegex.Replace(pattern, match => $"(?<{match.Groups["name"].Value}>");
}
public void Validate(string pluginName, int index)
{
if (string.IsNullOrWhiteSpace(Pattern))
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires claims.regexMappings[{index}].pattern to be specified.");
}
try
{
_ = new Regex(Pattern, RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
}
catch (ArgumentException ex)
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' claims.regexMappings[{index}].pattern is invalid: {ex.Message}", ex);
}
if (string.IsNullOrWhiteSpace(RoleFormat))
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires claims.regexMappings[{index}].roleFormat to be specified.");
}
}
}
internal sealed class LdapClaimsCacheOptions
{
public bool Enabled { get; set; }
public string? CollectionName { get; set; }
public int TtlSeconds { get; set; } = 600;
public int MaxEntries { get; set; } = 5000;
public void Normalize()
{
CollectionName = string.IsNullOrWhiteSpace(CollectionName) ? null : CollectionName.Trim();
if (TtlSeconds <= 0)
{
TtlSeconds = 600;
}
if (MaxEntries < 0)
{
MaxEntries = 0;
}
}
public void Validate(string pluginName)
{
if (!Enabled)
{
return;
}
if (TtlSeconds <= 0)
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires claims.cache.ttlSeconds to be greater than zero when enabled.");
}
var collectionName = ResolveCollectionName(pluginName);
if (string.IsNullOrWhiteSpace(collectionName))
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires claims.cache.collectionName when cache is enabled.");
}
}
public string ResolveCollectionName(string pluginName)
{
if (!string.IsNullOrWhiteSpace(CollectionName))
{
return CollectionName!;
}
var normalized = pluginName
.Replace(':', '_')
.Replace('/', '_')
.Replace('\\', '_');
return $"ldap_claims_cache_{normalized}".ToLowerInvariant();
}
}
internal sealed class LdapBootstrapOptions
{
private static readonly string[] DefaultObjectClasses = new[]
{
"top",
"person",
"organizationalPerson",
"inetOrgPerson"
};
public bool Enabled { get; set; }
public string? ContainerDn { get; set; }
public string RdnAttribute { get; set; } = "uid";
public string UsernameAttribute { get; set; } = "uid";
public string DisplayNameAttribute { get; set; } = "displayName";
public string GivenNameAttribute { get; set; } = "givenName";
public string SurnameAttribute { get; set; } = "sn";
public string? EmailAttribute { get; set; } = "mail";
public string SecretAttribute { get; set; } = "userPassword";
public string[] ObjectClasses { get; set; } = DefaultObjectClasses;
public Dictionary<string, string> StaticAttributes { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public LdapClientProvisioningAuditOptions AuditMirror { get; set; } = new();
public void Normalize()
{
ContainerDn = Normalize(ContainerDn);
RdnAttribute = Normalize(RdnAttribute) ?? "uid";
UsernameAttribute = Normalize(UsernameAttribute) ?? "uid";
DisplayNameAttribute = Normalize(DisplayNameAttribute) ?? "displayName";
GivenNameAttribute = Normalize(GivenNameAttribute) ?? "givenName";
SurnameAttribute = Normalize(SurnameAttribute) ?? "sn";
EmailAttribute = Normalize(EmailAttribute);
SecretAttribute = Normalize(SecretAttribute) ?? "userPassword";
ObjectClasses = ObjectClasses?
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray() ?? DefaultObjectClasses;
StaticAttributes = StaticAttributes?
.Where(pair => !string.IsNullOrWhiteSpace(pair.Key) && !string.IsNullOrWhiteSpace(pair.Value))
.ToDictionary(pair => pair.Key.Trim(), pair => pair.Value.Trim(), StringComparer.OrdinalIgnoreCase)
?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
AuditMirror ??= new LdapClientProvisioningAuditOptions();
AuditMirror.Normalize();
}
public void Validate(string pluginName)
{
if (!Enabled)
{
return;
}
if (string.IsNullOrWhiteSpace(ContainerDn))
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires bootstrap.containerDn when bootstrap is enabled.");
}
if (ObjectClasses.Length == 0)
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires bootstrap.objectClasses to contain at least one value when enabled.");
}
if (string.IsNullOrWhiteSpace(SecretAttribute))
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires bootstrap.secretAttribute when enabled.");
}
AuditMirror.Validate(pluginName);
}
public string ResolveAuditCollectionName(string pluginName)
=> AuditMirror.ResolveCollectionName($"{pluginName}_bootstrap");
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}

View File

@@ -1,12 +1,16 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
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;
using StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.Plugin.Ldap;
@@ -22,6 +26,8 @@ internal sealed class LdapPluginRegistrar : IAuthorityPluginRegistrar
var pluginName = pluginManifest.Name;
var configPath = pluginManifest.ConfigPath;
context.Services.TryAddSingleton(TimeProvider.System);
context.Services.AddOptions<LdapPluginOptions>(pluginName)
.Bind(context.Plugin.Configuration)
.PostConfigure(options =>
@@ -46,7 +52,43 @@ internal sealed class LdapPluginRegistrar : IAuthorityPluginRegistrar
sp.GetRequiredService<ILogger<LdapCredentialStore>>(),
sp.GetRequiredService<LdapMetrics>()));
context.Services.AddScoped<LdapClaimsEnricher>();
context.Services.AddScoped(sp => new LdapClientProvisioningStore(
pluginName,
sp.GetRequiredService<IAuthorityClientStore>(),
sp.GetRequiredService<IAuthorityRevocationStore>(),
sp.GetRequiredService<ILdapConnectionFactory>(),
sp.GetRequiredService<IOptionsMonitor<LdapPluginOptions>>(),
sp.GetRequiredService<IMongoDatabase>(),
sp.GetRequiredService<TimeProvider>(),
sp.GetRequiredService<ILogger<LdapClientProvisioningStore>>()));
context.Services.AddSingleton<ILdapClaimsCache>(sp =>
{
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<LdapPluginOptions>>();
var pluginOptions = optionsMonitor.Get(pluginName);
var cacheOptions = pluginOptions.Claims.Cache;
if (!cacheOptions.Enabled)
{
return DisabledLdapClaimsCache.Instance;
}
return new MongoLdapClaimsCache(
pluginName,
sp.GetRequiredService<IMongoDatabase>(),
cacheOptions,
ResolveTimeProvider(sp),
sp.GetRequiredService<ILogger<MongoLdapClaimsCache>>());
});
context.Services.AddScoped(sp => new LdapClaimsEnricher(
pluginName,
sp.GetRequiredService<ILdapConnectionFactory>(),
sp.GetRequiredService<IOptionsMonitor<LdapPluginOptions>>(),
sp.GetRequiredService<ILdapClaimsCache>(),
ResolveTimeProvider(sp),
sp.GetRequiredService<ILogger<LdapClaimsEnricher>>()));
context.Services.AddScoped<IClaimsEnricher>(sp => sp.GetRequiredService<LdapClaimsEnricher>());
context.Services.AddScoped<IUserCredentialStore>(sp => sp.GetRequiredService<LdapCredentialStore>());
@@ -57,6 +99,12 @@ internal sealed class LdapPluginRegistrar : IAuthorityPluginRegistrar
sp.GetRequiredService<LdapClaimsEnricher>(),
sp.GetRequiredService<ILdapConnectionFactory>(),
sp.GetRequiredService<IOptionsMonitor<LdapPluginOptions>>(),
sp.GetRequiredService<LdapClientProvisioningStore>(),
sp.GetRequiredService<ILogger<LdapIdentityProviderPlugin>>()));
context.Services.AddScoped<IClientProvisioningStore>(sp => sp.GetRequiredService<LdapClientProvisioningStore>());
}
private static TimeProvider ResolveTimeProvider(IServiceProvider services)
=> services.GetService<TimeProvider>() ?? TimeProvider.System;
}

View File

@@ -13,10 +13,12 @@
<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" />
<PackageReference Include="MongoDB.Driver" Version="3.5.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" />
<ProjectReference Include="..\\StellaOps.Authority.Storage.Mongo\\StellaOps.Authority.Storage.Mongo.csproj" />
</ItemGroup>
</Project>