Add LDAP Distinguished Name Helper and Credential Audit Context
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
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:
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -25,3 +25,11 @@ internal class LdapOperationException : Exception
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class LdapInsufficientAccessException : LdapOperationException
|
||||
{
|
||||
public LdapInsufficientAccessException(string message, Exception? innerException = null)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user