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

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

View File

@@ -0,0 +1,260 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Plugin.Standard.Tests.Security;
public class StandardCredentialAuditLoggerTests
{
[Fact]
public async Task RecordAsync_EmitsSuccessEvent_WithContext()
{
var sink = new TestAuthEventSink();
var accessor = new TestCredentialAuditContextAccessor();
using var scope = accessor.BeginScope(new AuthorityCredentialAuditContext(
"corr-success",
"client-app",
"tenant-alpha",
"203.0.113.10",
"198.51.100.5",
"TestAgent/1.0"));
var timestamp = DateTimeOffset.Parse("2025-11-08T12:00:00Z");
var sut = new StandardCredentialAuditLogger(
sink,
accessor,
new FixedTimeProvider(timestamp),
NullLogger<StandardCredentialAuditLogger>.Instance);
await sut.RecordAsync(
"standard",
"alice",
subjectId: "subject-1",
success: true,
failureCode: null,
reason: null,
properties: Array.Empty<AuthEventProperty>(),
CancellationToken.None);
var record = Assert.Single(sink.Records);
Assert.Equal("authority.plugin.standard.password_verification", record.EventType);
Assert.Equal(AuthEventOutcome.Success, record.Outcome);
Assert.Equal(timestamp, record.OccurredAt);
Assert.Equal("corr-success", record.CorrelationId);
Assert.Equal("subject-1", record.Subject?.SubjectId.Value);
Assert.Equal("alice", record.Subject?.Username.Value);
Assert.Equal("client-app", record.Client?.ClientId.Value);
Assert.Equal("standard", record.Client?.Provider.Value);
Assert.Equal("tenant-alpha", record.Tenant.Value);
Assert.Equal("203.0.113.10", record.Network?.RemoteAddress.Value);
Assert.Equal("198.51.100.5", record.Network?.ForwardedFor.Value);
Assert.Equal("TestAgent/1.0", record.Network?.UserAgent.Value);
}
[Fact]
public async Task RecordAsync_EmitsFailureEvent_WithProperties()
{
var sink = new TestAuthEventSink();
var accessor = new TestCredentialAuditContextAccessor();
using var scope = accessor.BeginScope(new AuthorityCredentialAuditContext(
"corr-failure",
null,
null,
null,
null,
null));
var sut = new StandardCredentialAuditLogger(
sink,
accessor,
new FixedTimeProvider(DateTimeOffset.Parse("2025-11-08T13:00:00Z")),
NullLogger<StandardCredentialAuditLogger>.Instance);
var properties = new[]
{
new AuthEventProperty
{
Name = "plugin.failed_attempts",
Value = ClassifiedString.Public("2")
}
};
await sut.RecordAsync(
"standard",
"bob",
subjectId: null,
success: false,
failureCode: AuthorityCredentialFailureCode.InvalidCredentials,
reason: "Invalid credentials.",
properties,
CancellationToken.None);
var record = Assert.Single(sink.Records);
Assert.Equal(AuthEventOutcome.Failure, record.Outcome);
Assert.Equal("Invalid credentials.", record.Reason);
Assert.Collection(
record.Properties,
property =>
{
Assert.Equal("plugin.failed_attempts", property.Name);
Assert.Equal("2", property.Value.Value);
},
property =>
{
Assert.Equal("plugin.failure_code", property.Name);
Assert.Equal(nameof(AuthorityCredentialFailureCode.InvalidCredentials), property.Value.Value);
});
}
[Fact]
public async Task RecordAsync_EmitsLockoutEvent_WithClassification()
{
var sink = new TestAuthEventSink();
var accessor = new TestCredentialAuditContextAccessor();
using var scope = accessor.BeginScope(new AuthorityCredentialAuditContext(
"corr-lockout",
"client-app",
"tenant-beta",
null,
null,
null));
var sut = new StandardCredentialAuditLogger(
sink,
accessor,
new FixedTimeProvider(DateTimeOffset.Parse("2025-11-08T14:30:00Z")),
NullLogger<StandardCredentialAuditLogger>.Instance);
var properties = new List<AuthEventProperty>
{
new()
{
Name = "plugin.lockout_until",
Value = ClassifiedString.Personal("2025-11-08T15:00:00Z")
}
};
await sut.RecordAsync(
"standard",
"carol",
subjectId: "subject-3",
success: false,
failureCode: AuthorityCredentialFailureCode.LockedOut,
reason: "Account locked.",
properties,
CancellationToken.None);
var record = Assert.Single(sink.Records);
Assert.Equal(AuthEventOutcome.LockedOut, record.Outcome);
Assert.Equal("Account locked.", record.Reason);
Assert.Equal("subject-3", record.Subject?.SubjectId.Value);
Assert.Equal("tenant-beta", record.Tenant.Value);
Assert.Collection(
record.Properties,
property =>
{
Assert.Equal("plugin.lockout_until", property.Name);
Assert.Equal("2025-11-08T15:00:00Z", property.Value.Value);
Assert.Equal(AuthEventDataClassification.Personal, property.Value.Classification);
},
property =>
{
Assert.Equal("plugin.failure_code", property.Name);
Assert.Equal(nameof(AuthorityCredentialFailureCode.LockedOut), property.Value.Value);
});
}
[Fact]
public async Task RecordAsync_AddsFailureCode_WhenPropertiesNull()
{
var sink = new TestAuthEventSink();
var accessor = new TestCredentialAuditContextAccessor();
var sut = new StandardCredentialAuditLogger(
sink,
accessor,
new FixedTimeProvider(DateTimeOffset.Parse("2025-11-08T15:45:00Z")),
NullLogger<StandardCredentialAuditLogger>.Instance);
await sut.RecordAsync(
"standard",
"dave",
subjectId: "subject-4",
success: false,
failureCode: AuthorityCredentialFailureCode.RequiresMfa,
reason: "MFA required.",
properties: null,
CancellationToken.None);
var record = Assert.Single(sink.Records);
var property = Assert.Single(record.Properties);
Assert.Equal("plugin.failure_code", property.Name);
Assert.Equal(nameof(AuthorityCredentialFailureCode.RequiresMfa), property.Value.Value);
}
private sealed class TestAuthEventSink : IAuthEventSink
{
public List<AuthEventRecord> Records { get; } = new();
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
{
Records.Add(record);
return ValueTask.CompletedTask;
}
}
private sealed class TestCredentialAuditContextAccessor : IAuthorityCredentialAuditContextAccessor
{
private readonly AsyncLocal<AuthorityCredentialAuditContext?> current = new();
public AuthorityCredentialAuditContext? Current => current.Value;
public IDisposable BeginScope(AuthorityCredentialAuditContext context)
{
var previous = current.Value;
current.Value = context;
return new Scope(this, previous);
}
private sealed class Scope : IDisposable
{
private readonly TestCredentialAuditContextAccessor accessor;
private readonly AuthorityCredentialAuditContext? previous;
private bool disposed;
public Scope(TestCredentialAuditContextAccessor accessor, AuthorityCredentialAuditContext? previous)
{
this.accessor = accessor;
this.previous = previous;
}
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
accessor.current.Value = previous;
}
}
}
private sealed class FixedTimeProvider : TimeProvider
{
private readonly DateTimeOffset timestamp;
public FixedTimeProvider(DateTimeOffset timestamp)
{
this.timestamp = timestamp;
}
public override DateTimeOffset GetUtcNow() => timestamp;
}
}

View File

@@ -45,9 +45,8 @@ public class StandardClientProvisioningStoreTests
Assert.Contains("scopea", descriptor.AllowedScopes);
}
[Fact]
[Fact]
public async Task CreateOrUpdateAsync_NormalisesTenant()
[Fact]
public async Task CreateOrUpdateAsync_NormalisesTenant()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
@@ -72,9 +71,8 @@ public class StandardClientProvisioningStoreTests
Assert.NotNull(descriptor);
Assert.Equal("tenant-alpha", descriptor!.Tenant);
}
public async Task CreateOrUpdateAsync_StoresAudiences()
[Fact]
public async Task CreateOrUpdateAsync_StoresAudiences()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();

View File

@@ -13,9 +13,10 @@ using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard;
using StellaOps.Authority.Plugin.Standard.Bootstrap;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Plugin.Standard.Tests;
@@ -54,36 +55,8 @@ public class StandardPluginRegistrarTests
new Dictionary<string, string?>(),
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityLoginAttemptStore>(sp =>
{
var mongo = sp.GetRequiredService<IMongoDatabase>();
var collection = mongo.GetCollection<AuthorityLoginAttemptDocument>("authority_login_attempts");
return new AuthorityLoginAttemptStore(collection, NullLogger<AuthorityLoginAttemptStore>.Instance);
});
services.AddSingleton<IAuthorityLoginAttemptStore>(sp =>
{
var mongo = sp.GetRequiredService<IMongoDatabase>();
var collection = mongo.GetCollection<AuthorityLoginAttemptDocument>("authority_login_attempts");
return new AuthorityLoginAttemptStore(collection, NullLogger<AuthorityLoginAttemptStore>.Instance);
});
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System);
services.AddSingleton(TimeProvider.System);
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
@@ -140,12 +113,10 @@ public class StandardPluginRegistrarTests
new Dictionary<string, string?>(),
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection();
var loggerProvider = new CapturingLoggerProvider();
services.AddLogging(builder => builder.AddProvider(loggerProvider));
services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
var loggerProvider = new CapturingLoggerProvider();
services.AddLogging(builder => builder.AddProvider(loggerProvider));
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
@@ -178,13 +149,8 @@ public class StandardPluginRegistrarTests
new Dictionary<string, string?>(),
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton(TimeProvider.System);
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
@@ -223,12 +189,9 @@ public class StandardPluginRegistrarTests
"standard.yaml");
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
var registrar = new StandardPluginRegistrar();
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
using var provider = services.BuildServiceProvider();
@@ -267,10 +230,8 @@ public class StandardPluginRegistrarTests
configPath);
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<IMongoDatabase>(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database);
services.AddSingleton(TimeProvider.System);
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
@@ -347,11 +308,11 @@ internal sealed class StubRevocationStore : IAuthorityRevocationStore
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
}
internal sealed class InMemoryClientStore : IAuthorityClientStore
{
private readonly Dictionary<string, AuthorityClientDocument> clients = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
internal sealed class InMemoryClientStore : IAuthorityClientStore
{
private readonly Dictionary<string, AuthorityClientDocument> clients = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
clients.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
@@ -363,6 +324,93 @@ internal sealed class InMemoryClientStore : IAuthorityClientStore
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(clients.Remove(clientId));
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(clients.Remove(clientId));
}
internal sealed class InMemoryLoginAttemptStore : IAuthorityLoginAttemptStore
{
private readonly List<AuthorityLoginAttemptDocument> documents = new();
public ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
documents.Add(document);
return ValueTask.CompletedTask;
}
public ValueTask<IReadOnlyList<AuthorityLoginAttemptDocument>> ListRecentAsync(string subjectId, int limit, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityLoginAttemptDocument>>(documents);
}
internal sealed class TestAuthorityCredentialAuditContextAccessor : IAuthorityCredentialAuditContextAccessor
{
private readonly AsyncLocal<AuthorityCredentialAuditContext?> current = new();
public AuthorityCredentialAuditContext? Current => current.Value;
public IDisposable BeginScope(AuthorityCredentialAuditContext context)
{
var previous = current.Value;
current.Value = context;
return new Scope(this, previous);
}
private sealed class Scope : IDisposable
{
private readonly TestAuthorityCredentialAuditContextAccessor accessor;
private readonly AuthorityCredentialAuditContext? previous;
private bool disposed;
public Scope(TestAuthorityCredentialAuditContextAccessor accessor, AuthorityCredentialAuditContext? previous)
{
this.accessor = accessor;
this.previous = previous;
}
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
accessor.current.Value = previous;
}
}
}
internal sealed class TestAuthEventSink : IAuthEventSink
{
public List<AuthEventRecord> Records { get; } = new();
public ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken)
{
Records.Add(record);
return ValueTask.CompletedTask;
}
}
internal static class StandardPluginRegistrarTestHelpers
{
public static ServiceCollection CreateServiceCollection(
IMongoDatabase database,
IAuthEventSink? authEventSink = null,
IAuthorityCredentialAuditContextAccessor? auditContextAccessor = null)
{
ArgumentNullException.ThrowIfNull(database);
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton(database);
services.AddSingleton<IAuthorityClientStore>(new InMemoryClientStore());
services.AddSingleton<IAuthorityRevocationStore>(new StubRevocationStore());
services.AddSingleton<IAuthorityLoginAttemptStore>(new InMemoryLoginAttemptStore());
services.AddSingleton(TimeProvider.System);
services.AddSingleton<IAuthorityCredentialAuditContextAccessor>(
auditContextAccessor ?? new TestAuthorityCredentialAuditContextAccessor());
services.AddSingleton<IAuthEventSink>(authEventSink ?? new TestAuthEventSink());
return services;
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -10,6 +11,7 @@ using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Plugin.Standard.Tests;
@@ -113,12 +115,27 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
Assert.NotNull(second.RetryAfter);
Assert.True(second.RetryAfter.Value > System.TimeSpan.Zero);
Assert.Contains(second.AuditProperties, property => property.Name == "plugin.lockout_until");
var secondRetryProperty = second.AuditProperties.Single(property => property.Name == "plugin.retry_after_seconds");
var expectedSecondRetry = Math.Ceiling(second.RetryAfter!.Value.TotalSeconds).ToString(CultureInfo.InvariantCulture);
Assert.Equal(expectedSecondRetry, secondRetryProperty.Value.Value);
Assert.Equal(2, auditLogger.Events.Count);
var third = await store.VerifyPasswordAsync("bob", "nope", CancellationToken.None);
Assert.False(third.Succeeded);
Assert.Equal(AuthorityCredentialFailureCode.LockedOut, third.FailureCode);
Assert.NotNull(third.RetryAfter);
var thirdRetryProperty = third.AuditProperties.Single(property => property.Name == "plugin.retry_after_seconds");
var expectedThirdRetry = Math.Ceiling(third.RetryAfter!.Value.TotalSeconds).ToString(CultureInfo.InvariantCulture);
Assert.Equal(expectedThirdRetry, thirdRetryProperty.Value.Value);
Assert.Equal(3, auditLogger.Events.Count);
Assert.False(auditLogger.Events[0].Success);
Assert.Equal(AuthorityCredentialFailureCode.InvalidCredentials, auditLogger.Events[0].FailureCode);
Assert.False(auditLogger.Events[1].Success);
Assert.Equal(AuthorityCredentialFailureCode.LockedOut, auditLogger.Events[1].FailureCode);
var lastAudit = auditLogger.Events[^1];
Assert.False(lastAudit.Success);
Assert.Equal(AuthorityCredentialFailureCode.LockedOut, lastAudit.FailureCode);
Assert.Contains(lastAudit.Properties, property => property.Name == "plugin.retry_after_seconds");
}
[Fact]
@@ -206,12 +223,22 @@ internal sealed class TestAuditLogger : IStandardCredentialAuditLogger
bool success,
AuthorityCredentialFailureCode? failureCode,
string? reason,
IReadOnlyList<AuthEventProperty> properties,
IReadOnlyList<AuthEventProperty>? properties,
CancellationToken cancellationToken)
{
events.Add(new AuditEntry(normalizedUsername, success, failureCode, reason));
events.Add(new AuditEntry(
normalizedUsername,
success,
failureCode,
reason,
properties ?? Array.Empty<AuthEventProperty>()));
return ValueTask.CompletedTask;
}
internal sealed record AuditEntry(string Username, bool Success, AuthorityCredentialFailureCode? FailureCode, string? Reason);
internal sealed record AuditEntry(
string Username,
bool Success,
AuthorityCredentialFailureCode? FailureCode,
string? Reason,
IReadOnlyList<AuthEventProperty> Properties);
}