feat: Implement PostgreSQL repositories for various entities
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled

- Added BootstrapInviteRepository for managing bootstrap invites.
- Added ClientRepository for handling OAuth/OpenID clients.
- Introduced LoginAttemptRepository for logging login attempts.
- Created OidcTokenRepository for managing OpenIddict tokens and refresh tokens.
- Implemented RevocationExportStateRepository for persisting revocation export state.
- Added RevocationRepository for managing revocations.
- Introduced ServiceAccountRepository for handling service accounts.
This commit is contained in:
master
2025-12-11 17:48:25 +02:00
parent 1995883476
commit ab22181e8b
82 changed files with 5153 additions and 2261 deletions

View File

@@ -1,34 +0,0 @@
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,74 @@
using System.Collections.Concurrent;
namespace StellaOps.Authority.Plugin.Ldap.Claims;
internal sealed class InMemoryLdapClaimsCache : ILdapClaimsCache
{
private readonly string pluginName;
private readonly LdapClaimsCacheOptions options;
private readonly TimeProvider timeProvider;
private readonly ConcurrentDictionary<string, LdapClaimsCacheEntry> entries = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeSpan entryLifetime;
public InMemoryLdapClaimsCache(string pluginName, LdapClaimsCacheOptions options, TimeProvider timeProvider)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pluginName);
ArgumentNullException.ThrowIfNull(options);
this.pluginName = pluginName;
this.options = options;
this.timeProvider = timeProvider ?? TimeProvider.System;
entryLifetime = TimeSpan.FromSeconds(options.TtlSeconds);
}
public ValueTask<LdapCachedClaims?> GetAsync(string subjectId, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(subjectId);
var now = timeProvider.GetUtcNow();
if (entries.TryGetValue(BuildKey(subjectId), out var entry))
{
if (entry.ExpiresAt <= now)
{
entries.TryRemove(BuildKey(subjectId), out _);
return ValueTask.FromResult<LdapCachedClaims?>(null);
}
return ValueTask.FromResult<LdapCachedClaims?>(entry.Claims);
}
return ValueTask.FromResult<LdapCachedClaims?>(null);
}
public ValueTask SetAsync(string subjectId, LdapCachedClaims claims, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(subjectId);
ArgumentNullException.ThrowIfNull(claims);
if (options.MaxEntries > 0)
{
EnforceCapacity(options.MaxEntries);
}
var expiresAt = timeProvider.GetUtcNow() + entryLifetime;
entries[BuildKey(subjectId)] = new LdapClaimsCacheEntry(claims, expiresAt);
return ValueTask.CompletedTask;
}
private void EnforceCapacity(int maxEntries)
{
while (entries.Count >= maxEntries)
{
var oldest = entries.OrderBy(kv => kv.Value.ExpiresAt).FirstOrDefault();
if (oldest.Key is null)
{
break;
}
entries.TryRemove(oldest.Key, out _);
}
}
private string BuildKey(string subjectId) => $"{pluginName}:{subjectId}".ToLowerInvariant();
private sealed record LdapClaimsCacheEntry(LdapCachedClaims Claims, DateTimeOffset ExpiresAt);
}

View File

@@ -1,181 +0,0 @@
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

@@ -5,8 +5,6 @@ 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;
@@ -32,7 +30,7 @@ internal sealed class LdapClientProvisioningStore : IClientProvisioningStore
private readonly IAuthorityRevocationStore revocationStore;
private readonly ILdapConnectionFactory connectionFactory;
private readonly IOptionsMonitor<LdapPluginOptions> optionsMonitor;
private readonly IMongoDatabase mongoDatabase;
private readonly IAuthorityAirgapAuditStore auditStore;
private readonly TimeProvider clock;
private readonly ILogger<LdapClientProvisioningStore> logger;
@@ -42,7 +40,7 @@ internal sealed class LdapClientProvisioningStore : IClientProvisioningStore
IAuthorityRevocationStore revocationStore,
ILdapConnectionFactory connectionFactory,
IOptionsMonitor<LdapPluginOptions> optionsMonitor,
IMongoDatabase mongoDatabase,
IAuthorityAirgapAuditStore auditStore,
TimeProvider clock,
ILogger<LdapClientProvisioningStore> logger)
{
@@ -51,7 +49,7 @@ internal sealed class LdapClientProvisioningStore : IClientProvisioningStore
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.auditStore = auditStore ?? throw new ArgumentNullException(nameof(auditStore));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -198,26 +196,35 @@ internal sealed class LdapClientProvisioningStore : IClientProvisioningStore
}
var collectionName = options.ResolveAuditCollectionName(pluginName);
var collection = mongoDatabase.GetCollection<LdapClientProvisioningAuditDocument>(collectionName);
var record = new LdapClientProvisioningAuditDocument
var properties = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
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
}
["senderConstraint"] = document.SenderConstraint,
["plugin"] = pluginName,
["distinguishedName"] = BuildDistinguishedName(document.ClientId, options),
["collection"] = collectionName,
["tenant"] = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenant) ? tenant : null,
["project"] = document.Properties.TryGetValue(AuthorityClientMetadataKeys.Project, out var project) ? project : null,
["secretHash"] = document.SecretHash
};
await collection.InsertOneAsync(record, cancellationToken: cancellationToken).ConfigureAwait(false);
var auditDocument = new AuthorityAirgapAuditDocument
{
EventType = $"ldap.client.{operation}",
OperatorId = pluginName,
ComponentId = collectionName,
Outcome = "success",
OccurredAt = clock.GetUtcNow(),
Properties = properties
.Where(kv => !string.IsNullOrWhiteSpace(kv.Value) || kv.Key is "plugin" or "collection" or "distinguishedName")
.Select(kv => new AuthorityAirgapAuditPropertyDocument
{
Name = kv.Key,
Value = kv.Value ?? string.Empty
})
.ToList()
};
await auditStore.InsertAsync(auditDocument, cancellationToken, session: null).ConfigureAwait(false);
}
private async Task RecordRevocationAsync(string clientId, CancellationToken cancellationToken)
@@ -429,39 +436,3 @@ internal sealed class LdapClientProvisioningStore : IClientProvisioningStore
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

@@ -6,13 +6,13 @@ 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.ClientProvisioning;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Monitoring;
using StellaOps.Authority.Plugin.Ldap.Security;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Plugin.Ldap.Credentials;
@@ -27,7 +27,7 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
private readonly ILdapConnectionFactory connectionFactory;
private readonly ILogger<LdapCredentialStore> logger;
private readonly LdapMetrics metrics;
private readonly IMongoDatabase mongoDatabase;
private readonly IAuthorityAirgapAuditStore auditStore;
private readonly TimeProvider timeProvider;
private readonly Func<TimeSpan, CancellationToken, Task> delayAsync;
@@ -37,7 +37,7 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
ILdapConnectionFactory connectionFactory,
ILogger<LdapCredentialStore> logger,
LdapMetrics metrics,
IMongoDatabase mongoDatabase,
IAuthorityAirgapAuditStore auditStore,
TimeProvider timeProvider,
Func<TimeSpan, CancellationToken, Task>? delayAsync = null)
{
@@ -46,7 +46,7 @@ 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.auditStore = auditStore ?? throw new ArgumentNullException(nameof(auditStore));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.delayAsync = delayAsync ?? ((delay, token) => Task.Delay(delay, token));
}
@@ -511,31 +511,35 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
}
var collectionName = options.ResolveAuditCollectionName(pluginName);
var collection = mongoDatabase.GetCollection<LdapBootstrapAuditDocument>(collectionName);
var document = new LdapBootstrapAuditDocument
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
Plugin = pluginName,
Username = NormalizeUsername(registration.Username),
DistinguishedName = distinguishedName,
Operation = "upsert",
SecretHash = string.IsNullOrWhiteSpace(registration.Password)
? null
: AuthoritySecretHasher.ComputeHash(registration.Password!),
Timestamp = timeProvider.GetUtcNow(),
Metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["requirePasswordReset"] = registration.RequirePasswordReset ? "true" : "false",
["email"] = registration.Email
}
["requirePasswordReset"] = registration.RequirePasswordReset ? "true" : "false",
["email"] = registration.Email,
["dn"] = distinguishedName,
["collection"] = collectionName,
["username"] = NormalizeUsername(registration.Username)
};
foreach (var attribute in registration.Attributes)
{
document.Metadata[$"attr.{attribute.Key}"] = attribute.Value;
metadata[$"attr.{attribute.Key}"] = attribute.Value;
}
await collection.InsertOneAsync(document, cancellationToken: cancellationToken).ConfigureAwait(false);
var auditDocument = new AuthorityAirgapAuditDocument
{
EventType = "ldap.bootstrap.upsert",
OperatorId = pluginName,
ComponentId = collectionName,
Outcome = "success",
OccurredAt = timeProvider.GetUtcNow(),
Properties = metadata.Select(pair => new AuthorityAirgapAuditPropertyDocument
{
Name = pair.Key,
Value = pair.Value ?? string.Empty
}).ToList()
};
await auditStore.InsertAsync(auditDocument, cancellationToken, session: null).ConfigureAwait(false);
}
private IReadOnlyDictionary<string, IReadOnlyCollection<string>> BuildBootstrapAttributes(

View File

@@ -2,7 +2,6 @@ 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;
@@ -51,7 +50,7 @@ internal sealed class LdapPluginRegistrar : IAuthorityPluginRegistrar
sp.GetRequiredService<ILdapConnectionFactory>(),
sp.GetRequiredService<ILogger<LdapCredentialStore>>(),
sp.GetRequiredService<LdapMetrics>(),
sp.GetRequiredService<IMongoDatabase>(),
sp.GetRequiredService<IAuthorityAirgapAuditStore>(),
ResolveTimeProvider(sp)));
context.Services.AddScoped(sp => new LdapClientProvisioningStore(
@@ -60,7 +59,7 @@ internal sealed class LdapPluginRegistrar : IAuthorityPluginRegistrar
sp.GetRequiredService<IAuthorityRevocationStore>(),
sp.GetRequiredService<ILdapConnectionFactory>(),
sp.GetRequiredService<IOptionsMonitor<LdapPluginOptions>>(),
sp.GetRequiredService<IMongoDatabase>(),
sp.GetRequiredService<IAuthorityAirgapAuditStore>(),
sp.GetRequiredService<TimeProvider>(),
sp.GetRequiredService<ILogger<LdapClientProvisioningStore>>()));
@@ -75,12 +74,10 @@ internal sealed class LdapPluginRegistrar : IAuthorityPluginRegistrar
return DisabledLdapClaimsCache.Instance;
}
return new MongoLdapClaimsCache(
return new InMemoryLdapClaimsCache(
pluginName,
sp.GetRequiredService<IMongoDatabase>(),
cacheOptions,
ResolveTimeProvider(sp),
sp.GetRequiredService<ILogger<MongoLdapClaimsCache>>());
ResolveTimeProvider(sp));
});
context.Services.AddScoped(sp => new LdapClaimsEnricher(

View File

@@ -20,6 +20,6 @@
<ProjectReference Include="..\\StellaOps.Auth.Abstractions\\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\\StellaOps.Authority.Storage.Mongo\\StellaOps.Authority.Storage.Mongo.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Plugin\\StellaOps.Plugin.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Authority.Storage.Postgres\\StellaOps.Authority.Storage.Postgres.csproj" />
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Authority.Storage.Postgres\\StellaOps.Authority.Storage.Postgres.csproj" />
</ItemGroup>
</Project>