save progress

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

View File

@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
using StellaOps.Authority.Plugin.Ldap.Connections;
@@ -12,13 +14,18 @@ namespace StellaOps.Authority.Plugin.Ldap.Tests.ClientProvisioning;
public class LdapCapabilityProbeTests
{
[Fact]
public void Evaluate_ReturnsTrue_WhenWritesSucceed()
public async Task EvaluateAsync_ReturnsTrue_WhenWritesSucceed()
{
var connection = new FakeLdapConnection();
var probe = CreateProbe(connection);
var options = CreateOptions(enableProvisioning: true, enableBootstrap: true);
var snapshot = probe.Evaluate(options, checkClientProvisioning: true, checkBootstrap: true);
var snapshot = await probe.EvaluateAsync(
options,
checkClientProvisioning: true,
checkBootstrap: true,
options.CapabilityProbe.Timeout,
CancellationToken.None);
Assert.True(snapshot.ClientProvisioningWritable);
Assert.True(snapshot.BootstrapWritable);
@@ -26,7 +33,7 @@ public class LdapCapabilityProbeTests
}
[Fact]
public void Evaluate_ReturnsFalse_WhenAccessDenied()
public async Task EvaluateAsync_ReturnsFalse_WhenAccessDenied()
{
var connection = new FakeLdapConnection
{
@@ -35,7 +42,12 @@ public class LdapCapabilityProbeTests
var probe = CreateProbe(connection);
var options = CreateOptions(enableProvisioning: true, enableBootstrap: true);
var snapshot = probe.Evaluate(options, checkClientProvisioning: true, checkBootstrap: true);
var snapshot = await probe.EvaluateAsync(
options,
checkClientProvisioning: true,
checkBootstrap: true,
options.CapabilityProbe.Timeout,
CancellationToken.None);
Assert.False(snapshot.ClientProvisioningWritable);
Assert.False(snapshot.BootstrapWritable);

View File

@@ -0,0 +1,74 @@
using System;
using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
using Xunit;
namespace StellaOps.Authority.Plugin.Ldap.Tests.ClientProvisioning;
public sealed class LdapCapabilitySnapshotCacheTests
{
[Fact]
public void TryGet_ReturnsSnapshot_WhenFingerprintMatchesAndNotExpired()
{
var options = CreateOptions();
var fingerprint = LdapCapabilitySnapshotCache.ComputeFingerprint(options, checkClientProvisioning: true, checkBootstrap: true);
var snapshot = new LdapCapabilitySnapshot(true, false);
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
LdapCapabilitySnapshotCache.Set("corp-ldap-cache-1", fingerprint, now, TimeSpan.FromMinutes(5), snapshot);
var found = LdapCapabilitySnapshotCache.TryGet("corp-ldap-cache-1", fingerprint, now.AddMinutes(1), out var cached);
Assert.True(found);
Assert.True(cached.ClientProvisioningWritable);
Assert.False(cached.BootstrapWritable);
}
[Fact]
public void TryGet_ReturnsFalse_WhenExpired()
{
var options = CreateOptions();
var fingerprint = LdapCapabilitySnapshotCache.ComputeFingerprint(options, checkClientProvisioning: true, checkBootstrap: false);
var snapshot = new LdapCapabilitySnapshot(true, true);
var now = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
LdapCapabilitySnapshotCache.Set("corp-ldap-cache-2", fingerprint, now, TimeSpan.FromSeconds(1), snapshot);
var found = LdapCapabilitySnapshotCache.TryGet("corp-ldap-cache-2", fingerprint, now.AddSeconds(2), out _);
Assert.False(found);
}
[Fact]
public void ComputeFingerprint_ChangesWhenOptionsChange()
{
var options = CreateOptions();
var fingerprintA = LdapCapabilitySnapshotCache.ComputeFingerprint(options, checkClientProvisioning: true, checkBootstrap: true);
options.Connection.Host = "ldaps://ldap-secondary.example.internal";
var fingerprintB = LdapCapabilitySnapshotCache.ComputeFingerprint(options, checkClientProvisioning: true, checkBootstrap: true);
Assert.NotEqual(fingerprintA, fingerprintB);
}
private static LdapPluginOptions CreateOptions()
=> new()
{
Connection = new LdapConnectionOptions
{
Host = "ldaps://ldap.example.internal",
BindDn = "cn=service,dc=example,dc=internal",
BindPasswordSecret = "service-secret",
UserDnFormat = "uid={username},ou=people,dc=example,dc=internal"
},
ClientProvisioning = new LdapClientProvisioningOptions
{
Enabled = true,
ContainerDn = "ou=service,dc=example,dc=internal"
},
Bootstrap = new LdapBootstrapOptions
{
Enabled = true,
ContainerDn = "ou=people,dc=example,dc=internal"
}
};
}

View File

@@ -0,0 +1,31 @@
using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
using Xunit;
namespace StellaOps.Authority.Plugin.Ldap.Tests.ClientProvisioning;
public sealed class LdapDistinguishedNameHelperTests
{
[Fact]
public void UnescapeRdnValue_ReturnsOriginal_WhenNoEscapes()
{
var value = LdapDistinguishedNameHelper.UnescapeRdnValue("john.doe");
Assert.Equal("john.doe", value);
}
[Fact]
public void UnescapeRdnValue_UnescapesSimpleCharacters()
{
var value = LdapDistinguishedNameHelper.UnescapeRdnValue("john\\,doe");
Assert.Equal("john,doe", value);
}
[Fact]
public void UnescapeRdnValue_UnescapesHexPairs()
{
var value = LdapDistinguishedNameHelper.UnescapeRdnValue("john\\2Cdoe");
Assert.Equal("john,doe", value);
}
}

View File

@@ -148,6 +148,42 @@ public class LdapCredentialStoreTests
Assert.Equal(AuthorityCredentialFailureCode.InvalidCredentials, result.FailureCode);
}
[Fact]
public async Task FindBySubjectAsync_UsesSubjectDnAndResolvesUsername()
{
var options = CreateBaseOptions();
options.Connection.UserDnFormat = "uid={username},ou=people,dc=example,dc=internal";
options.Connection.UsernameAttribute = "uid";
var monitor = new StaticOptionsMonitor(options);
var connection = new FakeLdapConnection();
connection.OnFindAsync = (baseDn, filter, attributes, ct) =>
{
Assert.Equal("uid=j.doe,ou=people,dc=example,dc=internal", baseDn);
Assert.Equal("(objectClass=*)", filter);
Assert.Contains("uid", attributes);
var attr = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase)
{
["uid"] = new List<string> { "j.doe" },
["displayName"] = new List<string> { "John Doe" }
};
return ValueTask.FromResult<LdapSearchEntry?>(new LdapSearchEntry(baseDn, attr));
};
var store = CreateStore(
monitor,
new FakeLdapConnectionFactory(connection));
var result = await store.FindBySubjectAsync("uid=j.doe,ou=people,dc=example,dc=internal", CancellationToken.None);
Assert.NotNull(result);
Assert.Equal("uid=j.doe,ou=people,dc=example,dc=internal", result!.SubjectId);
Assert.Equal("j.doe", result.Username);
Assert.Equal("John Doe", result.DisplayName);
}
[Fact]
public async Task UpsertUserAsync_WritesBootstrapEntryAndAudit()
{

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Security;
@@ -9,8 +10,6 @@ namespace StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
internal sealed class LdapCapabilityProbe
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(5);
private readonly string pluginName;
private readonly ILdapConnectionFactory connectionFactory;
private readonly ILogger logger;
@@ -25,7 +24,12 @@ internal sealed class LdapCapabilityProbe
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public LdapCapabilitySnapshot Evaluate(LdapPluginOptions options, bool checkClientProvisioning, bool checkBootstrap)
public async ValueTask<LdapCapabilitySnapshot> EvaluateAsync(
LdapPluginOptions options,
bool checkClientProvisioning,
bool checkBootstrap,
TimeSpan timeout,
CancellationToken cancellationToken)
{
if (!checkClientProvisioning && !checkBootstrap)
{
@@ -37,35 +41,30 @@ internal sealed class LdapCapabilityProbe
try
{
using var timeoutCts = new CancellationTokenSource(DefaultTimeout);
var cancellationToken = timeoutCts.Token;
var connection = connectionFactory.CreateAsync(cancellationToken).GetAwaiter().GetResult();
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(timeout);
var timeoutToken = timeoutCts.Token;
try
await using var connection = await connectionFactory.CreateAsync(timeoutToken).ConfigureAwait(false);
await MaybeBindServiceAccountAsync(connection, options, timeoutToken).ConfigureAwait(false);
if (checkClientProvisioning)
{
MaybeBindServiceAccount(connection, options, cancellationToken);
if (checkClientProvisioning)
{
clientProvisioningWritable = TryProbeContainer(
connection,
options.ClientProvisioning.ContainerDn,
options.ClientProvisioning.RdnAttribute,
cancellationToken);
}
if (checkBootstrap)
{
bootstrapWritable = TryProbeContainer(
connection,
options.Bootstrap.ContainerDn,
options.Bootstrap.RdnAttribute,
cancellationToken);
}
clientProvisioningWritable = await TryProbeContainerAsync(
connection,
options.ClientProvisioning.ContainerDn,
options.ClientProvisioning.RdnAttribute,
timeoutToken).ConfigureAwait(false);
}
finally
if (checkBootstrap)
{
connection.DisposeAsync().GetAwaiter().GetResult();
bootstrapWritable = await TryProbeContainerAsync(
connection,
options.Bootstrap.ContainerDn,
options.Bootstrap.RdnAttribute,
timeoutToken).ConfigureAwait(false);
}
}
catch (Exception ex) when (ex is LdapOperationException or LdapTransientException)
@@ -87,7 +86,10 @@ internal sealed class LdapCapabilityProbe
return new LdapCapabilitySnapshot(clientProvisioningWritable, bootstrapWritable);
}
private void MaybeBindServiceAccount(ILdapConnectionHandle connection, LdapPluginOptions options, CancellationToken cancellationToken)
private static async ValueTask MaybeBindServiceAccountAsync(
ILdapConnectionHandle connection,
LdapPluginOptions options,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(options.Connection.BindDn))
{
@@ -95,10 +97,10 @@ internal sealed class LdapCapabilityProbe
}
var secret = LdapSecretResolver.Resolve(options.Connection.BindPasswordSecret);
connection.BindAsync(options.Connection.BindDn!, secret, cancellationToken).GetAwaiter().GetResult();
await connection.BindAsync(options.Connection.BindDn!, secret, cancellationToken).ConfigureAwait(false);
}
private bool TryProbeContainer(
private async ValueTask<bool> TryProbeContainerAsync(
ILdapConnectionHandle connection,
string? containerDn,
string rdnAttribute,
@@ -125,8 +127,8 @@ internal sealed class LdapCapabilityProbe
try
{
connection.AddEntryAsync(distinguishedName, attributes, cancellationToken).GetAwaiter().GetResult();
connection.DeleteEntryAsync(distinguishedName, cancellationToken).GetAwaiter().GetResult();
await connection.AddEntryAsync(distinguishedName, attributes, cancellationToken).ConfigureAwait(false);
await connection.DeleteEntryAsync(distinguishedName, cancellationToken).ConfigureAwait(false);
return true;
}
catch (LdapInsufficientAccessException ex)
@@ -141,15 +143,15 @@ internal sealed class LdapCapabilityProbe
}
finally
{
TryDeleteProbeEntry(connection, distinguishedName, cancellationToken);
await TryDeleteProbeEntryAsync(connection, distinguishedName, cancellationToken).ConfigureAwait(false);
}
}
private void TryDeleteProbeEntry(ILdapConnectionHandle connection, string dn, CancellationToken cancellationToken)
private static async ValueTask TryDeleteProbeEntryAsync(ILdapConnectionHandle connection, string dn, CancellationToken cancellationToken)
{
try
{
connection.DeleteEntryAsync(dn, cancellationToken).GetAwaiter().GetResult();
await connection.DeleteEntryAsync(dn, cancellationToken).ConfigureAwait(false);
}
catch
{

View File

@@ -1,5 +1,7 @@
using System;
using System.Collections.Concurrent;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
@@ -7,12 +9,62 @@ internal sealed record LdapCapabilitySnapshot(bool ClientProvisioningWritable, b
internal static class LdapCapabilitySnapshotCache
{
private static readonly ConcurrentDictionary<string, LdapCapabilitySnapshot> Cache = new(StringComparer.OrdinalIgnoreCase);
private static readonly ConcurrentDictionary<string, CacheEntry> Cache = new(StringComparer.OrdinalIgnoreCase);
public static LdapCapabilitySnapshot GetOrAdd(string pluginName, Func<LdapCapabilitySnapshot> factory)
public static bool TryGet(string pluginName, string fingerprint, DateTimeOffset now, out LdapCapabilitySnapshot snapshot)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pluginName);
ArgumentNullException.ThrowIfNull(factory);
return Cache.GetOrAdd(pluginName, _ => factory());
if (Cache.TryGetValue(pluginName, out var entry) &&
string.Equals(entry.Fingerprint, fingerprint, StringComparison.Ordinal) &&
entry.ExpiresAt > now)
{
snapshot = entry.Snapshot;
return true;
}
snapshot = new LdapCapabilitySnapshot(false, false);
return false;
}
public static void Set(string pluginName, string fingerprint, DateTimeOffset now, TimeSpan ttl, LdapCapabilitySnapshot snapshot)
{
ArgumentException.ThrowIfNullOrWhiteSpace(pluginName);
ArgumentException.ThrowIfNullOrWhiteSpace(fingerprint);
var entry = new CacheEntry(snapshot, fingerprint, now.Add(ttl));
Cache.AddOrUpdate(pluginName, entry, (_, _) => entry);
}
public static string ComputeFingerprint(LdapPluginOptions options, bool checkClientProvisioning, bool checkBootstrap)
{
ArgumentNullException.ThrowIfNull(options);
var builder = new StringBuilder();
Append(builder, options.Connection.Host);
Append(builder, options.Connection.Port.ToString());
Append(builder, options.Connection.UseStartTls.ToString());
Append(builder, options.Connection.BindDn);
Append(builder, options.Connection.BindPasswordSecret);
Append(builder, options.ClientProvisioning.Enabled.ToString());
Append(builder, options.ClientProvisioning.ContainerDn);
Append(builder, options.ClientProvisioning.RdnAttribute);
Append(builder, options.Bootstrap.Enabled.ToString());
Append(builder, options.Bootstrap.ContainerDn);
Append(builder, options.Bootstrap.RdnAttribute);
Append(builder, checkClientProvisioning.ToString());
Append(builder, checkBootstrap.ToString());
var bytes = Encoding.UTF8.GetBytes(builder.ToString());
var hash = SHA256.HashData(bytes);
return Convert.ToHexString(hash).ToLowerInvariant();
}
private static void Append(StringBuilder builder, string? value)
{
builder.Append(value ?? string.Empty);
builder.Append('|');
}
private sealed record CacheEntry(LdapCapabilitySnapshot Snapshot, string Fingerprint, DateTimeOffset ExpiresAt);
}

View File

@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
namespace StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
@@ -58,6 +60,39 @@ internal static class LdapDistinguishedNameHelper
.Replace("\0", "\\00", StringComparison.Ordinal);
}
public static string UnescapeRdnValue(string value)
{
if (string.IsNullOrEmpty(value) || !value.Contains('\\'))
{
return value;
}
var builder = new StringBuilder(value.Length);
for (var i = 0; i < value.Length; i++)
{
var ch = value[i];
if (ch != '\\' || i == value.Length - 1)
{
builder.Append(ch);
continue;
}
var next = value[i + 1];
if (i + 2 < value.Length && IsHex(next) && IsHex(value[i + 2]))
{
var hex = $"{next}{value[i + 2]}";
builder.Append((char)int.Parse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture));
i += 2;
continue;
}
builder.Append(next);
i++;
}
return builder.ToString();
}
private static bool HasSpecial(ReadOnlySpan<char> chars)
{
foreach (var c in chars)
@@ -70,4 +105,9 @@ internal static class LdapDistinguishedNameHelper
return false;
}
private static bool IsHex(char value)
=> value is >= '0' and <= '9'
or >= 'a' and <= 'f'
or >= 'A' and <= 'F';
}

View File

@@ -45,7 +45,7 @@ internal sealed class DirectoryServicesLdapConnectionFactory : ILdapConnectionFa
var identifier = new LdapDirectoryIdentifier(connectionOptions.Host!, connectionOptions.Port, fullyQualifiedDnsHostName: false, connectionless: false);
var connection = new LdapConnection(identifier)
{
Timeout = TimeSpan.FromSeconds(10)
Timeout = TimeSpan.FromSeconds(connectionOptions.TimeoutSeconds)
};
connection.SessionOptions.ProtocolVersion = 3;

View File

@@ -239,11 +239,50 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
}
}
public ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)
public async ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)
{
_ = subjectId;
_ = cancellationToken;
return ValueTask.FromResult<AuthorityUserDescriptor?>(null);
if (string.IsNullOrWhiteSpace(subjectId))
{
return null;
}
var options = optionsMonitor.Get(pluginName);
try
{
await using var connection = await connectionFactory.CreateAsync(cancellationToken).ConfigureAwait(false);
await EnsureServiceBindAsync(connection, options, cancellationToken).ConfigureAwait(false);
var attributes = BuildLookupAttributes(options);
var entry = await ExecuteWithRetryAsync(
"subject_lookup",
ct => connection.FindEntryAsync(subjectId, "(objectClass=*)", attributes, ct),
cancellationToken)
.ConfigureAwait(false);
if (entry is null)
{
return null;
}
var resolvedUsername = ResolveUsername(entry, options, subjectId);
if (string.IsNullOrWhiteSpace(resolvedUsername))
{
resolvedUsername = subjectId;
}
return BuildDescriptor(entry, NormalizeUsername(resolvedUsername), passwordRequiresReset: false);
}
catch (LdapTransientException ex)
{
logger.LogWarning(ex, "LDAP plugin {Plugin} transient failure while resolving subject {SubjectId}.", pluginName, subjectId);
return null;
}
catch (LdapOperationException ex)
{
logger.LogError(ex, "LDAP plugin {Plugin} failed to resolve subject {SubjectId}.", pluginName, subjectId);
return null;
}
}
private async Task EnsureServiceBindAsync(ILdapConnectionHandle connection, LdapPluginOptions options, CancellationToken cancellationToken)
@@ -358,79 +397,59 @@ internal sealed class LdapCredentialStore : IUserCredentialStore
=> username.Trim().ToLowerInvariant();
private static string BuildUserDistinguishedName(string template, string username)
=> template.Replace("{username}", EscapeDnValue(username), StringComparison.Ordinal);
private static string EscapeDnValue(string value)
{
var needsEscape = value.Any(static ch => ch is ',' or '+' or '"' or '\\' or '<' or '>' or ';' or '#' or '=' || char.IsWhiteSpace(ch));
if (!needsEscape)
{
return value;
}
return value.Replace("\\", "\\\\", StringComparison.Ordinal)
.Replace(",", "\\,", StringComparison.Ordinal)
.Replace("+", "\\+", StringComparison.Ordinal)
.Replace("\"", "\\\"", StringComparison.Ordinal)
.Replace("<", "\\<", StringComparison.Ordinal)
.Replace(">", "\\>", StringComparison.Ordinal)
.Replace(";", "\\;", StringComparison.Ordinal)
.Replace("#", "\\#", StringComparison.Ordinal)
.Replace("=", "\\=", StringComparison.Ordinal);
}
=> template.Replace("{username}", LdapDistinguishedNameHelper.EscapeRdnValue(username), StringComparison.Ordinal);
private static string BuildUserFilter(LdapPluginOptions options, string username)
{
if (!string.IsNullOrWhiteSpace(options.Queries.UserFilter))
{
return options.Queries.UserFilter.Replace("{username}", EscapeFilterValue(username), StringComparison.Ordinal);
return options.Queries.UserFilter.Replace("{username}", LdapDistinguishedNameHelper.EscapeFilterValue(username), StringComparison.Ordinal);
}
var attribute = options.Connection.UsernameAttribute ?? "uid";
return $"({attribute}={EscapeFilterValue(username)})";
return $"({attribute}={LdapDistinguishedNameHelper.EscapeFilterValue(username)})";
}
private static string EscapeFilterValue(string value)
private static IReadOnlyCollection<string> BuildLookupAttributes(LdapPluginOptions options)
{
Span<char> buffer = stackalloc char[value.Length * 3];
var index = 0;
var attributes = options.Queries.Attributes.Length > 0
? new List<string>(options.Queries.Attributes)
: new List<string> { "displayName", "cn", "mail" };
foreach (var ch in value)
if (!string.IsNullOrWhiteSpace(options.Connection.UsernameAttribute) &&
!attributes.Any(attribute => string.Equals(attribute, options.Connection.UsernameAttribute, StringComparison.OrdinalIgnoreCase)))
{
switch (ch)
{
case '\\':
buffer[index++] = '\\';
buffer[index++] = '5';
buffer[index++] = 'c';
break;
case '*':
buffer[index++] = '\\';
buffer[index++] = '2';
buffer[index++] = 'a';
break;
case '(':
buffer[index++] = '\\';
buffer[index++] = '2';
buffer[index++] = '8';
break;
case ')':
buffer[index++] = '\\';
buffer[index++] = '2';
buffer[index++] = '9';
break;
case '\0':
buffer[index++] = '\\';
buffer[index++] = '0';
buffer[index++] = '0';
break;
default:
buffer[index++] = ch;
break;
}
attributes.Add(options.Connection.UsernameAttribute!);
}
return new string(buffer[..index]);
return attributes;
}
private static string? ResolveUsername(LdapSearchEntry entry, LdapPluginOptions options, string subjectId)
{
if (!string.IsNullOrWhiteSpace(options.Connection.UsernameAttribute) &&
entry.Attributes.TryGetValue(options.Connection.UsernameAttribute!, out var values) &&
values.Count > 0 &&
!string.IsNullOrWhiteSpace(values[0]))
{
return values[0];
}
return TryExtractRdnValue(subjectId);
}
private static string? TryExtractRdnValue(string subjectId)
{
var commaIndex = subjectId.IndexOf(',', StringComparison.Ordinal);
var rdn = commaIndex >= 0 ? subjectId[..commaIndex] : subjectId;
var equalsIndex = rdn.IndexOf('=', StringComparison.Ordinal);
if (equalsIndex <= 0 || equalsIndex >= rdn.Length - 1)
{
return null;
}
var value = rdn[(equalsIndex + 1)..];
return LdapDistinguishedNameHelper.UnescapeRdnValue(value);
}
private AuthorityUserDescriptor BuildDescriptor(LdapSearchEntry entry, string normalizedUsername, bool passwordRequiresReset)

View File

@@ -22,10 +22,14 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
private readonly IOptionsMonitor<LdapPluginOptions> optionsMonitor;
private readonly LdapClientProvisioningStore clientProvisioningStore;
private readonly ILogger<LdapIdentityProviderPlugin> logger;
private readonly AuthorityIdentityProviderCapabilities capabilities;
private readonly bool clientProvisioningActive;
private readonly bool bootstrapActive;
private readonly LdapCapabilityProbe capabilityProbe;
private readonly AuthorityIdentityProviderCapabilities manifestCapabilities;
private readonly SemaphoreSlim capabilityGate = new(1, 1);
private AuthorityIdentityProviderCapabilities capabilities;
private bool clientProvisioningActive;
private bool bootstrapActive;
private bool loggedProvisioningDegrade;
private bool loggedBootstrapDegrade;
public LdapIdentityProviderPlugin(
AuthorityPluginContext pluginContext,
@@ -46,7 +50,7 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
capabilityProbe = new LdapCapabilityProbe(pluginContext.Manifest.Name, connectionFactory, logger);
var manifestCapabilities = AuthorityIdentityProviderCapabilities.FromCapabilities(pluginContext.Manifest.Capabilities);
manifestCapabilities = AuthorityIdentityProviderCapabilities.FromCapabilities(pluginContext.Manifest.Capabilities);
var pluginOptions = optionsMonitor.Get(pluginContext.Manifest.Name);
var provisioningOptions = pluginOptions.ClientProvisioning;
var bootstrapOptions = pluginOptions.Bootstrap;
@@ -65,40 +69,7 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
pluginContext.Manifest.Name);
}
var snapshot = LdapCapabilitySnapshotCache.GetOrAdd(
pluginContext.Manifest.Name,
() => capabilityProbe.Evaluate(
pluginOptions,
manifestCapabilities.SupportsClientProvisioning && provisioningOptions.Enabled,
manifestCapabilities.SupportsBootstrap && bootstrapOptions.Enabled));
clientProvisioningActive = manifestCapabilities.SupportsClientProvisioning
&& provisioningOptions.Enabled
&& snapshot.ClientProvisioningWritable;
bootstrapActive = manifestCapabilities.SupportsBootstrap
&& bootstrapOptions.Enabled
&& snapshot.BootstrapWritable;
if (manifestCapabilities.SupportsClientProvisioning && provisioningOptions.Enabled && !clientProvisioningActive)
{
this.logger.LogWarning(
"LDAP plugin '{PluginName}' degraded client provisioning capability because LDAP write permissions could not be validated.",
pluginContext.Manifest.Name);
}
if (manifestCapabilities.SupportsBootstrap && bootstrapOptions.Enabled && !bootstrapActive)
{
this.logger.LogWarning(
"LDAP plugin '{PluginName}' degraded bootstrap capability because LDAP write permissions could not be validated.",
pluginContext.Manifest.Name);
}
capabilities = new AuthorityIdentityProviderCapabilities(
SupportsPassword: true,
SupportsMfa: manifestCapabilities.SupportsMfa,
SupportsClientProvisioning: clientProvisioningActive,
SupportsBootstrap: bootstrapActive);
InitializeCapabilities(pluginOptions);
}
public string Name => pluginContext.Manifest.Name;
@@ -119,6 +90,7 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
{
try
{
await RefreshCapabilitiesAsync(cancellationToken).ConfigureAwait(false);
await using var connection = await connectionFactory.CreateAsync(cancellationToken).ConfigureAwait(false);
var options = optionsMonitor.Get(Name);
@@ -129,14 +101,13 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
}
var degradeReasons = new List<string>();
var latestOptions = optionsMonitor.Get(Name);
if (latestOptions.ClientProvisioning.Enabled && !clientProvisioningActive)
if (options.ClientProvisioning.Enabled && !clientProvisioningActive)
{
degradeReasons.Add("clientProvisioningDisabled");
}
if (latestOptions.Bootstrap.Enabled && !bootstrapActive)
if (options.Bootstrap.Enabled && !bootstrapActive)
{
degradeReasons.Add("bootstrapDisabled");
}
@@ -164,4 +135,102 @@ internal sealed class LdapIdentityProviderPlugin : IIdentityProviderPlugin
return AuthorityPluginHealthResult.Degraded(ex.Message);
}
}
private void InitializeCapabilities(LdapPluginOptions options)
{
var checkProvisioning = manifestCapabilities.SupportsClientProvisioning && options.ClientProvisioning.Enabled;
var checkBootstrap = manifestCapabilities.SupportsBootstrap && options.Bootstrap.Enabled;
var fingerprint = LdapCapabilitySnapshotCache.ComputeFingerprint(options, checkProvisioning, checkBootstrap);
if (LdapCapabilitySnapshotCache.TryGet(Name, fingerprint, DateTimeOffset.UtcNow, out var snapshot))
{
UpdateCapabilities(snapshot, checkProvisioning, checkBootstrap, logDegrade: true);
}
else
{
UpdateCapabilities(new LdapCapabilitySnapshot(false, false), checkProvisioning, checkBootstrap, logDegrade: false);
}
}
private async Task RefreshCapabilitiesAsync(CancellationToken cancellationToken)
{
var options = optionsMonitor.Get(Name);
var checkProvisioning = manifestCapabilities.SupportsClientProvisioning && options.ClientProvisioning.Enabled;
var checkBootstrap = manifestCapabilities.SupportsBootstrap && options.Bootstrap.Enabled;
var fingerprint = LdapCapabilitySnapshotCache.ComputeFingerprint(options, checkProvisioning, checkBootstrap);
var now = DateTimeOffset.UtcNow;
if (LdapCapabilitySnapshotCache.TryGet(Name, fingerprint, now, out var cached))
{
UpdateCapabilities(cached, checkProvisioning, checkBootstrap, logDegrade: true);
return;
}
await capabilityGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (LdapCapabilitySnapshotCache.TryGet(Name, fingerprint, DateTimeOffset.UtcNow, out cached))
{
UpdateCapabilities(cached, checkProvisioning, checkBootstrap, logDegrade: true);
return;
}
var snapshot = await capabilityProbe.EvaluateAsync(
options,
checkProvisioning,
checkBootstrap,
options.CapabilityProbe.Timeout,
cancellationToken)
.ConfigureAwait(false);
LdapCapabilitySnapshotCache.Set(Name, fingerprint, DateTimeOffset.UtcNow, options.CapabilityProbe.CacheTtl, snapshot);
UpdateCapabilities(snapshot, checkProvisioning, checkBootstrap, logDegrade: true);
}
finally
{
capabilityGate.Release();
}
}
private void UpdateCapabilities(LdapCapabilitySnapshot snapshot, bool checkProvisioning, bool checkBootstrap, bool logDegrade)
{
clientProvisioningActive = checkProvisioning && snapshot.ClientProvisioningWritable;
bootstrapActive = checkBootstrap && snapshot.BootstrapWritable;
if (logDegrade && checkProvisioning && !clientProvisioningActive)
{
if (!loggedProvisioningDegrade)
{
logger.LogWarning(
"LDAP plugin '{PluginName}' degraded client provisioning capability because LDAP write permissions could not be validated.",
pluginContext.Manifest.Name);
loggedProvisioningDegrade = true;
}
}
else
{
loggedProvisioningDegrade = false;
}
if (logDegrade && checkBootstrap && !bootstrapActive)
{
if (!loggedBootstrapDegrade)
{
logger.LogWarning(
"LDAP plugin '{PluginName}' degraded bootstrap capability because LDAP write permissions could not be validated.",
pluginContext.Manifest.Name);
loggedBootstrapDegrade = true;
}
}
else
{
loggedBootstrapDegrade = false;
}
capabilities = new AuthorityIdentityProviderCapabilities(
SupportsPassword: true,
SupportsMfa: manifestCapabilities.SupportsMfa,
SupportsClientProvisioning: clientProvisioningActive,
SupportsBootstrap: bootstrapActive);
}
}

View File

@@ -20,6 +20,8 @@ internal sealed class LdapPluginOptions
public LdapBootstrapOptions Bootstrap { get; set; } = new();
public LdapCapabilityProbeOptions CapabilityProbe { get; set; } = new();
public void Normalize(string configPath)
{
ArgumentNullException.ThrowIfNull(configPath);
@@ -30,6 +32,7 @@ internal sealed class LdapPluginOptions
Claims.Normalize();
ClientProvisioning.Normalize();
Bootstrap.Normalize();
CapabilityProbe.Normalize();
}
public void Validate(string pluginName)
@@ -42,6 +45,7 @@ internal sealed class LdapPluginOptions
Claims.Validate(pluginName);
ClientProvisioning.Validate(pluginName);
Bootstrap.Validate(pluginName);
CapabilityProbe.Validate(pluginName);
EnsureSecurityRequirements(pluginName);
}
@@ -73,6 +77,8 @@ internal sealed class LdapConnectionOptions
public int Port { get; set; } = 636;
public int TimeoutSeconds { get; set; } = 10;
public bool UseStartTls { get; set; }
public bool ValidateCertificates { get; set; } = true;
@@ -132,6 +138,11 @@ internal sealed class LdapConnectionOptions
}
TrustStore.Normalize(configPath);
if (TimeoutSeconds <= 0)
{
TimeoutSeconds = 10;
}
}
internal void Validate(string pluginName)
@@ -146,6 +157,11 @@ internal sealed class LdapConnectionOptions
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires connection.port to be between 1 and 65535.");
}
if (TimeoutSeconds <= 0)
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires connection.timeoutSeconds to be greater than zero.");
}
if (string.IsNullOrWhiteSpace(BindDn))
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires connection.bindDn to be configured.");
@@ -728,3 +744,40 @@ internal sealed class LdapBootstrapOptions
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
internal sealed class LdapCapabilityProbeOptions
{
public int TimeoutSeconds { get; set; } = 5;
public int CacheTtlSeconds { get; set; } = 300;
internal void Normalize()
{
if (TimeoutSeconds <= 0)
{
TimeoutSeconds = 5;
}
if (CacheTtlSeconds <= 0)
{
CacheTtlSeconds = 300;
}
}
internal void Validate(string pluginName)
{
if (TimeoutSeconds <= 0)
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires capabilityProbe.timeoutSeconds to be greater than zero.");
}
if (CacheTtlSeconds <= 0)
{
throw new InvalidOperationException($"LDAP plugin '{pluginName}' requires capabilityProbe.cacheTtlSeconds to be greater than zero.");
}
}
public TimeSpan Timeout => TimeSpan.FromSeconds(TimeoutSeconds);
public TimeSpan CacheTtl => TimeSpan.FromSeconds(CacheTtlSeconds);
}

View File

@@ -2,6 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0090-M | DONE | Maintainability audit for StellaOps.Authority.Plugin.Ldap. |
| AUDIT-0090-T | DONE | Test coverage audit for StellaOps.Authority.Plugin.Ldap. |
| AUDIT-0090-A | TODO | Pending approval for changes. |
| AUDIT-0090-A | DONE | Applied LDAP plugin updates, tests, and docs. |

View File

@@ -0,0 +1,266 @@
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Authority.Plugin.Oidc;
using StellaOps.Authority.Plugin.Oidc.Credentials;
using StellaOps.Authority.Plugins.Abstractions;
using Xunit;
namespace StellaOps.Authority.Plugin.Oidc.Tests.Credentials;
public sealed class OidcCredentialStoreTests
{
[Fact]
public async Task VerifyPasswordAsync_RejectsSymmetricToken_WhenAsymmetricRequired()
{
var options = CreateOptions();
options.RequireAsymmetricKey = true;
var symmetricKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes("super-secret-key-super-secret-key"))
{
KeyId = "symm-1"
};
var handler = new OidcTestHttpMessageHandler(
options.Authority,
BuildSymmetricJwks(symmetricKey));
var factory = new TestHttpClientFactory(handler);
var cache = new MemoryCache(new MemoryCacheOptions());
var store = new OidcCredentialStore(
"oidc-test",
new StaticOptionsMonitor(options),
cache,
NullLogger<OidcCredentialStore>.Instance,
factory);
var token = CreateJwtToken(
issuer: options.Authority,
audience: options.Audience ?? options.ClientId,
subject: "user-1",
username: "user@example.com",
signingCredentials: new SigningCredentials(symmetricKey, SecurityAlgorithms.HmacSha256));
var result = await store.VerifyPasswordAsync("user@example.com", token, CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal(AuthorityCredentialFailureCode.InvalidCredentials, result.FailureCode);
Assert.Contains("symmetric", result.Message ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task FindBySubjectAsync_IsolatedByPluginName()
{
var options = CreateOptions();
using var rsa = RSA.Create(2048);
var rsaKey = new RsaSecurityKey(rsa) { KeyId = "rsa-1" };
var handler = new OidcTestHttpMessageHandler(
options.Authority,
BuildRsaJwks(rsaKey));
var factory = new TestHttpClientFactory(handler);
var cache = new MemoryCache(new MemoryCacheOptions());
var storeA = new OidcCredentialStore(
"oidc-a",
new StaticOptionsMonitor(new Dictionary<string, OidcPluginOptions>
{
["oidc-a"] = options,
["oidc-b"] = options
}),
cache,
NullLogger<OidcCredentialStore>.Instance,
factory);
var storeB = new OidcCredentialStore(
"oidc-b",
new StaticOptionsMonitor(new Dictionary<string, OidcPluginOptions>
{
["oidc-a"] = options,
["oidc-b"] = options
}),
cache,
NullLogger<OidcCredentialStore>.Instance,
factory);
var token = CreateJwtToken(
issuer: options.Authority,
audience: options.Audience ?? options.ClientId,
subject: "user-2",
username: "user2@example.com",
signingCredentials: new SigningCredentials(rsaKey, SecurityAlgorithms.RsaSha256));
var result = await storeA.VerifyPasswordAsync("user2@example.com", token, CancellationToken.None);
Assert.True(result.Succeeded);
var cached = await storeB.FindBySubjectAsync("user-2", CancellationToken.None);
Assert.Null(cached);
}
private static OidcPluginOptions CreateOptions()
=> new()
{
Authority = "https://idp.example.com",
ClientId = "stellaops-client",
Audience = "stellaops-api",
RequireHttpsMetadata = true,
MetadataTimeoutSeconds = 5,
Scopes = new[] { "openid", "profile" },
ValidateLifetime = false
};
private static string CreateJwtToken(
string issuer,
string audience,
string subject,
string username,
SigningCredentials signingCredentials)
{
var handler = new JwtSecurityTokenHandler();
var now = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc);
var token = handler.CreateJwtSecurityToken(
issuer: issuer,
audience: audience,
subject: new System.Security.Claims.ClaimsIdentity(new[]
{
new System.Security.Claims.Claim("sub", subject),
new System.Security.Claims.Claim("preferred_username", username)
}),
notBefore: now.AddMinutes(-1),
expires: now.AddMinutes(30),
signingCredentials: signingCredentials);
return handler.WriteToken(token);
}
private sealed class OidcTestHttpMessageHandler : HttpMessageHandler
{
private readonly string metadataJson;
private readonly string jwksJson;
public OidcTestHttpMessageHandler(string authority, string jwksJson)
{
var metadata = new Dictionary<string, string>
{
["issuer"] = authority,
["jwks_uri"] = $"{authority.TrimEnd('/')}/.well-known/jwks.json"
};
metadataJson = JsonSerializer.Serialize(metadata);
this.jwksJson = jwksJson;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var url = request.RequestUri?.AbsoluteUri ?? string.Empty;
HttpResponseMessage response;
if (url.EndsWith("/.well-known/openid-configuration", StringComparison.OrdinalIgnoreCase))
{
response = CreateResponse(metadataJson);
}
else if (url.EndsWith("/.well-known/jwks.json", StringComparison.OrdinalIgnoreCase))
{
response = CreateResponse(jwksJson);
}
else
{
response = new HttpResponseMessage(HttpStatusCode.NotFound);
}
return Task.FromResult(response);
}
private static HttpResponseMessage CreateResponse(string json)
=> new(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
}
private sealed class TestHttpClientFactory : IHttpClientFactory
{
private readonly HttpMessageHandler handler;
public TestHttpClientFactory(HttpMessageHandler handler)
{
this.handler = handler;
}
public HttpClient CreateClient(string name)
=> new(handler, disposeHandler: false);
}
private sealed class StaticOptionsMonitor : IOptionsMonitor<OidcPluginOptions>
{
private readonly IReadOnlyDictionary<string, OidcPluginOptions> options;
public StaticOptionsMonitor(OidcPluginOptions value)
: this(new Dictionary<string, OidcPluginOptions> { ["oidc-test"] = value })
{
}
public StaticOptionsMonitor(IReadOnlyDictionary<string, OidcPluginOptions> options)
{
this.options = options;
}
public OidcPluginOptions CurrentValue => options.Values.First();
public OidcPluginOptions Get(string name)
=> options.TryGetValue(name, out var value) ? value : options.Values.First();
public IDisposable OnChange(Action<OidcPluginOptions, string> listener)
=> new NoopDisposable();
private sealed class NoopDisposable : IDisposable
{
public void Dispose()
{
}
}
}
private static string BuildRsaJwks(RsaSecurityKey key)
{
var parameters = key.Rsa!.ExportParameters(false);
var jwk = new Dictionary<string, string>
{
["kty"] = "RSA",
["use"] = "sig",
["kid"] = key.KeyId ?? "rsa",
["alg"] = "RS256",
["n"] = Base64UrlEncoder.Encode(parameters.Modulus),
["e"] = Base64UrlEncoder.Encode(parameters.Exponent)
};
return JsonSerializer.Serialize(new { keys = new[] { jwk } });
}
private static string BuildSymmetricJwks(SymmetricSecurityKey key)
{
var jwk = new Dictionary<string, string>
{
["kty"] = "oct",
["use"] = "sig",
["kid"] = key.KeyId ?? "symm",
["alg"] = "HS256",
["k"] = Base64UrlEncoder.Encode(key.Key)
};
return JsonSerializer.Serialize(new { keys = new[] { jwk } });
}
}

View File

@@ -0,0 +1,148 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Plugin.Oidc;
using StellaOps.Authority.Plugin.Oidc.Claims;
using StellaOps.Authority.Plugin.Oidc.Credentials;
using StellaOps.Authority.Plugins.Abstractions;
using Xunit;
namespace StellaOps.Authority.Plugin.Oidc.Tests;
public sealed class OidcIdentityProviderPluginTests
{
[Fact]
public async Task CheckHealthAsync_ReturnsHealthy_OnOkMetadata()
{
var (plugin, _) = CreatePlugin(HttpStatusCode.OK);
var result = await plugin.CheckHealthAsync(CancellationToken.None);
Assert.Equal(AuthorityPluginHealthStatus.Healthy, result.Status);
}
[Fact]
public async Task CheckHealthAsync_ReturnsDegraded_OnNonOkMetadata()
{
var (plugin, _) = CreatePlugin(HttpStatusCode.ServiceUnavailable);
var result = await plugin.CheckHealthAsync(CancellationToken.None);
Assert.Equal(AuthorityPluginHealthStatus.Degraded, result.Status);
}
private static (OidcIdentityProviderPlugin Plugin, IMemoryCache Cache) CreatePlugin(HttpStatusCode statusCode)
{
var pluginName = "oidc-test";
var options = new OidcPluginOptions
{
Authority = "https://idp.example.com",
ClientId = "stellaops-client",
Scopes = new[] { "openid" },
RequireHttpsMetadata = true
};
var optionsMonitor = new StaticOptionsMonitor(options, pluginName);
var handler = new FixedResponseHandler(statusCode, "{}");
var httpClientFactory = new TestHttpClientFactory(handler);
var cache = new MemoryCache(new MemoryCacheOptions());
var credentialStore = new OidcCredentialStore(
pluginName,
optionsMonitor,
cache,
NullLogger<OidcCredentialStore>.Instance,
httpClientFactory);
var claimsEnricher = new OidcClaimsEnricher(
pluginName,
optionsMonitor,
NullLogger<OidcClaimsEnricher>.Instance);
var manifest = new AuthorityPluginManifest(
Name: pluginName,
Type: OidcPluginRegistrar.PluginType,
Enabled: true,
AssemblyName: null,
AssemblyPath: null,
Capabilities: new[] { AuthorityPluginCapabilities.Password },
Metadata: new Dictionary<string, string?>(),
ConfigPath: "oidc.yaml");
var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
var plugin = new OidcIdentityProviderPlugin(
context,
credentialStore,
claimsEnricher,
optionsMonitor,
NullLogger<OidcIdentityProviderPlugin>.Instance,
httpClientFactory);
return (plugin, cache);
}
private sealed class FixedResponseHandler : HttpMessageHandler
{
private readonly HttpStatusCode statusCode;
private readonly string content;
public FixedResponseHandler(HttpStatusCode statusCode, string content)
{
this.statusCode = statusCode;
this.content = content;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(statusCode)
{
Content = new StringContent(content)
});
}
private sealed class TestHttpClientFactory : IHttpClientFactory
{
private readonly HttpMessageHandler handler;
public TestHttpClientFactory(HttpMessageHandler handler)
{
this.handler = handler;
}
public HttpClient CreateClient(string name)
=> new(handler, disposeHandler: false);
}
private sealed class StaticOptionsMonitor : IOptionsMonitor<OidcPluginOptions>
{
private readonly OidcPluginOptions options;
private readonly string pluginName;
public StaticOptionsMonitor(OidcPluginOptions options, string pluginName)
{
this.options = options;
this.pluginName = pluginName;
}
public OidcPluginOptions CurrentValue => options;
public OidcPluginOptions Get(string name)
=> string.Equals(name, pluginName, StringComparison.Ordinal) ? options : options;
public IDisposable OnChange(Action<OidcPluginOptions, string> listener)
=> new NoopDisposable();
private sealed class NoopDisposable : IDisposable
{
public void Dispose()
{
}
}
}
}

View File

@@ -0,0 +1,57 @@
using System;
using StellaOps.Authority.Plugin.Oidc;
using Xunit;
namespace StellaOps.Authority.Plugin.Oidc.Tests;
public sealed class OidcPluginOptionsTests
{
[Fact]
public void Validate_Throws_WhenScopeEmpty()
{
var options = CreateOptions();
options.Scopes = new[] { "openid", "" };
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Scopes", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_Throws_WhenRedirectUriNotHttps()
{
var options = CreateOptions();
options.RedirectUri = new Uri("http://localhost/callback");
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("RedirectUri", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_Throws_WhenPostLogoutRedirectUriRelative()
{
var options = CreateOptions();
options.PostLogoutRedirectUri = new Uri("/logout", UriKind.Relative);
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("PostLogoutRedirectUri", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_Throws_WhenMetadataTimeoutNonPositive()
{
var options = CreateOptions();
options.MetadataTimeoutSeconds = 0;
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("MetadataTimeoutSeconds", ex.Message, StringComparison.OrdinalIgnoreCase);
}
private static OidcPluginOptions CreateOptions()
=> new()
{
Authority = "https://idp.example.com",
ClientId = "stellaops-client",
Scopes = new[] { "openid", "profile" },
RequireHttpsMetadata = true
};
}

View File

@@ -8,6 +8,7 @@ using System.Security.Claims;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Net.Http;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
@@ -25,6 +26,7 @@ internal sealed class OidcCredentialStore : IUserCredentialStore
private readonly IOptionsMonitor<OidcPluginOptions> optionsMonitor;
private readonly IMemoryCache sessionCache;
private readonly ILogger<OidcCredentialStore> logger;
private readonly IHttpClientFactory httpClientFactory;
private readonly ConfigurationManager<OpenIdConnectConfiguration> configurationManager;
private readonly JwtSecurityTokenHandler tokenHandler;
@@ -32,20 +34,24 @@ internal sealed class OidcCredentialStore : IUserCredentialStore
string pluginName,
IOptionsMonitor<OidcPluginOptions> optionsMonitor,
IMemoryCache sessionCache,
ILogger<OidcCredentialStore> logger)
ILogger<OidcCredentialStore> logger,
IHttpClientFactory httpClientFactory)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.sessionCache = sessionCache ?? throw new ArgumentNullException(nameof(sessionCache));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
var options = optionsMonitor.Get(pluginName);
var metadataAddress = $"{options.Authority.TrimEnd('/')}/.well-known/openid-configuration";
var httpClient = httpClientFactory.CreateClient(OidcPluginRegistrar.GetHttpClientName(pluginName));
httpClient.Timeout = TimeSpan.FromSeconds(options.MetadataTimeoutSeconds);
configurationManager = new ConfigurationManager<OpenIdConnectConfiguration>(
metadataAddress,
new OpenIdConnectConfigurationRetriever(),
new HttpDocumentRetriever { RequireHttps = options.RequireHttpsMetadata })
new HttpDocumentRetriever(httpClient) { RequireHttps = options.RequireHttpsMetadata })
{
RefreshInterval = options.MetadataRefreshInterval,
AutomaticRefreshInterval = options.AutomaticRefreshInterval
@@ -66,17 +72,27 @@ internal sealed class OidcCredentialStore : IUserCredentialStore
// The "password" field contains the access token or ID token.
var token = password;
if (string.IsNullOrWhiteSpace(token))
{
return AuthorityCredentialVerificationResult.Failure(
AuthorityCredentialFailureCode.InvalidCredentials,
"Token is required for OIDC authentication.");
}
if (string.IsNullOrWhiteSpace(token))
{
return AuthorityCredentialVerificationResult.Failure(
AuthorityCredentialFailureCode.InvalidCredentials,
"Token is required for OIDC authentication.");
}
try
{
var options = optionsMonitor.Get(pluginName);
var configuration = await configurationManager.GetConfigurationAsync(cancellationToken).ConfigureAwait(false);
try
{
var options = optionsMonitor.Get(pluginName);
if (options.RequireAsymmetricKey &&
TryGetAlgorithm(token, out var algorithm) &&
IsSymmetricAlgorithm(algorithm))
{
return AuthorityCredentialVerificationResult.Failure(
AuthorityCredentialFailureCode.InvalidCredentials,
"Token uses a symmetric algorithm but asymmetric keys are required.");
}
var configuration = await configurationManager.GetConfigurationAsync(cancellationToken).ConfigureAwait(false);
var validationParameters = new TokenValidationParameters
{
@@ -132,7 +148,7 @@ internal sealed class OidcCredentialStore : IUserCredentialStore
attributes: attributes);
// Cache the session
var cacheKey = $"oidc:session:{subjectId}";
var cacheKey = BuildSessionCacheKey(pluginName, subjectId);
sessionCache.Set(cacheKey, user, options.SessionCacheDuration);
logger.LogInformation(
@@ -196,7 +212,7 @@ internal sealed class OidcCredentialStore : IUserCredentialStore
string subjectId,
CancellationToken cancellationToken)
{
var cacheKey = $"oidc:session:{subjectId}";
var cacheKey = BuildSessionCacheKey(pluginName, subjectId);
if (sessionCache.TryGetValue<AuthorityUserDescriptor>(cacheKey, out var cached))
{
@@ -206,6 +222,9 @@ internal sealed class OidcCredentialStore : IUserCredentialStore
return ValueTask.FromResult<AuthorityUserDescriptor?>(null);
}
internal static string BuildSessionCacheKey(string pluginName, string subjectId)
=> $"oidc:{pluginName}:session:{subjectId}";
private static string? GetClaimValue(IEnumerable<Claim> claims, string claimType)
{
return claims
@@ -213,6 +232,37 @@ internal sealed class OidcCredentialStore : IUserCredentialStore
?.Value;
}
private static bool IsSymmetricAlgorithm(string? algorithm)
{
if (string.IsNullOrWhiteSpace(algorithm))
{
return false;
}
return algorithm.StartsWith("HS", StringComparison.OrdinalIgnoreCase);
}
private bool TryGetAlgorithm(string token, out string? algorithm)
{
algorithm = null;
if (!tokenHandler.CanReadToken(token))
{
return false;
}
try
{
var jwtToken = tokenHandler.ReadJwtToken(token);
algorithm = jwtToken.Header.Alg;
return !string.IsNullOrWhiteSpace(algorithm);
}
catch (Exception)
{
return false;
}
}
private static List<string> ExtractRoles(IEnumerable<Claim> claims, OidcPluginOptions options)
{
var roles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

View File

@@ -5,6 +5,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Net.Http;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Oidc.Claims;
using StellaOps.Authority.Plugin.Oidc.Credentials;
@@ -21,6 +22,7 @@ internal sealed class OidcIdentityProviderPlugin : IIdentityProviderPlugin
private readonly OidcClaimsEnricher claimsEnricher;
private readonly IOptionsMonitor<OidcPluginOptions> optionsMonitor;
private readonly ILogger<OidcIdentityProviderPlugin> logger;
private readonly IHttpClientFactory httpClientFactory;
private readonly AuthorityIdentityProviderCapabilities capabilities;
public OidcIdentityProviderPlugin(
@@ -28,13 +30,15 @@ internal sealed class OidcIdentityProviderPlugin : IIdentityProviderPlugin
OidcCredentialStore credentialStore,
OidcClaimsEnricher claimsEnricher,
IOptionsMonitor<OidcPluginOptions> optionsMonitor,
ILogger<OidcIdentityProviderPlugin> logger)
ILogger<OidcIdentityProviderPlugin> logger,
IHttpClientFactory httpClientFactory)
{
this.pluginContext = pluginContext ?? throw new ArgumentNullException(nameof(pluginContext));
this.credentialStore = credentialStore ?? throw new ArgumentNullException(nameof(credentialStore));
this.claimsEnricher = claimsEnricher ?? throw new ArgumentNullException(nameof(claimsEnricher));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
// Validate configuration on startup
var options = optionsMonitor.Get(pluginContext.Manifest.Name);
@@ -78,7 +82,8 @@ internal sealed class OidcIdentityProviderPlugin : IIdentityProviderPlugin
var options = optionsMonitor.Get(Name);
var metadataAddress = $"{options.Authority.TrimEnd('/')}/.well-known/openid-configuration";
using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
using var httpClient = httpClientFactory.CreateClient(OidcPluginRegistrar.GetHttpClientName(Name));
httpClient.Timeout = TimeSpan.FromSeconds(options.MetadataTimeoutSeconds);
var response = await httpClient.GetAsync(metadataAddress, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)

View File

@@ -101,6 +101,11 @@ public sealed class OidcPluginOptions
/// </summary>
public TimeSpan AutomaticRefreshInterval { get; set; } = TimeSpan.FromHours(12);
/// <summary>
/// Timeout in seconds for metadata retrieval and health checks.
/// </summary>
public int MetadataTimeoutSeconds { get; set; } = 10;
/// <summary>
/// Cache duration for user sessions.
/// </summary>
@@ -160,6 +165,55 @@ public sealed class OidcPluginOptions
{
throw new InvalidOperationException("OIDC Authority must use HTTPS when RequireHttpsMetadata is true.");
}
if (MetadataTimeoutSeconds <= 0)
{
throw new InvalidOperationException("OIDC MetadataTimeoutSeconds must be greater than zero.");
}
ValidateScopes(Scopes, "Scopes");
if (TokenExchange is { Enabled: true })
{
ValidateScopes(TokenExchange.Scopes, "TokenExchange.Scopes");
}
ValidateRedirectUri(nameof(RedirectUri), RedirectUri);
ValidateRedirectUri(nameof(PostLogoutRedirectUri), PostLogoutRedirectUri);
}
private void ValidateRedirectUri(string name, Uri? uri)
{
if (uri is null)
{
return;
}
if (!uri.IsAbsoluteUri)
{
throw new InvalidOperationException($"OIDC {name} must be an absolute URI.");
}
if (RequireHttpsMetadata && !string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"OIDC {name} must use HTTPS when RequireHttpsMetadata is true.");
}
}
private static void ValidateScopes(IReadOnlyCollection<string> scopes, string name)
{
if (scopes is null || scopes.Count == 0)
{
throw new InvalidOperationException($"OIDC {name} must include at least one scope.");
}
foreach (var scope in scopes)
{
if (string.IsNullOrWhiteSpace(scope))
{
throw new InvalidOperationException($"OIDC {name} cannot include empty scopes.");
}
}
}
}

View File

@@ -7,6 +7,7 @@ using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Net.Http;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Oidc.Claims;
using StellaOps.Authority.Plugin.Oidc.Credentials;
@@ -23,6 +24,9 @@ public static class OidcPluginRegistrar
/// </summary>
public const string PluginType = "oidc";
public static string GetHttpClientName(string pluginName)
=> $"oidc:{pluginName}";
/// <summary>
/// Registers the OIDC plugin with the given context.
/// </summary>
@@ -39,15 +43,17 @@ public static class OidcPluginRegistrar
var optionsMonitor = serviceProvider.GetRequiredService<IOptionsMonitor<OidcPluginOptions>>();
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
// Get or create a memory cache for sessions
var sessionCache = serviceProvider.GetService<IMemoryCache>()
?? new MemoryCache(new MemoryCacheOptions());
optionsMonitor.Get(pluginName).Validate();
var sessionCache = serviceProvider.GetRequiredService<IMemoryCache>();
var httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>();
var credentialStore = new OidcCredentialStore(
pluginName,
optionsMonitor,
sessionCache,
loggerFactory.CreateLogger<OidcCredentialStore>());
loggerFactory.CreateLogger<OidcCredentialStore>(),
httpClientFactory);
var claimsEnricher = new OidcClaimsEnricher(
pluginName,
@@ -59,7 +65,8 @@ public static class OidcPluginRegistrar
credentialStore,
claimsEnricher,
optionsMonitor,
loggerFactory.CreateLogger<OidcIdentityProviderPlugin>());
loggerFactory.CreateLogger<OidcIdentityProviderPlugin>(),
httpClientFactory);
return plugin;
}
@@ -73,7 +80,7 @@ public static class OidcPluginRegistrar
Action<OidcPluginOptions>? configureOptions = null)
{
services.AddMemoryCache();
services.AddHttpClient();
services.AddHttpClient(GetHttpClientName(pluginName));
if (configureOptions != null)
{

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Authority.Plugin.Oidc.Tests")]

View File

@@ -2,6 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>

View File

@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Authority.Plugin.Oidc</RootNamespace>
<Description>StellaOps Authority OIDC Identity Provider Plugin</Description>
<IsAuthorityPlugin>true</IsAuthorityPlugin>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.10.0" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.10.0" />
</ItemGroup>
</Project>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0092-M | DONE | Maintainability audit for StellaOps.Authority.Plugin.Oidc. |
| AUDIT-0092-T | DONE | Test coverage audit for StellaOps.Authority.Plugin.Oidc. |
| AUDIT-0092-A | TODO | Pending approval for changes. |
| AUDIT-0092-A | DONE | Applied OIDC plugin updates and tests. |

View File

@@ -0,0 +1,15 @@
using StellaOps.Authority.Plugin.Saml.Credentials;
using Xunit;
namespace StellaOps.Authority.Plugin.Saml.Tests.Credentials;
public sealed class SamlCredentialStoreTests
{
[Fact]
public void BuildSessionCacheKey_IncludesPluginName()
{
var key = SamlCredentialStore.BuildSessionCacheKey("saml-test", "subject-1");
Assert.Equal("saml:saml-test:session:subject-1", key);
}
}

View File

@@ -0,0 +1,146 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Plugin.Saml;
using StellaOps.Authority.Plugin.Saml.Claims;
using StellaOps.Authority.Plugin.Saml.Credentials;
using StellaOps.Authority.Plugins.Abstractions;
using Xunit;
namespace StellaOps.Authority.Plugin.Saml.Tests;
public sealed class SamlIdentityProviderPluginTests
{
[Fact]
public async Task CheckHealthAsync_ReturnsHealthy_WhenMetadataOk()
{
var plugin = CreatePlugin(HttpStatusCode.OK);
var result = await plugin.CheckHealthAsync(CancellationToken.None);
Assert.Equal(AuthorityPluginHealthStatus.Healthy, result.Status);
}
[Fact]
public async Task CheckHealthAsync_ReturnsDegraded_WhenMetadataNotOk()
{
var plugin = CreatePlugin(HttpStatusCode.ServiceUnavailable);
var result = await plugin.CheckHealthAsync(CancellationToken.None);
Assert.Equal(AuthorityPluginHealthStatus.Degraded, result.Status);
}
private static SamlIdentityProviderPlugin CreatePlugin(HttpStatusCode statusCode)
{
var pluginName = "saml-test";
var options = new SamlPluginOptions
{
EntityId = "urn:stellaops:sp",
IdpEntityId = "urn:idp:test",
IdpMetadataUrl = "https://idp.example.com/metadata",
ValidateSignature = false,
SignAuthenticationRequests = false,
SignLogoutRequests = false
};
var optionsMonitor = new StaticOptionsMonitor(options, pluginName);
var handler = new FixedResponseHandler(statusCode);
var httpClientFactory = new TestHttpClientFactory(handler);
var cache = new MemoryCache(new MemoryCacheOptions());
var credentialStore = new SamlCredentialStore(
pluginName,
optionsMonitor,
cache,
NullLogger<SamlCredentialStore>.Instance,
httpClientFactory);
var claimsEnricher = new SamlClaimsEnricher(
pluginName,
optionsMonitor,
NullLogger<SamlClaimsEnricher>.Instance);
var manifest = new AuthorityPluginManifest(
Name: pluginName,
Type: SamlPluginRegistrar.PluginType,
Enabled: true,
AssemblyName: null,
AssemblyPath: null,
Capabilities: new[] { AuthorityPluginCapabilities.Password },
Metadata: new Dictionary<string, string?>(),
ConfigPath: "saml.yaml");
var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
return new SamlIdentityProviderPlugin(
context,
credentialStore,
claimsEnricher,
optionsMonitor,
NullLogger<SamlIdentityProviderPlugin>.Instance,
httpClientFactory);
}
private sealed class FixedResponseHandler : HttpMessageHandler
{
private readonly HttpStatusCode statusCode;
public FixedResponseHandler(HttpStatusCode statusCode)
{
this.statusCode = statusCode;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> Task.FromResult(new HttpResponseMessage(statusCode)
{
Content = new StringContent("{}")
});
}
private sealed class TestHttpClientFactory : IHttpClientFactory
{
private readonly HttpMessageHandler handler;
public TestHttpClientFactory(HttpMessageHandler handler)
{
this.handler = handler;
}
public HttpClient CreateClient(string name)
=> new(handler, disposeHandler: false);
}
private sealed class StaticOptionsMonitor : IOptionsMonitor<SamlPluginOptions>
{
private readonly SamlPluginOptions options;
private readonly string pluginName;
public StaticOptionsMonitor(SamlPluginOptions options, string pluginName)
{
this.options = options;
this.pluginName = pluginName;
}
public SamlPluginOptions CurrentValue => options;
public SamlPluginOptions Get(string name)
=> string.Equals(name, pluginName, StringComparison.Ordinal) ? options : options;
public IDisposable OnChange(Action<SamlPluginOptions, string> listener)
=> new NoopDisposable();
private sealed class NoopDisposable : IDisposable
{
public void Dispose()
{
}
}
}
}

View File

@@ -0,0 +1,40 @@
using System;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using StellaOps.Authority.Plugin.Saml;
using Xunit;
namespace StellaOps.Authority.Plugin.Saml.Tests;
public sealed class SamlMetadataParserTests
{
[Fact]
public void TryExtractSigningCertificate_ReturnsCertificate()
{
using var rsa = RSA.Create(2048);
var request = new CertificateRequest("CN=Test", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
var notBefore = new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero);
var cert = request.CreateSelfSigned(notBefore, notBefore.AddDays(30));
var base64 = Convert.ToBase64String(cert.Export(X509ContentType.Cert));
var metadata = $"""
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="urn:idp:test">
<IDPSSODescriptor>
<KeyDescriptor>
<KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
<X509Data>
<X509Certificate>{base64}</X509Certificate>
</X509Data>
</KeyInfo>
</KeyDescriptor>
</IDPSSODescriptor>
</EntityDescriptor>
""";
var result = SamlMetadataParser.TryExtractSigningCertificate(metadata, out var extracted);
Assert.True(result);
Assert.NotNull(extracted);
Assert.Equal(cert.Thumbprint, extracted.Thumbprint);
}
}

View File

@@ -0,0 +1,59 @@
using System;
using StellaOps.Authority.Plugin.Saml;
using Xunit;
namespace StellaOps.Authority.Plugin.Saml.Tests;
public sealed class SamlPluginOptionsTests
{
[Fact]
public void Validate_Throws_WhenEncryptedAssertionsEnabled()
{
var options = CreateOptions();
options.RequireEncryptedAssertions = true;
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("encrypted assertions", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_Throws_WhenRequestSigningEnabled()
{
var options = CreateOptions();
options.SignAuthenticationRequests = true;
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("request signing", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_Throws_WhenMetadataUrlNotHttps()
{
var options = CreateOptions();
options.IdpMetadataUrl = "http://idp.example.com/metadata";
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("metadata URL", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_Throws_WhenMetadataTimeoutNonPositive()
{
var options = CreateOptions();
options.MetadataTimeoutSeconds = 0;
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("MetadataTimeoutSeconds", ex.Message, StringComparison.OrdinalIgnoreCase);
}
private static SamlPluginOptions CreateOptions()
=> new()
{
EntityId = "urn:stellaops:sp",
IdpEntityId = "urn:idp:test",
IdpMetadataUrl = "https://idp.example.com/metadata",
ValidateSignature = false,
SignAuthenticationRequests = false,
SignLogoutRequests = false
};
}

View File

@@ -4,14 +4,18 @@
// -----------------------------------------------------------------------------
using System.Security.Claims;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.IO;
using System.Xml;
using System.Net.Http;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using Microsoft.IdentityModel.Tokens.Saml2;
using StellaOps.Authority.Plugin.Saml;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Cryptography.Audit;
@@ -26,37 +30,42 @@ internal sealed class SamlCredentialStore : IUserCredentialStore
private readonly IOptionsMonitor<SamlPluginOptions> optionsMonitor;
private readonly IMemoryCache sessionCache;
private readonly ILogger<SamlCredentialStore> logger;
private readonly IHttpClientFactory httpClientFactory;
private readonly Saml2SecurityTokenHandler tokenHandler;
private X509Certificate2? idpSigningCertificate;
private string? certificateCacheKey;
private DateTimeOffset? lastMetadataRefresh;
private readonly SemaphoreSlim metadataGate = new(1, 1);
public SamlCredentialStore(
string pluginName,
IOptionsMonitor<SamlPluginOptions> optionsMonitor,
IMemoryCache sessionCache,
ILogger<SamlCredentialStore> logger)
ILogger<SamlCredentialStore> logger,
IHttpClientFactory httpClientFactory)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.sessionCache = sessionCache ?? throw new ArgumentNullException(nameof(sessionCache));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
tokenHandler = new Saml2SecurityTokenHandler();
LoadIdpCertificate();
optionsMonitor.OnChange((_, name) =>
{
if (string.Equals(name, pluginName, StringComparison.Ordinal))
{
ClearCertificateCache();
}
});
}
private void LoadIdpCertificate()
private void ClearCertificateCache()
{
var options = optionsMonitor.Get(pluginName);
if (!string.IsNullOrWhiteSpace(options.IdpSigningCertificatePath))
{
idpSigningCertificate = new X509Certificate2(options.IdpSigningCertificatePath);
}
else if (!string.IsNullOrWhiteSpace(options.IdpSigningCertificateBase64))
{
var certBytes = Convert.FromBase64String(options.IdpSigningCertificateBase64);
idpSigningCertificate = new X509Certificate2(certBytes);
}
idpSigningCertificate = null;
certificateCacheKey = null;
lastMetadataRefresh = null;
}
public async ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(
@@ -78,6 +87,14 @@ internal sealed class SamlCredentialStore : IUserCredentialStore
try
{
var options = optionsMonitor.Get(pluginName);
await EnsureIdpSigningCertificateAsync(options, cancellationToken).ConfigureAwait(false);
if (options.ValidateSignature && idpSigningCertificate == null)
{
return AuthorityCredentialVerificationResult.Failure(
AuthorityCredentialFailureCode.InvalidCredentials,
"SAML signing certificate is not available for validation.");
}
// Decode the SAML response
string xmlContent;
@@ -93,8 +110,11 @@ internal sealed class SamlCredentialStore : IUserCredentialStore
}
// Parse the SAML assertion
var doc = new XmlDocument { PreserveWhitespace = true };
doc.LoadXml(xmlContent);
var doc = new XmlDocument { PreserveWhitespace = true, XmlResolver = null };
using (var reader = XmlReader.Create(new StringReader(xmlContent), CreateSecureXmlReaderSettings()))
{
doc.Load(reader);
}
// Find the assertion element
var assertionNode = FindAssertionNode(doc);
@@ -107,8 +127,8 @@ internal sealed class SamlCredentialStore : IUserCredentialStore
// Validate the assertion
var validationParameters = CreateValidationParameters(options);
var reader = XmlReader.Create(new StringReader(assertionNode.OuterXml));
var token = tokenHandler.ReadToken(reader) as Saml2SecurityToken;
using var assertionReader = XmlReader.Create(new StringReader(assertionNode.OuterXml), CreateSecureXmlReaderSettings());
var token = tokenHandler.ReadToken(assertionReader) as Saml2SecurityToken;
if (token == null)
{
@@ -154,7 +174,7 @@ internal sealed class SamlCredentialStore : IUserCredentialStore
attributes: attributes);
// Cache the session
var cacheKey = $"saml:session:{subjectId}";
var cacheKey = BuildSessionCacheKey(pluginName, subjectId);
sessionCache.Set(cacheKey, user, options.SessionCacheDuration);
logger.LogInformation(
@@ -223,7 +243,7 @@ internal sealed class SamlCredentialStore : IUserCredentialStore
string subjectId,
CancellationToken cancellationToken)
{
var cacheKey = $"saml:session:{subjectId}";
var cacheKey = BuildSessionCacheKey(pluginName, subjectId);
if (sessionCache.TryGetValue<AuthorityUserDescriptor>(cacheKey, out var cached))
{
@@ -233,6 +253,9 @@ internal sealed class SamlCredentialStore : IUserCredentialStore
return ValueTask.FromResult<AuthorityUserDescriptor?>(null);
}
internal static string BuildSessionCacheKey(string pluginName, string subjectId)
=> $"saml:{pluginName}:session:{subjectId}";
private TokenValidationParameters CreateValidationParameters(SamlPluginOptions options)
{
var parameters = new TokenValidationParameters
@@ -315,4 +338,125 @@ internal sealed class SamlCredentialStore : IUserCredentialStore
return roles.ToList();
}
private static XmlReaderSettings CreateSecureXmlReaderSettings()
=> new()
{
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null
};
private async Task EnsureIdpSigningCertificateAsync(SamlPluginOptions options, CancellationToken cancellationToken)
{
if (!options.ValidateSignature)
{
idpSigningCertificate = null;
return;
}
var key = BuildCertificateCacheKey(options);
if (idpSigningCertificate != null && string.Equals(key, certificateCacheKey, StringComparison.Ordinal))
{
if (!RequiresMetadataRefresh(options))
{
return;
}
}
await metadataGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (idpSigningCertificate != null && string.Equals(key, certificateCacheKey, StringComparison.Ordinal))
{
if (!RequiresMetadataRefresh(options))
{
return;
}
}
if (!string.IsNullOrWhiteSpace(options.IdpSigningCertificatePath))
{
idpSigningCertificate = new X509Certificate2(options.IdpSigningCertificatePath);
certificateCacheKey = key;
lastMetadataRefresh = null;
return;
}
if (!string.IsNullOrWhiteSpace(options.IdpSigningCertificateBase64))
{
var certBytes = Convert.FromBase64String(options.IdpSigningCertificateBase64);
idpSigningCertificate = new X509Certificate2(certBytes);
certificateCacheKey = key;
lastMetadataRefresh = null;
return;
}
if (!string.IsNullOrWhiteSpace(options.IdpMetadataUrl))
{
var metadata = await FetchMetadataAsync(options, cancellationToken).ConfigureAwait(false);
if (SamlMetadataParser.TryExtractSigningCertificate(metadata, out var certificate))
{
idpSigningCertificate = certificate;
certificateCacheKey = key;
lastMetadataRefresh = DateTimeOffset.UtcNow;
return;
}
logger.LogWarning("SAML metadata did not contain a signing certificate for plugin {Plugin}.", pluginName);
}
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or XmlException or CryptographicException)
{
logger.LogWarning(ex, "Failed to refresh SAML signing certificate for plugin {Plugin}.", pluginName);
}
finally
{
metadataGate.Release();
}
}
private bool RequiresMetadataRefresh(SamlPluginOptions options)
{
if (string.IsNullOrWhiteSpace(options.IdpMetadataUrl))
{
return false;
}
if (lastMetadataRefresh is null)
{
return true;
}
return DateTimeOffset.UtcNow - lastMetadataRefresh.Value >= options.MetadataRefreshInterval;
}
private static string BuildCertificateCacheKey(SamlPluginOptions options)
{
if (!string.IsNullOrWhiteSpace(options.IdpSigningCertificatePath))
{
return $"path:{options.IdpSigningCertificatePath}";
}
if (!string.IsNullOrWhiteSpace(options.IdpSigningCertificateBase64))
{
return $"base64:{options.IdpSigningCertificateBase64}";
}
if (!string.IsNullOrWhiteSpace(options.IdpMetadataUrl))
{
return $"metadata:{options.IdpMetadataUrl}";
}
return "none";
}
private async Task<string> FetchMetadataAsync(SamlPluginOptions options, CancellationToken cancellationToken)
{
var client = httpClientFactory.CreateClient(SamlPluginRegistrar.GetHttpClientName(pluginName));
client.Timeout = TimeSpan.FromSeconds(options.MetadataTimeoutSeconds);
var response = await client.GetAsync(options.IdpMetadataUrl!, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("StellaOps.Authority.Plugin.Saml.Tests")]

View File

@@ -5,6 +5,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Net.Http;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Saml.Claims;
using StellaOps.Authority.Plugin.Saml.Credentials;
@@ -21,6 +22,7 @@ internal sealed class SamlIdentityProviderPlugin : IIdentityProviderPlugin
private readonly SamlClaimsEnricher claimsEnricher;
private readonly IOptionsMonitor<SamlPluginOptions> optionsMonitor;
private readonly ILogger<SamlIdentityProviderPlugin> logger;
private readonly IHttpClientFactory httpClientFactory;
private readonly AuthorityIdentityProviderCapabilities capabilities;
public SamlIdentityProviderPlugin(
@@ -28,13 +30,15 @@ internal sealed class SamlIdentityProviderPlugin : IIdentityProviderPlugin
SamlCredentialStore credentialStore,
SamlClaimsEnricher claimsEnricher,
IOptionsMonitor<SamlPluginOptions> optionsMonitor,
ILogger<SamlIdentityProviderPlugin> logger)
ILogger<SamlIdentityProviderPlugin> logger,
IHttpClientFactory httpClientFactory)
{
this.pluginContext = pluginContext ?? throw new ArgumentNullException(nameof(pluginContext));
this.credentialStore = credentialStore ?? throw new ArgumentNullException(nameof(credentialStore));
this.claimsEnricher = claimsEnricher ?? throw new ArgumentNullException(nameof(claimsEnricher));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
var options = optionsMonitor.Get(pluginContext.Manifest.Name);
options.Validate();
@@ -76,7 +80,8 @@ internal sealed class SamlIdentityProviderPlugin : IIdentityProviderPlugin
if (!string.IsNullOrWhiteSpace(options.IdpMetadataUrl))
{
using var httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
using var httpClient = httpClientFactory.CreateClient(SamlPluginRegistrar.GetHttpClientName(Name));
httpClient.Timeout = TimeSpan.FromSeconds(options.MetadataTimeoutSeconds);
var response = await httpClient.GetAsync(options.IdpMetadataUrl, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)

View File

@@ -0,0 +1,47 @@
using System;
using System.IO;
using System.Security.Cryptography.X509Certificates;
using System.Xml;
namespace StellaOps.Authority.Plugin.Saml;
internal static class SamlMetadataParser
{
public static bool TryExtractSigningCertificate(string metadataXml, out X509Certificate2 certificate)
{
certificate = null!;
if (string.IsNullOrWhiteSpace(metadataXml))
{
return false;
}
var doc = new XmlDocument { PreserveWhitespace = true, XmlResolver = null };
using (var reader = XmlReader.Create(new StringReader(metadataXml), CreateSecureXmlReaderSettings()))
{
doc.Load(reader);
}
var nsManager = new XmlNamespaceManager(doc.NameTable);
nsManager.AddNamespace("md", "urn:oasis:names:tc:SAML:2.0:metadata");
nsManager.AddNamespace("ds", "http://www.w3.org/2000/09/xmldsig#");
var node = doc.SelectSingleNode("//ds:X509Certificate", nsManager);
if (node == null || string.IsNullOrWhiteSpace(node.InnerText))
{
return false;
}
var raw = node.InnerText.Trim();
var bytes = Convert.FromBase64String(raw);
certificate = new X509Certificate2(bytes);
return true;
}
private static XmlReaderSettings CreateSecureXmlReaderSettings()
=> new()
{
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null
};
}

View File

@@ -124,18 +124,33 @@ public sealed class SamlPluginOptions
/// <summary>
/// Whether to sign authentication requests.
/// </summary>
public bool SignAuthenticationRequests { get; set; } = true;
public bool SignAuthenticationRequests { get; set; } = false;
/// <summary>
/// Whether to sign logout requests.
/// </summary>
public bool SignLogoutRequests { get; set; } = true;
public bool SignLogoutRequests { get; set; } = false;
/// <summary>
/// Cache duration for user sessions.
/// </summary>
public TimeSpan SessionCacheDuration { get; set; } = TimeSpan.FromMinutes(30);
/// <summary>
/// Require HTTPS when fetching metadata.
/// </summary>
public bool RequireHttpsMetadata { get; set; } = true;
/// <summary>
/// Metadata refresh interval.
/// </summary>
public TimeSpan MetadataRefreshInterval { get; set; } = TimeSpan.FromHours(24);
/// <summary>
/// Timeout in seconds for metadata retrieval and health checks.
/// </summary>
public int MetadataTimeoutSeconds { get; set; } = 10;
/// <summary>
/// Role mapping configuration.
/// </summary>
@@ -169,6 +184,39 @@ public sealed class SamlPluginOptions
throw new InvalidOperationException(
"SAML IdP signing certificate is required when ValidateSignature is true.");
}
if (RequireEncryptedAssertions)
{
throw new InvalidOperationException("SAML encrypted assertions are not supported yet. Disable RequireEncryptedAssertions.");
}
if (SignAuthenticationRequests || SignLogoutRequests)
{
throw new InvalidOperationException("SAML request signing is not supported yet. Disable SignAuthenticationRequests and SignLogoutRequests.");
}
if (!string.IsNullOrWhiteSpace(IdpMetadataUrl))
{
if (!Uri.TryCreate(IdpMetadataUrl, UriKind.Absolute, out var metadataUri))
{
throw new InvalidOperationException($"Invalid SAML IdP metadata URL: {IdpMetadataUrl}");
}
if (RequireHttpsMetadata && !string.Equals(metadataUri.Scheme, "https", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("SAML IdP metadata URL must use HTTPS when RequireHttpsMetadata is true.");
}
}
if (MetadataTimeoutSeconds <= 0)
{
throw new InvalidOperationException("SAML MetadataTimeoutSeconds must be greater than zero.");
}
if (MetadataRefreshInterval <= TimeSpan.Zero)
{
throw new InvalidOperationException("SAML MetadataRefreshInterval must be greater than zero.");
}
}
}

View File

@@ -7,6 +7,7 @@ using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Net.Http;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Saml.Claims;
using StellaOps.Authority.Plugin.Saml.Credentials;
@@ -23,6 +24,9 @@ public static class SamlPluginRegistrar
/// </summary>
public const string PluginType = "saml";
public static string GetHttpClientName(string pluginName)
=> $"saml:{pluginName}";
/// <summary>
/// Registers the SAML plugin with the given context.
/// </summary>
@@ -39,14 +43,15 @@ public static class SamlPluginRegistrar
var optionsMonitor = serviceProvider.GetRequiredService<IOptionsMonitor<SamlPluginOptions>>();
var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>();
var sessionCache = serviceProvider.GetService<IMemoryCache>()
?? new MemoryCache(new MemoryCacheOptions());
var sessionCache = serviceProvider.GetRequiredService<IMemoryCache>();
var httpClientFactory = serviceProvider.GetRequiredService<IHttpClientFactory>();
var credentialStore = new SamlCredentialStore(
pluginName,
optionsMonitor,
sessionCache,
loggerFactory.CreateLogger<SamlCredentialStore>());
loggerFactory.CreateLogger<SamlCredentialStore>(),
httpClientFactory);
var claimsEnricher = new SamlClaimsEnricher(
pluginName,
@@ -58,7 +63,8 @@ public static class SamlPluginRegistrar
credentialStore,
claimsEnricher,
optionsMonitor,
loggerFactory.CreateLogger<SamlIdentityProviderPlugin>());
loggerFactory.CreateLogger<SamlIdentityProviderPlugin>(),
httpClientFactory);
return plugin;
}
@@ -72,7 +78,7 @@ public static class SamlPluginRegistrar
Action<SamlPluginOptions>? configureOptions = null)
{
services.AddMemoryCache();
services.AddHttpClient();
services.AddHttpClient(GetHttpClientName(pluginName));
if (configureOptions != null)
{

View File

@@ -2,6 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>

View File

@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Authority.Plugin.Saml</RootNamespace>
<Description>StellaOps Authority SAML Identity Provider Plugin</Description>
<IsAuthorityPlugin>true</IsAuthorityPlugin>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.1" />
<PackageReference Include="Microsoft.IdentityModel.Tokens.Saml" Version="8.10.0" />
</ItemGroup>
</Project>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0094-M | DONE | Maintainability audit for StellaOps.Authority.Plugin.Saml. |
| AUDIT-0094-T | DONE | Test coverage audit for StellaOps.Authority.Plugin.Saml. |
| AUDIT-0094-A | TODO | Pending approval for changes. |
| AUDIT-0094-A | DONE | Applied SAML plugin updates, tests, and docs. |

View File

@@ -0,0 +1,53 @@
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardClaimsEnricherTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task EnrichAsync_AddsRolesAndAttributes()
{
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
"StellaOps.Authority.Plugin.Standard",
"standard.dll",
new[] { AuthorityPluginCapabilities.Password },
new Dictionary<string, string?>(),
"standard.yaml");
var context = new AuthorityClaimsEnrichmentContext(
new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build()),
new AuthorityUserDescriptor(
"subject-1",
"alice",
"Alice",
false,
new[] { "admin", "ops" },
new Dictionary<string, string?>
{
["region"] = "eu",
["team"] = "platform"
}),
client: null);
var identity = new ClaimsIdentity();
var enricher = new StandardClaimsEnricher();
await enricher.EnrichAsync(identity, context, CancellationToken.None);
Assert.Contains(identity.Claims, claim => claim.Type == ClaimTypes.Role && claim.Value == "admin");
Assert.Contains(identity.Claims, claim => claim.Type == ClaimTypes.Role && claim.Value == "ops");
Assert.Contains(identity.Claims, claim => claim.Type == "region" && claim.Value == "eu");
Assert.Contains(identity.Claims, claim => claim.Type == "team" && claim.Value == "platform");
}
}

View File

@@ -147,6 +147,40 @@ public class StandardClientProvisioningStoreTests
Assert.Equal("primary", binding.Label);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DeleteAsync_RemovesClientAndWritesRevocation()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-12-29T12:30:00Z"));
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, clock);
var registration = new AuthorityClientRegistration(
clientId: "delete-me",
confidential: false,
displayName: "Delete Me",
clientSecret: null,
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "scopeA" });
await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
var result = await provisioning.DeleteAsync("delete-me", CancellationToken.None);
Assert.True(result.Succeeded);
Assert.False(store.Documents.ContainsKey("delete-me"));
var revocation = Assert.Single(revocations.Upserts);
Assert.Equal("client", revocation.Category);
Assert.Equal("delete-me", revocation.RevocationId);
Assert.Equal("delete-me", revocation.ClientId);
Assert.Equal("operator_request", revocation.Reason);
Assert.Equal(clock.GetUtcNow(), revocation.RevokedAt);
Assert.Equal(clock.GetUtcNow(), revocation.EffectiveAt);
Assert.Equal("standard", revocation.Metadata["plugin"]);
}
private sealed class TrackingClientStore : IAuthorityClientStore
{
public Dictionary<string, AuthorityClientDocument> Documents { get; } = new(StringComparer.OrdinalIgnoreCase);
@@ -186,4 +220,13 @@ public class StandardClientProvisioningStoreTests
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
}
private sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset fixedNow;
public FakeTimeProvider(DateTimeOffset fixedNow) => this.fixedNow = fixedNow;
public override DateTimeOffset GetUtcNow() => fixedNow;
}
}

View File

@@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Authority.Persistence.InMemory.Stores;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard;
using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Cryptography;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardIdentityProviderPluginTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CheckHealthAsync_ReturnsHealthy()
{
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
"StellaOps.Authority.Plugin.Standard",
"standard.dll",
new[] { AuthorityPluginCapabilities.Password, AuthorityPluginCapabilities.Bootstrap, AuthorityPluginCapabilities.ClientProvisioning },
new Dictionary<string, string?>(),
"standard.yaml");
var context = new AuthorityPluginContext(manifest, new ConfigurationBuilder().Build());
var userRepository = new InMemoryUserRepository();
var options = new StandardPluginOptions();
var cryptoProvider = new DefaultCryptoProvider();
var auditLogger = new TestAuditLogger();
var store = new StandardUserCredentialStore(
"standard",
"tenant-1",
userRepository,
options,
new CryptoPasswordHasher(options, cryptoProvider),
auditLogger,
TimeProvider.System,
new FixedStandardIdGenerator(),
NullLogger<StandardUserCredentialStore>.Instance);
var clientStore = new InMemoryClientStore();
var revocationStore = new InMemoryRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", clientStore, revocationStore, TimeProvider.System);
var plugin = new StandardIdentityProviderPlugin(
context,
store,
provisioning,
new StandardClaimsEnricher(),
NullLogger<StandardIdentityProviderPlugin>.Instance);
var health = await plugin.CheckHealthAsync(CancellationToken.None);
Assert.Equal(AuthorityPluginHealthStatus.Healthy, health.Status);
}
private sealed class FixedStandardIdGenerator : IStandardIdGenerator
{
public Guid NewUserId() => Guid.Parse("00000000-0000-0000-0000-000000000201");
public string NewSubjectId() => "subject-201";
}
}

View File

@@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard;
using StellaOps.Authority.Plugin.Standard.Bootstrap;
using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Audit;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardPluginBootstrapperTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StartAsync_DoesNotThrow_WhenBootstrapFails()
{
var services = new ServiceCollection();
services.AddOptions<StandardPluginOptions>("standard")
.Configure(options =>
{
options.BootstrapUser = new BootstrapUserOptions
{
Username = "bootstrap",
Password = "Password1!",
RequirePasswordReset = false
};
});
services.AddSingleton<IUserRepository>(new ThrowingUserRepository());
services.AddSingleton<IStandardCredentialAuditLogger, NullAuditLogger>();
services.AddSingleton<TimeProvider>(new FakeTimeProvider(DateTimeOffset.Parse("2025-12-29T13:00:00Z")));
services.AddSingleton<IStandardIdGenerator>(new FixedStandardIdGenerator());
services.AddSingleton<ICryptoProvider>(new DefaultCryptoProvider());
services.AddSingleton(sp =>
{
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var options = optionsMonitor.Get("standard");
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
return new StandardUserCredentialStore(
"standard",
"tenant-1",
sp.GetRequiredService<IUserRepository>(),
options,
new CryptoPasswordHasher(options, cryptoProvider),
auditLogger,
sp.GetRequiredService<TimeProvider>(),
sp.GetRequiredService<IStandardIdGenerator>(),
NullLogger<StandardUserCredentialStore>.Instance);
});
services.AddSingleton<StandardPluginBootstrapper>(sp =>
new StandardPluginBootstrapper("standard", sp.GetRequiredService<IServiceScopeFactory>(), NullLogger<StandardPluginBootstrapper>.Instance));
using var provider = services.BuildServiceProvider();
var bootstrapper = provider.GetRequiredService<StandardPluginBootstrapper>();
var exception = await Record.ExceptionAsync(() => bootstrapper.StartAsync(CancellationToken.None));
Assert.Null(exception);
}
private sealed class ThrowingUserRepository : IUserRepository
{
public Task<UserEntity> CreateAsync(UserEntity user, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
public Task<UserEntity?> GetByIdAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
public Task<UserEntity?> GetByUsernameAsync(string tenantId, string username, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
public Task<UserEntity?> GetBySubjectIdAsync(string tenantId, string subjectId, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
public Task<UserEntity?> GetByEmailAsync(string tenantId, string email, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
public Task<IReadOnlyList<UserEntity>> GetAllAsync(string tenantId, bool? enabled = null, int limit = 100, int offset = 0, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
public Task<bool> UpdateAsync(UserEntity user, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
public Task<bool> DeleteAsync(string tenantId, Guid id, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
public Task<bool> UpdatePasswordAsync(string tenantId, Guid userId, string passwordHash, string passwordSalt, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
public Task<int> RecordFailedLoginAsync(string tenantId, Guid userId, DateTimeOffset? lockUntil = null, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
public Task RecordSuccessfulLoginAsync(string tenantId, Guid userId, CancellationToken cancellationToken = default)
=> throw new InvalidOperationException("Simulated failure");
}
private sealed class NullAuditLogger : IStandardCredentialAuditLogger
{
public ValueTask RecordAsync(
string pluginName,
string normalizedUsername,
string? subjectId,
bool success,
AuthorityCredentialFailureCode? failureCode,
string? reason,
IReadOnlyList<AuthEventProperty>? properties,
CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
}
private sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset fixedNow;
public FakeTimeProvider(DateTimeOffset fixedNow) => this.fixedNow = fixedNow;
public override DateTimeOffset GetUtcNow() => fixedNow;
}
private sealed class FixedStandardIdGenerator : IStandardIdGenerator
{
public Guid NewUserId() => Guid.Parse("00000000-0000-0000-0000-000000000301");
public string NewSubjectId() => "subject-301";
}
}

View File

@@ -104,6 +104,40 @@ public class StandardPluginOptionsTests
Assert.Equal(Path.GetFullPath(absolute), options.TokenSigning.KeyDirectory);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Normalize_TrimsTenantAndBootstrapValues()
{
var options = new StandardPluginOptions
{
TenantId = " Tenant-A ",
BootstrapUser = new BootstrapUserOptions
{
Username = " admin ",
Password = " "
}
};
options.Normalize(Path.Combine(Path.GetTempPath(), "config", "standard.yaml"));
Assert.Equal("tenant-a", options.TenantId);
Assert.Equal("admin", options.BootstrapUser?.Username);
Assert.Null(options.BootstrapUser?.Password);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_Throws_WhenTokenSigningConfigured()
{
var options = new StandardPluginOptions
{
TokenSigning = { KeyDirectory = "/tmp/keys" }
};
var ex = Assert.Throws<InvalidOperationException>(() => options.Validate("standard"));
Assert.Contains("token signing", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Validate_Throws_WhenPasswordHashingMemoryInvalid()

View File

@@ -208,7 +208,7 @@ public class StandardPluginRegistrarTests
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Register_NormalizesTokenSigningKeyDirectory()
public void Register_Throws_WhenTokenSigningKeyDirectoryConfigured()
{
var client = new InMemoryClient();
var database = client.GetDatabase("registrar-token-signing");
@@ -238,7 +238,6 @@ public class StandardPluginRegistrarTests
var pluginContext = new AuthorityPluginContext(manifest, configuration);
var services = StandardPluginRegistrarTestHelpers.CreateServiceCollection(database, configuration);
services.AddSingleton(TimeProvider.System);
var registrar = new StandardPluginRegistrar();
registrar.Register(new AuthorityPluginRegistrationContext(services, pluginContext, configuration));
@@ -246,10 +245,7 @@ public class StandardPluginRegistrarTests
using var provider = services.BuildServiceProvider();
var optionsMonitor = provider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var options = optionsMonitor.Get("standard");
var expected = Path.GetFullPath(Path.Combine(configDir, "../keys"));
Assert.Equal(expected, options.TokenSigning.KeyDirectory);
Assert.Throws<InvalidOperationException>(() => optionsMonitor.Get("standard"));
}
finally
{

View File

@@ -23,6 +23,8 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
private readonly StandardPluginOptions options;
private readonly StandardUserCredentialStore store;
private readonly TestAuditLogger auditLogger;
private readonly FakeTimeProvider clock;
private readonly SequenceStandardIdGenerator idGenerator;
public StandardUserCredentialStoreTests()
{
@@ -53,6 +55,8 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
var cryptoProvider = new DefaultCryptoProvider();
auditLogger = new TestAuditLogger();
userRepository = new InMemoryUserRepository();
clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-12-29T12:00:00Z"));
idGenerator = new SequenceStandardIdGenerator();
store = new StandardUserCredentialStore(
"standard",
"test-tenant",
@@ -60,6 +64,8 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
options,
new CryptoPasswordHasher(options, cryptoProvider),
auditLogger,
clock,
idGenerator,
NullLogger<StandardUserCredentialStore>.Instance);
}
@@ -155,7 +161,7 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
await userRepository.CreateAsync(new UserEntity
{
Id = Guid.NewGuid(),
Id = Guid.Parse("00000000-0000-0000-0000-000000000101"),
TenantId = "test-tenant",
Username = "legacy",
Email = "legacy@local",
@@ -188,6 +194,87 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
Assert.StartsWith("$argon2id$", updated!.PasswordHash, StringComparison.Ordinal);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpsertUserAsync_PreservesRolesAndAttributesOnUpdate()
{
var registration = new AuthorityUserRegistration(
"chris",
"Password1!",
"Chris",
null,
false,
new[] { "viewer" },
new Dictionary<string, string?>
{
["region"] = "eu"
});
var created = await store.UpsertUserAsync(registration, CancellationToken.None);
Assert.True(created.Succeeded);
var update = new AuthorityUserRegistration(
"chris",
password: null,
displayName: "Chris Updated",
email: null,
requirePasswordReset: true,
roles: new[] { "editor", "admin" },
attributes: new Dictionary<string, string?>
{
["region"] = "us",
["team"] = "platform"
});
var updated = await store.UpsertUserAsync(update, CancellationToken.None);
Assert.True(updated.Succeeded);
Assert.Contains("editor", updated.Value.Roles);
Assert.Contains("admin", updated.Value.Roles);
Assert.Equal("us", updated.Value.Attributes["region"]);
Assert.Equal("platform", updated.Value.Attributes["team"]);
Assert.True(updated.Value.RequiresPasswordReset);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task FindBySubjectAsync_ReturnsUserWhenSubjectMatches()
{
var registration = new AuthorityUserRegistration(
"dana",
"Password1!",
"Dana",
null,
false,
Array.Empty<string>(),
new Dictionary<string, string?>());
var created = await store.UpsertUserAsync(registration, CancellationToken.None);
Assert.True(created.Succeeded);
var found = await store.FindBySubjectAsync(created.Value.SubjectId, CancellationToken.None);
Assert.NotNull(found);
Assert.Equal("dana", found!.Username);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task UpsertUserAsync_RejectsWeakPasswords()
{
var registration = new AuthorityUserRegistration(
"erin",
"short",
"Erin",
null,
false,
Array.Empty<string>(),
new Dictionary<string, string?>());
var result = await store.UpsertUserAsync(registration, CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Equal("password_policy_violation", result.ErrorCode);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task VerifyPasswordAsync_RecordsAudit_ForUnknownUser()
@@ -249,6 +336,34 @@ internal sealed class TestAuditLogger : IStandardCredentialAuditLogger
IReadOnlyList<AuthEventProperty> Properties);
}
internal sealed class FakeTimeProvider : TimeProvider
{
private readonly DateTimeOffset fixedNow;
public FakeTimeProvider(DateTimeOffset fixedNow) => this.fixedNow = fixedNow;
public override DateTimeOffset GetUtcNow() => fixedNow;
}
internal sealed class SequenceStandardIdGenerator : IStandardIdGenerator
{
private int userCounter;
private int subjectCounter;
public Guid NewUserId()
{
userCounter++;
var suffix = userCounter.ToString("D12", CultureInfo.InvariantCulture);
return Guid.Parse($"00000000-0000-0000-0000-{suffix}");
}
public string NewSubjectId()
{
subjectCounter++;
return $"subject-{subjectCounter}";
}
}

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Authority.Persistence.Postgres.Models;
@@ -70,6 +71,24 @@ internal sealed class InMemoryUserRepository : IUserRepository
return Task.FromResult<UserEntity?>(null);
}
public Task<UserEntity?> GetBySubjectIdAsync(string tenantId, string subjectId, CancellationToken cancellationToken = default)
{
foreach (var user in users.Values)
{
if (!string.Equals(user.TenantId, tenantId, StringComparison.Ordinal))
{
continue;
}
if (TryGetSubjectId(user.Metadata, out var stored) && string.Equals(stored, subjectId, StringComparison.Ordinal))
{
return Task.FromResult<UserEntity?>(user);
}
}
return Task.FromResult<UserEntity?>(null);
}
public Task<UserEntity?> GetByEmailAsync(string tenantId, string email, CancellationToken cancellationToken = default)
{
var key = GetEmailKey(tenantId, email);
@@ -278,4 +297,34 @@ internal sealed class InMemoryUserRepository : IUserRepository
private static string GetEmailKey(string tenantId, string email)
=> $"{tenantId}::{email}".ToLowerInvariant();
private static bool TryGetSubjectId(string? metadataJson, out string? subjectId)
{
subjectId = null;
if (string.IsNullOrWhiteSpace(metadataJson))
{
return false;
}
try
{
using var document = JsonDocument.Parse(metadataJson);
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
return false;
}
if (document.RootElement.TryGetProperty("subjectId", out var subjectElement)
&& subjectElement.ValueKind == JsonValueKind.String)
{
subjectId = subjectElement.GetString();
return !string.IsNullOrWhiteSpace(subjectId);
}
}
catch
{
}
return false;
}
}

View File

@@ -37,7 +37,14 @@ internal sealed class StandardPluginBootstrapper : IHostedService
}
logger.LogInformation("Standard Authority plugin '{PluginName}' ensuring bootstrap user.", pluginName);
await credentialStore.EnsureBootstrapUserAsync(options.BootstrapUser, cancellationToken).ConfigureAwait(false);
try
{
await credentialStore.EnsureBootstrapUserAsync(options.BootstrapUser, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
logger.LogError(ex, "Standard Authority plugin '{PluginName}' failed to ensure bootstrap user.", pluginName);
}
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;

View File

@@ -20,6 +20,8 @@ internal sealed class StandardPluginOptions
public void Normalize(string configPath)
{
TenantId = NormalizeTenantId(TenantId);
BootstrapUser?.Normalize();
TokenSigning.Normalize(configPath);
}
@@ -29,7 +31,16 @@ internal sealed class StandardPluginOptions
PasswordPolicy.Validate(pluginName);
Lockout.Validate(pluginName);
PasswordHashing.Validate();
if (!string.IsNullOrWhiteSpace(TokenSigning.KeyDirectory))
{
throw new InvalidOperationException(
$"Standard plugin '{pluginName}' does not support token signing keys. Remove tokenSigning.keyDirectory from the configuration.");
}
}
private static string? NormalizeTenantId(string? tenantId)
=> string.IsNullOrWhiteSpace(tenantId) ? null : tenantId.Trim().ToLowerInvariant();
}
internal sealed class BootstrapUserOptions
@@ -52,6 +63,15 @@ internal sealed class BootstrapUserOptions
throw new InvalidOperationException($"Standard plugin '{pluginName}' requires both bootstrapUser.username and bootstrapUser.password when configuring a bootstrap user.");
}
}
public void Normalize()
{
Username = string.IsNullOrWhiteSpace(Username) ? null : Username.Trim();
if (string.IsNullOrWhiteSpace(Password))
{
Password = null;
}
}
}
internal sealed class PasswordPolicyOptions

View File

@@ -46,6 +46,7 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
.ValidateOnStart();
context.Services.AddScoped<IStandardCredentialAuditLogger, StandardCredentialAuditLogger>();
context.Services.AddSingleton<IStandardIdGenerator, GuidStandardIdGenerator>();
context.Services.AddScoped(sp =>
{
@@ -57,6 +58,8 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
var clock = sp.GetRequiredService<TimeProvider>();
var idGenerator = sp.GetRequiredService<IStandardIdGenerator>();
var baselinePolicy = new PasswordPolicyOptions();
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
@@ -86,6 +89,8 @@ internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
pluginOptions,
passwordHasher,
auditLogger,
clock,
idGenerator,
loggerFactory.CreateLogger<StandardUserCredentialStore>());
});

View File

@@ -5,7 +5,7 @@
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsAuthorityPlugin>true</IsAuthorityPlugin>
</PropertyGroup>
<ItemGroup>

View File

@@ -0,0 +1,17 @@
using System;
namespace StellaOps.Authority.Plugin.Standard.Storage;
internal interface IStandardIdGenerator
{
Guid NewUserId();
string NewSubjectId();
}
internal sealed class GuidStandardIdGenerator : IStandardIdGenerator
{
public Guid NewUserId() => Guid.NewGuid();
public string NewSubjectId() => Guid.NewGuid().ToString("N");
}

View File

@@ -20,6 +20,8 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
private readonly StandardPluginOptions options;
private readonly IPasswordHasher passwordHasher;
private readonly IStandardCredentialAuditLogger auditLogger;
private readonly TimeProvider clock;
private readonly IStandardIdGenerator idGenerator;
private readonly ILogger<StandardUserCredentialStore> logger;
private readonly string pluginName;
private readonly string tenantId;
@@ -31,6 +33,8 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
StandardPluginOptions options,
IPasswordHasher passwordHasher,
IStandardCredentialAuditLogger auditLogger,
TimeProvider clock,
IStandardIdGenerator idGenerator,
ILogger<StandardUserCredentialStore> logger)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
@@ -39,6 +43,8 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
this.options = options ?? throw new ArgumentNullException(nameof(options));
this.passwordHasher = passwordHasher ?? throw new ArgumentNullException(nameof(passwordHasher));
this.auditLogger = auditLogger ?? throw new ArgumentNullException(nameof(auditLogger));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
@@ -74,9 +80,10 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
var user = MapToDocument(userEntity);
if (options.Lockout.Enabled && userEntity.LockedUntil is { } lockoutEnd && lockoutEnd > DateTimeOffset.UtcNow)
var now = clock.GetUtcNow();
if (options.Lockout.Enabled && userEntity.LockedUntil is { } lockoutEnd && lockoutEnd > now)
{
var retryAfter = lockoutEnd - DateTimeOffset.UtcNow;
var retryAfter = lockoutEnd - now;
logger.LogWarning("Plugin {PluginName} denied access for {Username} due to lockout (retry after {RetryAfter}).", pluginName, normalized, retryAfter);
auditProperties.Add(new AuthEventProperty
{
@@ -154,8 +161,9 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
? AuthorityCredentialFailureCode.LockedOut
: AuthorityCredentialFailureCode.InvalidCredentials;
TimeSpan? retry = updatedUser?.LockedUntil is { } lockoutTime && lockoutTime > DateTimeOffset.UtcNow
? lockoutTime - DateTimeOffset.UtcNow
var retryNow = clock.GetUtcNow();
TimeSpan? retry = updatedUser?.LockedUntil is { } lockoutTime && lockoutTime > retryNow
? lockoutTime - retryNow
: null;
auditProperties.Add(new AuthEventProperty
@@ -198,8 +206,6 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
ArgumentNullException.ThrowIfNull(registration);
var normalized = NormalizeUsername(registration.Username);
var now = DateTimeOffset.UtcNow;
if (!string.IsNullOrEmpty(registration.Password))
{
var passwordValidation = ValidatePassword(registration.Password);
@@ -221,7 +227,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
var metadata = new Dictionary<string, object?>
{
["subjectId"] = Guid.NewGuid().ToString("N"),
["subjectId"] = idGenerator.NewSubjectId(),
["roles"] = registration.Roles.ToList(),
["attributes"] = registration.Attributes,
["requirePasswordReset"] = registration.RequirePasswordReset
@@ -229,7 +235,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
var newUser = new UserEntity
{
Id = Guid.NewGuid(),
Id = idGenerator.NewUserId(),
TenantId = tenantId,
Username = normalized,
Email = registration.Email ?? $"{normalized}@local",
@@ -301,17 +307,23 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
return null;
}
// We need to search by subjectId which is stored in metadata
// For now, get all users and filter - in production, add a dedicated query
var users = await userRepository.GetAllAsync(tenantId, enabled: null, limit: 1000, cancellationToken: cancellationToken)
var user = await userRepository.GetBySubjectIdAsync(tenantId, subjectId, cancellationToken)
.ConfigureAwait(false);
foreach (var user in users)
if (user is not null)
{
var metadata = ParseMetadata(user.Metadata);
if (metadata.TryGetValue("subjectId", out var sid) && sid?.ToString() == subjectId)
return ToDescriptor(MapToDocument(user, metadata));
}
if (Guid.TryParse(subjectId, out var parsed))
{
var fallback = await userRepository.GetByIdAsync(tenantId, parsed, cancellationToken)
.ConfigureAwait(false);
if (fallback is not null)
{
return ToDescriptor(MapToDocument(user, metadata));
var metadata = ParseMetadata(fallback.Metadata);
return ToDescriptor(MapToDocument(fallback, metadata));
}
}
@@ -387,7 +399,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
if (options.Lockout.Enabled && user.FailedLoginAttempts + 1 >= options.Lockout.MaxAttempts)
{
lockUntil = DateTimeOffset.UtcNow + options.Lockout.Window;
lockUntil = clock.GetUtcNow() + options.Lockout.Window;
}
await userRepository.RecordFailedLoginAsync(tenantId, user.Id, lockUntil, cancellationToken)
@@ -401,14 +413,12 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
{
metadata ??= ParseMetadata(entity.Metadata);
var subjectId = metadata.TryGetValue("subjectId", out var sid) ? sid?.ToString() ?? entity.Id.ToString("N") : entity.Id.ToString("N");
var roles = metadata.TryGetValue("roles", out var r) && r is JsonElement rolesElement
? rolesElement.EnumerateArray().Select(e => e.GetString() ?? "").Where(s => !string.IsNullOrEmpty(s)).ToList()
: new List<string>();
var attrs = metadata.TryGetValue("attributes", out var a) && a is JsonElement attrsElement
? attrsElement.EnumerateObject().ToDictionary(p => p.Name, p => p.Value.GetString(), StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
var requireReset = metadata.TryGetValue("requirePasswordReset", out var rr) && rr is JsonElement rrElement && rrElement.GetBoolean();
var subjectId = metadata.TryGetValue("subjectId", out var sid) && !string.IsNullOrWhiteSpace(sid?.ToString())
? sid!.ToString()!
: entity.Id.ToString("N");
var roles = ReadRoles(metadata.TryGetValue("roles", out var r) ? r : null);
var attrs = ReadAttributes(metadata.TryGetValue("attributes", out var a) ? a : null);
var requireReset = ReadBoolean(metadata.TryGetValue("requirePasswordReset", out var rr) ? rr : null);
return new StandardUserDocument
{
@@ -421,7 +431,7 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
Email = entity.Email,
RequirePasswordReset = requireReset,
Roles = roles,
Attributes = attrs!,
Attributes = attrs,
Lockout = new StandardLockoutState
{
FailedAttempts = entity.FailedLoginAttempts,
@@ -460,6 +470,97 @@ internal sealed class StandardUserCredentialStore : IUserCredentialStore
document.Roles,
document.Attributes);
private static List<string> ReadRoles(object? value)
{
if (value is null)
{
return new List<string>();
}
if (value is JsonElement element && element.ValueKind == JsonValueKind.Array)
{
return element.EnumerateArray()
.Select(entry => entry.GetString() ?? string.Empty)
.Where(entry => !string.IsNullOrWhiteSpace(entry))
.ToList();
}
if (value is IEnumerable<string> strings)
{
return strings.Where(static entry => !string.IsNullOrWhiteSpace(entry))
.Select(static entry => entry.Trim())
.ToList();
}
if (value is IEnumerable<object> values)
{
return values.Select(static entry => entry?.ToString() ?? string.Empty)
.Where(static entry => !string.IsNullOrWhiteSpace(entry))
.Select(static entry => entry.Trim())
.ToList();
}
if (value is string single && !string.IsNullOrWhiteSpace(single))
{
return new List<string> { single.Trim() };
}
return new List<string>();
}
private static Dictionary<string, string?> ReadAttributes(object? value)
{
if (value is null)
{
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
if (value is JsonElement element && element.ValueKind == JsonValueKind.Object)
{
return element.EnumerateObject()
.ToDictionary(
property => property.Name,
property => property.Value.ValueKind == JsonValueKind.Null ? null : property.Value.ToString(),
StringComparer.OrdinalIgnoreCase);
}
if (value is IReadOnlyDictionary<string, string?> stringMap)
{
return new Dictionary<string, string?>(stringMap, StringComparer.OrdinalIgnoreCase);
}
if (value is IReadOnlyDictionary<string, object?> objectMap)
{
var resolved = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in objectMap)
{
resolved[pair.Key] = pair.Value switch
{
null => null,
JsonElement json when json.ValueKind == JsonValueKind.Null => null,
JsonElement json => json.ToString(),
_ => pair.Value.ToString()
};
}
return resolved;
}
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
private static bool ReadBoolean(object? value)
{
return value switch
{
null => false,
bool flag => flag,
JsonElement json when json.ValueKind == JsonValueKind.True => true,
JsonElement json when json.ValueKind == JsonValueKind.False => false,
_ => false
};
}
private async ValueTask RecordAuditAsync(
string normalizedUsername,
string? subjectId,

View File

@@ -5,9 +5,9 @@ namespace StellaOps.Authority.Plugin.Standard.Storage;
internal sealed class StandardUserDocument
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid Id { get; set; }
public string SubjectId { get; set; } = Guid.NewGuid().ToString("N");
public string SubjectId { get; set; } = string.Empty;
public string Username { get; set; } = string.Empty;
@@ -27,9 +27,9 @@ internal sealed class StandardUserDocument
public StandardLockoutState Lockout { get; set; } = new();
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; }
}
internal sealed class StandardLockoutState

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0096-M | DONE | Maintainability audit for StellaOps.Authority.Plugin.Standard. |
| AUDIT-0096-T | DONE | Test coverage audit for StellaOps.Authority.Plugin.Standard. |
| AUDIT-0096-A | TODO | Pending approval for changes. |
| AUDIT-0096-A | DONE | Pending approval for changes. |

View File

@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using StellaOps.Authority.Plugins.Abstractions;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Authority.Plugins.Abstractions.Tests;
public class AuthorityClientDescriptorNormalizationTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ClientDescriptor_NormalizesScopesAndMetadata()
{
var descriptor = new AuthorityClientDescriptor(
clientId: "client-1",
displayName: "Client 1",
confidential: true,
allowedGrantTypes: new[] { "client_credentials", " client_credentials " },
allowedScopes: new[] { " Authority.Users.Read ", "authority.users.read" },
allowedAudiences: new[] { "api", " api " },
properties: new Dictionary<string, string?>
{
[AuthorityClientMetadataKeys.Tenant] = " Tenant-A ",
[AuthorityClientMetadataKeys.Project] = " Project-One "
});
Assert.Equal("tenant-a", descriptor.Tenant);
Assert.Equal("project-one", descriptor.Project);
Assert.Single(descriptor.AllowedGrantTypes);
Assert.Single(descriptor.AllowedAudiences);
Assert.Contains("authority.users.read", descriptor.AllowedScopes);
Assert.Equal("project-one", descriptor.Properties[AuthorityClientMetadataKeys.Project]);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void CertificateBindingRegistration_NormalizesFields()
{
var binding = new AuthorityClientCertificateBindingRegistration(
thumbprint: "aa:bb:cc:dd",
serialNumber: " 01ff ",
subject: " CN=test ",
issuer: " CN=issuer ",
subjectAlternativeNames: new[] { "EXAMPLE.com", " example.com ", "spiffe://client" },
label: " primary ");
Assert.Equal("AABBCCDD", binding.Thumbprint);
Assert.Equal("01ff", binding.SerialNumber);
Assert.Equal("CN=test", binding.Subject);
Assert.Equal("CN=issuer", binding.Issuer);
Assert.Equal("primary", binding.Label);
Assert.Equal(2, binding.SubjectAlternativeNames.Count);
}
}

View File

@@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Authority.Plugins.Abstractions;
using Microsoft.Extensions.Configuration;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Authority.Plugins.Abstractions.Tests;
public class AuthorityIdentityProviderHandleTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void Dispose_DisposesScope()
{
var scope = new TrackingScope();
var handle = CreateHandle(scope);
handle.Dispose();
Assert.Equal(1, scope.DisposeCalls);
Assert.Equal(0, scope.DisposeAsyncCalls);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DisposeAsync_DisposesScopeAsync()
{
var scope = new TrackingScope();
var handle = CreateHandle(scope);
await handle.DisposeAsync();
Assert.Equal(0, scope.DisposeCalls);
Assert.Equal(1, scope.DisposeAsyncCalls);
}
private static AuthorityIdentityProviderHandle CreateHandle(TrackingScope scope)
{
var asyncScope = new AsyncServiceScope(scope);
var metadata = new AuthorityIdentityProviderMetadata(
"standard",
"standard",
new AuthorityIdentityProviderCapabilities(true, false, false, false));
var plugin = new StubIdentityProviderPlugin();
return new AuthorityIdentityProviderHandle(asyncScope, metadata, plugin);
}
private sealed class TrackingScope : IServiceScope, IAsyncDisposable
{
public IServiceProvider ServiceProvider { get; } = new ServiceCollection().BuildServiceProvider();
public int DisposeCalls { get; private set; }
public int DisposeAsyncCalls { get; private set; }
public void Dispose()
{
DisposeCalls++;
}
public ValueTask DisposeAsync()
{
DisposeAsyncCalls++;
return ValueTask.CompletedTask;
}
}
private sealed class StubIdentityProviderPlugin : IIdentityProviderPlugin
{
public string Name => "standard";
public string Type => "standard";
public AuthorityPluginContext Context { get; } = new(
new AuthorityPluginManifest(
"standard",
"standard",
true,
"assembly",
"path",
Array.Empty<string>(),
new Dictionary<string, string?>(),
"standard.yaml"),
new ConfigurationBuilder().Build());
public IUserCredentialStore Credentials { get; } = new StubCredentialStore();
public IClaimsEnricher ClaimsEnricher { get; } = new StubClaimsEnricher();
public IClientProvisioningStore? ClientProvisioning => null;
public AuthorityIdentityProviderCapabilities Capabilities { get; } = new(true, false, false, false);
public ValueTask<AuthorityPluginHealthResult> CheckHealthAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult(AuthorityPluginHealthResult.Healthy());
}
private sealed class StubCredentialStore : IUserCredentialStore
{
public ValueTask<AuthorityCredentialVerificationResult> VerifyPasswordAsync(string username, string password, CancellationToken cancellationToken)
=> ValueTask.FromResult(AuthorityCredentialVerificationResult.Failure(AuthorityCredentialFailureCode.InvalidCredentials));
public ValueTask<AuthorityPluginOperationResult<AuthorityUserDescriptor>> UpsertUserAsync(AuthorityUserRegistration registration, CancellationToken cancellationToken)
=> ValueTask.FromResult(AuthorityPluginOperationResult<AuthorityUserDescriptor>.Failure("not_supported"));
public ValueTask<AuthorityUserDescriptor?> FindBySubjectAsync(string subjectId, CancellationToken cancellationToken)
=> ValueTask.FromResult<AuthorityUserDescriptor?>(null);
}
private sealed class StubClaimsEnricher : IClaimsEnricher
{
public ValueTask EnrichAsync(ClaimsIdentity identity, AuthorityClaimsEnrichmentContext context, CancellationToken cancellationToken)
=> ValueTask.CompletedTask;
}
}

View File

@@ -0,0 +1,45 @@
using System.Collections.Generic;
using StellaOps.Authority.Plugins.Abstractions;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Authority.Plugins.Abstractions.Tests;
public class AuthorityPluginManifestTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void HasCapability_IgnoresCaseAndWhitespace()
{
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
"assembly",
"path",
new List<string> { " password ", "Bootstrap" },
new Dictionary<string, string?>(),
"config.yaml");
Assert.True(manifest.HasCapability("password"));
Assert.True(manifest.HasCapability(" bootstrap "));
Assert.False(manifest.HasCapability("mfa"));
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void HasCapability_ReturnsFalse_ForBlankInput()
{
var manifest = new AuthorityPluginManifest(
"standard",
"standard",
true,
"assembly",
"path",
new List<string>(),
new Dictionary<string, string?>(),
"config.yaml");
Assert.False(manifest.HasCapability(" "));
}
}

View File

@@ -0,0 +1,88 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Cryptography;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Authority.Plugins.Abstractions.Tests;
public class AuthoritySecretHasherTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ComputeHash_Throws_WhenUnconfiguredAlgorithmRequested()
{
AuthoritySecretHasher.Reset();
var ex = Assert.Throws<InvalidOperationException>(() => AuthoritySecretHasher.ComputeHash("secret", "sha512"));
Assert.Contains("not configured", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ComputeHash_UsesConfiguredDefaultAlgorithm()
{
using var scope = AuthoritySecretHasher.BeginScope(new FakeCryptoHash(), "sha512");
var hash = AuthoritySecretHasher.ComputeHash("secret");
Assert.Equal(Convert.ToBase64String("SHA512"u8.ToArray()), hash);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public void ComputeHash_UsesExplicitAlgorithmWhenProvided()
{
using var scope = AuthoritySecretHasher.BeginScope(new FakeCryptoHash(), "sha256");
var hash = AuthoritySecretHasher.ComputeHash("secret", "sha384");
Assert.Equal(Convert.ToBase64String("SHA384"u8.ToArray()), hash);
}
private sealed class FakeCryptoHash : ICryptoHash
{
public byte[] ComputeHash(ReadOnlySpan<byte> data, string? algorithmId = null)
=> System.Text.Encoding.UTF8.GetBytes(algorithmId ?? string.Empty);
public string ComputeHashHex(ReadOnlySpan<byte> data, string? algorithmId = null)
=> throw new NotImplementedException();
public string ComputeHashBase64(ReadOnlySpan<byte> data, string? algorithmId = null)
=> throw new NotImplementedException();
public ValueTask<byte[]> ComputeHashAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public ValueTask<string> ComputeHashHexAsync(Stream stream, string? algorithmId = null, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public byte[] ComputeHashForPurpose(ReadOnlySpan<byte> data, string purpose)
=> throw new NotImplementedException();
public string ComputeHashHexForPurpose(ReadOnlySpan<byte> data, string purpose)
=> throw new NotImplementedException();
public string ComputeHashBase64ForPurpose(ReadOnlySpan<byte> data, string purpose)
=> throw new NotImplementedException();
public ValueTask<byte[]> ComputeHashForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public ValueTask<string> ComputeHashHexForPurposeAsync(Stream stream, string purpose, CancellationToken cancellationToken = default)
=> throw new NotImplementedException();
public string GetAlgorithmForPurpose(string purpose)
=> throw new NotImplementedException();
public string GetHashPrefix(string purpose)
=> throw new NotImplementedException();
public string ComputePrefixedHashForPurpose(ReadOnlySpan<byte> data, string purpose)
=> throw new NotImplementedException();
}
}

View File

@@ -51,9 +51,20 @@ public sealed record AuthorityPluginManifest(
return false;
}
var normalized = capability.Trim();
if (normalized.Length == 0)
{
return false;
}
foreach (var entry in Capabilities)
{
if (string.Equals(entry, capability, StringComparison.OrdinalIgnoreCase))
if (string.IsNullOrWhiteSpace(entry))
{
continue;
}
if (string.Equals(entry.Trim(), normalized, StringComparison.OrdinalIgnoreCase))
{
return true;
}

View File

@@ -11,8 +11,7 @@ namespace StellaOps.Authority.Plugins.Abstractions;
/// </summary>
public static class AuthoritySecretHasher
{
private static ICryptoHash? configuredHash;
private static string defaultAlgorithm = HashAlgorithms.Sha256;
private static AuthoritySecretHasherConfiguration configuration = AuthoritySecretHasherConfiguration.Default;
/// <summary>
/// Configures the shared crypto hash service used for secret hashing.
@@ -20,13 +19,29 @@ public static class AuthoritySecretHasher
public static void Configure(ICryptoHash hash, string? algorithmId = null)
{
ArgumentNullException.ThrowIfNull(hash);
Volatile.Write(ref configuredHash, hash);
if (!string.IsNullOrWhiteSpace(algorithmId))
{
defaultAlgorithm = NormalizeAlgorithm(algorithmId);
}
Volatile.Write(ref configuration, AuthoritySecretHasherConfiguration.Create(hash, algorithmId));
}
/// <summary>
/// Configures the shared crypto hash service for a scoped duration.
/// </summary>
public static AuthoritySecretHasherScope BeginScope(ICryptoHash hash, string? algorithmId = null)
{
ArgumentNullException.ThrowIfNull(hash);
var previous = Volatile.Read(ref configuration);
Volatile.Write(ref configuration, AuthoritySecretHasherConfiguration.Create(hash, algorithmId));
return new AuthoritySecretHasherScope(previous);
}
/// <summary>
/// Resets the configuration to the default SHA-256 implementation.
/// </summary>
public static void Reset()
=> Volatile.Write(ref configuration, AuthoritySecretHasherConfiguration.Default);
internal static void ResetTo(AuthoritySecretHasherConfiguration previous)
=> Volatile.Write(ref configuration, previous);
/// <summary>
/// Computes a stable hash for the provided secret using the configured crypto provider.
/// </summary>
@@ -37,11 +52,12 @@ public static class AuthoritySecretHasher
return string.Empty;
}
var config = Volatile.Read(ref configuration);
var algorithm = string.IsNullOrWhiteSpace(algorithmId)
? defaultAlgorithm
: NormalizeAlgorithm(algorithmId);
? config.DefaultAlgorithm
: AuthoritySecretHasherConfiguration.NormalizeAlgorithm(algorithmId);
var hasher = Volatile.Read(ref configuredHash);
var hasher = config.Hash;
if (hasher is not null)
{
var digest = hasher.ComputeHash(Encoding.UTF8.GetBytes(secret), algorithm);
@@ -58,8 +74,55 @@ public static class AuthoritySecretHasher
return Convert.ToBase64String(bytes);
}
private static string NormalizeAlgorithm(string algorithmId)
}
/// <summary>
/// Restores the previous AuthoritySecretHasher configuration when disposed.
/// </summary>
public readonly struct AuthoritySecretHasherScope : IDisposable
{
private readonly Action? restore;
internal AuthoritySecretHasherScope(AuthoritySecretHasherConfiguration previous)
{
restore = () => AuthoritySecretHasher.ResetTo(previous);
}
public void Dispose()
{
restore?.Invoke();
}
}
/// <summary>
/// Represents a scoped AuthoritySecretHasher configuration.
/// </summary>
public sealed record AuthoritySecretHasherConfiguration
{
public ICryptoHash? Hash { get; }
public string DefaultAlgorithm { get; }
private AuthoritySecretHasherConfiguration(ICryptoHash? hash, string defaultAlgorithm)
{
Hash = hash;
DefaultAlgorithm = defaultAlgorithm;
}
public static AuthoritySecretHasherConfiguration Default { get; } =
new(null, HashAlgorithms.Sha256);
public static AuthoritySecretHasherConfiguration Create(ICryptoHash hash, string? algorithmId = null)
{
var algorithm = NormalizeAlgorithm(algorithmId);
return new AuthoritySecretHasherConfiguration(hash, algorithm);
}
internal static string NormalizeAlgorithm(string? algorithmId)
=> string.IsNullOrWhiteSpace(algorithmId)
? HashAlgorithms.Sha256
: algorithmId.Trim().ToUpperInvariant();
}
/// <summary>
/// Restores the previous AuthoritySecretHasher configuration when disposed.
/// </summary>

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Security.Claims;
using System.Threading;
@@ -240,7 +241,7 @@ public sealed record AuthorityPluginHealthResult
=> new(AuthorityPluginHealthStatus.Unavailable, message, details ?? EmptyDetails);
private static readonly IReadOnlyDictionary<string, string?> EmptyDetails =
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
new ReadOnlyDictionary<string, string?>(new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase));
}
/// <summary>

View File

@@ -5,7 +5,7 @@
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateMSBuildEditorConfigFile>false</GenerateMSBuildEditorConfigFile>
</PropertyGroup>
<ItemGroup>

View File

@@ -1,27 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>preview</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<GenerateMSBuildEditorConfigFile>false</GenerateMSBuildEditorConfigFile>
</PropertyGroup>
<ItemGroup>
<EditorConfigFiles Remove="$(IntermediateOutputPath)$(MSBuildProjectName).GeneratedMSBuildEditorConfig.editorconfig" />
</ItemGroup>
<Target Name="EnsureGeneratedEditorConfig" BeforeTargets="ResolveEditorConfigFiles">
<WriteLinesToFile File="$(IntermediateOutputPath)$(MSBuildProjectName).GeneratedMSBuildEditorConfig.editorconfig" Lines="" Overwrite="false" />
</Target>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
</ItemGroup>
</Project>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0098-M | DONE | Maintainability audit for StellaOps.Authority.Plugins.Abstractions. |
| AUDIT-0098-T | DONE | Test coverage audit for StellaOps.Authority.Plugins.Abstractions. |
| AUDIT-0098-A | TODO | Pending approval for changes. |
| AUDIT-0098-A | DONE | Pending approval for changes. |

View File

@@ -14,6 +14,7 @@ using StellaOps.Authority.Vulnerability.Workflow;
using StellaOps.Configuration;
using StellaOps.Cryptography;
using Xunit;
using StellaOpsCryptoProvider = StellaOps.Cryptography.ICryptoProvider;
namespace StellaOps.Authority.Tests.Vulnerability;
@@ -37,7 +38,7 @@ public sealed class VulnTokenIssuerTests
var principal = BuildPrincipal();
var request = new VulnWorkflowAntiForgeryIssueRequest
{
Actions = new[] { "assign" }
Actions = new List<string> { "assign" }
};
var result = await issuer.IssueAsync(principal, request, CancellationToken.None);
@@ -64,7 +65,7 @@ public sealed class VulnTokenIssuerTests
var principal = BuildPrincipal();
var request = new VulnWorkflowAntiForgeryIssueRequest
{
Actions = new[] { "assign" },
Actions = new List<string> { "assign" },
Nonce = "short"
};
@@ -186,15 +187,15 @@ public sealed class VulnTokenIssuerTests
private sealed class TestCryptoProviderRegistry : ICryptoProviderRegistry
{
public IReadOnlyCollection<ICryptoProvider> Providers => Array.Empty<ICryptoProvider>();
public IReadOnlyCollection<StellaOpsCryptoProvider> Providers => Array.Empty<StellaOpsCryptoProvider>();
public bool TryResolve(string preferredProvider, out ICryptoProvider provider)
public bool TryResolve(string preferredProvider, out StellaOpsCryptoProvider provider)
{
provider = null!;
return false;
}
public ICryptoProvider ResolveOrThrow(CryptoCapability capability, string algorithmId)
public StellaOpsCryptoProvider ResolveOrThrow(CryptoCapability capability, string algorithmId)
=> throw new NotSupportedException();
public CryptoSignerResolution ResolveSigner(

View File

@@ -52,7 +52,7 @@ internal sealed class AuthorityAirgapAuditService : IAuthorityAirgapAuditService
properties.Add(new AuthorityAirgapAuditPropertyDocument
{
Name = name,
Value = value
Value = value ?? string.Empty
});
}
@@ -87,7 +87,7 @@ internal sealed class AuthorityAirgapAuditService : IAuthorityAirgapAuditService
IReadOnlyDictionary<string, string?> metadata = document.Properties is { Count: > 0 }
? document.Properties.ToDictionary(
property => property.Name,
property => property.Value,
property => (string?)property.Value,
StringComparer.Ordinal)
: ImmutableDictionary<string, string?>.Empty;

View File

@@ -184,7 +184,7 @@ internal sealed class AuthorityAuditSink : IAuthEventSink
properties.Add(new AuthorityLoginAttemptPropertyDocument
{
Name = name,
Value = value.Value,
Value = value.Value ?? string.Empty,
Classification = NormalizeClassification(value.Classification)
});
}

View File

@@ -56,7 +56,6 @@ using System.Text;
using StellaOps.Authority.Signing;
using StellaOps.Cryptography;
using StellaOps.Cryptography.Kms;
using StellaOps.Authority.Persistence.Documents;
using StellaOps.Authority.Security;
using StellaOps.Authority.OpenApi;
using StellaOps.Auth.Abstractions;

View File

@@ -313,7 +313,7 @@ internal sealed class PostgresTokenStore : IAuthorityTokenStore, IAuthorityRefre
private static AuthorityTokenDocument Map(OidcTokenEntity entity)
{
var properties = entity.Properties.ToDictionary(kv => kv.Key, kv => kv.Value, StringComparer.OrdinalIgnoreCase);
var properties = entity.Properties.ToDictionary(kv => kv.Key, kv => (string?)kv.Value, StringComparer.OrdinalIgnoreCase);
var scope = properties.TryGetValue("scope", out var scopeRaw) && !string.IsNullOrWhiteSpace(scopeRaw)
? scopeRaw.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList()
: new List<string>();
@@ -364,7 +364,7 @@ internal sealed class PostgresTokenStore : IAuthorityTokenStore, IAuthorityRefre
Scope = scope,
ActorChain = actorChain,
Devices = devices,
Status = Get(properties, "status", "valid"),
Status = Get(properties, "status", "valid") ?? "valid",
Tenant = Get(properties, "tenant", null),
Project = Get(properties, "project", null),
SenderConstraint = Get(properties, "sender_constraint", null),
@@ -492,7 +492,7 @@ internal sealed class PostgresTokenStore : IAuthorityTokenStore, IAuthorityRefre
return properties;
}
private static string? Get(IReadOnlyDictionary<string, string> properties, string key, string? defaultValue)
private static string? Get(IReadOnlyDictionary<string, string?> properties, string key, string? defaultValue)
{
if (properties.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
{
@@ -502,9 +502,11 @@ internal sealed class PostgresTokenStore : IAuthorityTokenStore, IAuthorityRefre
return defaultValue;
}
private static DateTimeOffset? ParseDate(IReadOnlyDictionary<string, string> properties, string key)
private static DateTimeOffset? ParseDate(IReadOnlyDictionary<string, string?> properties, string key)
{
if (properties.TryGetValue(key, out var value) && DateTimeOffset.TryParse(value, out var parsed))
if (properties.TryGetValue(key, out var value)
&& !string.IsNullOrWhiteSpace(value)
&& DateTimeOffset.TryParse(value, out var parsed))
{
return parsed;
}
@@ -512,11 +514,11 @@ internal sealed class PostgresTokenStore : IAuthorityTokenStore, IAuthorityRefre
return null;
}
private static IReadOnlyDictionary<string, string?>? ExtractRevokedMetadata(IReadOnlyDictionary<string, string> properties)
private static IReadOnlyDictionary<string, string?>? ExtractRevokedMetadata(IReadOnlyDictionary<string, string?> properties)
{
var metadata = properties
.Where(kvp => kvp.Key.StartsWith("revoked_meta_", StringComparison.OrdinalIgnoreCase))
.ToDictionary(kvp => kvp.Key["revoked_meta_".Length..], kvp => (string?)kvp.Value, StringComparer.OrdinalIgnoreCase);
.ToDictionary(kvp => kvp.Key["revoked_meta_".Length..], kvp => kvp.Value, StringComparer.OrdinalIgnoreCase);
return metadata.Count == 0 ? null : metadata;
}

View File

@@ -23,9 +23,7 @@ public static class AuthorityPersistenceExtensions
IConfiguration configuration,
string sectionName = "Postgres:Authority")
{
services.Configure<PostgresOptions>(sectionName, configuration.GetSection(sectionName));
RegisterAuthorityServices(services);
return services;
return services.AddAuthorityPostgresStorage(configuration, sectionName);
}
/// <summary>
@@ -38,54 +36,6 @@ public static class AuthorityPersistenceExtensions
this IServiceCollection services,
Action<PostgresOptions> configureOptions)
{
services.Configure(configureOptions);
RegisterAuthorityServices(services);
return services;
}
private static void RegisterAuthorityServices(IServiceCollection services)
{
services.AddSingleton<AuthorityDataSource>();
services.AddScoped<TenantRepository>();
services.AddScoped<UserRepository>();
services.AddScoped<RoleRepository>();
services.AddScoped<PermissionRepository>();
services.AddScoped<TokenRepository>();
services.AddScoped<RefreshTokenRepository>();
services.AddScoped<ApiKeyRepository>();
services.AddScoped<SessionRepository>();
services.AddScoped<AuditRepository>();
// Default interface bindings
services.AddScoped<ITenantRepository>(sp => sp.GetRequiredService<TenantRepository>());
services.AddScoped<IUserRepository>(sp => sp.GetRequiredService<UserRepository>());
services.AddScoped<IRoleRepository>(sp => sp.GetRequiredService<RoleRepository>());
services.AddScoped<IPermissionRepository>(sp => sp.GetRequiredService<PermissionRepository>());
services.AddScoped<IApiKeyRepository>(sp => sp.GetRequiredService<ApiKeyRepository>());
services.AddScoped<ISessionRepository>(sp => sp.GetRequiredService<SessionRepository>());
services.AddScoped<IAuditRepository>(sp => sp.GetRequiredService<AuditRepository>());
services.AddScoped<ITokenRepository>(sp => sp.GetRequiredService<TokenRepository>());
services.AddScoped<IRefreshTokenRepository>(sp => sp.GetRequiredService<RefreshTokenRepository>());
// Additional stores (PostgreSQL-backed)
services.AddScoped<BootstrapInviteRepository>();
services.AddScoped<ServiceAccountRepository>();
services.AddScoped<ClientRepository>();
services.AddScoped<RevocationRepository>();
services.AddScoped<LoginAttemptRepository>();
services.AddScoped<OidcTokenRepository>();
services.AddScoped<AirgapAuditRepository>();
services.AddScoped<OfflineKitAuditRepository>();
services.AddScoped<IOfflineKitAuditRepository>(sp => sp.GetRequiredService<OfflineKitAuditRepository>());
services.AddScoped<IOfflineKitAuditEmitter, OfflineKitAuditEmitter>();
services.AddScoped<RevocationExportStateRepository>();
services.AddScoped<IBootstrapInviteRepository>(sp => sp.GetRequiredService<BootstrapInviteRepository>());
services.AddScoped<IServiceAccountRepository>(sp => sp.GetRequiredService<ServiceAccountRepository>());
services.AddScoped<IClientRepository>(sp => sp.GetRequiredService<ClientRepository>());
services.AddScoped<IRevocationRepository>(sp => sp.GetRequiredService<RevocationRepository>());
services.AddScoped<ILoginAttemptRepository>(sp => sp.GetRequiredService<LoginAttemptRepository>());
services.AddScoped<IOidcTokenRepository>(sp => sp.GetRequiredService<OidcTokenRepository>());
services.AddScoped<IAirgapAuditRepository>(sp => sp.GetRequiredService<AirgapAuditRepository>());
return services.AddAuthorityPostgresStorage(configureOptions);
}
}

View File

@@ -5,7 +5,7 @@ namespace StellaOps.Authority.Persistence.Documents;
/// </summary>
public sealed class AuthorityBootstrapInviteDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Id { get; set; } = string.Empty;
public string Token { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public string? Provider { get; set; }
@@ -44,7 +44,7 @@ public sealed record BootstrapInviteReservationResult(BootstrapInviteReservation
/// </summary>
public sealed class AuthorityServiceAccountDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Id { get; set; } = string.Empty;
public string AccountId { get; set; } = string.Empty;
public string Tenant { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
@@ -62,7 +62,7 @@ public sealed class AuthorityServiceAccountDocument
/// </summary>
public sealed class AuthorityClientDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Id { get; set; } = string.Empty;
public string ClientId { get; set; } = string.Empty;
public string? ClientSecret { get; set; }
public string? SecretHash { get; set; }
@@ -91,7 +91,7 @@ public sealed class AuthorityClientDocument
/// </summary>
public sealed class AuthorityRevocationDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Id { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public string RevocationId { get; set; } = string.Empty;
public string SubjectId { get; set; } = string.Empty;
@@ -113,7 +113,7 @@ public sealed class AuthorityRevocationDocument
/// </summary>
public sealed class AuthorityLoginAttemptDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Id { get; set; } = string.Empty;
public string? CorrelationId { get; set; }
public string? SubjectId { get; set; }
public string? Username { get; set; }
@@ -148,7 +148,7 @@ public sealed class AuthorityLoginAttemptPropertyDocument
/// </summary>
public sealed class AuthorityTokenDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Id { get; set; } = string.Empty;
public string TokenId { get; set; } = string.Empty;
public string? SubjectId { get; set; }
public string? ClientId { get; set; }
@@ -191,7 +191,7 @@ public sealed class AuthorityTokenDocument
/// </summary>
public sealed class AuthorityRefreshTokenDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Id { get; set; } = string.Empty;
public string TokenId { get; set; } = string.Empty;
public string? SubjectId { get; set; }
public string? ClientId { get; set; }
@@ -207,7 +207,7 @@ public sealed class AuthorityRefreshTokenDocument
/// </summary>
public sealed class AuthorityAirgapAuditDocument
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Id { get; set; } = string.Empty;
public string? Tenant { get; set; }
public string? SubjectId { get; set; }
public string? Username { get; set; }

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Authority.InMemoryDriver;
using StellaOps.Authority.Persistence.InMemory.Initialization;
using StellaOps.Authority.Persistence.Sessions;
@@ -46,6 +47,9 @@ public static class ServiceCollectionExtensions
// Register null session accessor
services.AddSingleton<IAuthoritySessionAccessor, NullAuthoritySessionAccessor>();
services.TryAddSingleton(TimeProvider.System);
services.TryAddSingleton<IAuthorityInMemoryIdGenerator, GuidAuthorityInMemoryIdGenerator>();
// Register in-memory shims for compatibility
var inMemoryClient = new InMemoryClient();
var inMemoryDatabase = inMemoryClient.GetDatabase(options.DatabaseName);
@@ -54,14 +58,22 @@ public static class ServiceCollectionExtensions
// Register in-memory store implementations
// These should be replaced by Postgres-backed implementations over time
services.AddSingleton<IAuthorityBootstrapInviteStore, InMemoryBootstrapInviteStore>();
services.AddSingleton<IAuthorityServiceAccountStore, InMemoryServiceAccountStore>();
services.AddSingleton<IAuthorityClientStore, InMemoryClientStore>();
services.AddSingleton<IAuthorityRevocationStore, InMemoryRevocationStore>();
services.AddSingleton<IAuthorityLoginAttemptStore, InMemoryLoginAttemptStore>();
services.AddSingleton<IAuthorityTokenStore, InMemoryTokenStore>();
services.AddSingleton<IAuthorityRefreshTokenStore, InMemoryRefreshTokenStore>();
services.AddSingleton<IAuthorityAirgapAuditStore, InMemoryAirgapAuditStore>();
services.AddSingleton<IAuthorityBootstrapInviteStore>(sp =>
new InMemoryBootstrapInviteStore(sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<IAuthorityInMemoryIdGenerator>()));
services.AddSingleton<IAuthorityServiceAccountStore>(sp =>
new InMemoryServiceAccountStore(sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<IAuthorityInMemoryIdGenerator>()));
services.AddSingleton<IAuthorityClientStore>(sp =>
new InMemoryClientStore(sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<IAuthorityInMemoryIdGenerator>()));
services.AddSingleton<IAuthorityRevocationStore>(sp =>
new InMemoryRevocationStore(sp.GetRequiredService<IAuthorityInMemoryIdGenerator>()));
services.AddSingleton<IAuthorityLoginAttemptStore>(sp =>
new InMemoryLoginAttemptStore(sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<IAuthorityInMemoryIdGenerator>()));
services.AddSingleton<IAuthorityTokenStore>(sp =>
new InMemoryTokenStore(sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<IAuthorityInMemoryIdGenerator>()));
services.AddSingleton<IAuthorityRefreshTokenStore>(sp =>
new InMemoryRefreshTokenStore(sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<IAuthorityInMemoryIdGenerator>()));
services.AddSingleton<IAuthorityAirgapAuditStore>(sp =>
new InMemoryAirgapAuditStore(sp.GetRequiredService<TimeProvider>(), sp.GetRequiredService<IAuthorityInMemoryIdGenerator>()));
services.AddSingleton<IAuthorityRevocationExportStateStore, InMemoryRevocationExportStateStore>();
}
}

View File

@@ -0,0 +1,11 @@
namespace StellaOps.Authority.Persistence.InMemory.Stores;
public interface IAuthorityInMemoryIdGenerator
{
string NextId();
}
public sealed class GuidAuthorityInMemoryIdGenerator : IAuthorityInMemoryIdGenerator
{
public string NextId() => Guid.NewGuid().ToString("N");
}

View File

@@ -11,6 +11,19 @@ namespace StellaOps.Authority.Persistence.InMemory.Stores;
public sealed class InMemoryBootstrapInviteStore : IAuthorityBootstrapInviteStore
{
private readonly ConcurrentDictionary<string, AuthorityBootstrapInviteDocument> _invites = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
private readonly IAuthorityInMemoryIdGenerator _idGenerator;
public InMemoryBootstrapInviteStore()
: this(TimeProvider.System, new GuidAuthorityInMemoryIdGenerator())
{
}
public InMemoryBootstrapInviteStore(TimeProvider timeProvider, IAuthorityInMemoryIdGenerator idGenerator)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
}
public ValueTask<AuthorityBootstrapInviteDocument?> FindByTokenAsync(string token, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
@@ -20,7 +33,12 @@ public sealed class InMemoryBootstrapInviteStore : IAuthorityBootstrapInviteStor
public ValueTask<AuthorityBootstrapInviteDocument> CreateAsync(AuthorityBootstrapInviteDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
document.CreatedAt = document.CreatedAt == default ? DateTimeOffset.UtcNow : document.CreatedAt;
if (string.IsNullOrWhiteSpace(document.Id))
{
document.Id = _idGenerator.NextId();
}
document.CreatedAt = document.CreatedAt == default ? _timeProvider.GetUtcNow() : document.CreatedAt;
document.IssuedAt = document.IssuedAt == default ? document.CreatedAt : document.IssuedAt;
document.Status = AuthorityBootstrapInviteStatuses.Pending;
_invites[document.Token] = document;
@@ -109,6 +127,19 @@ public sealed class InMemoryBootstrapInviteStore : IAuthorityBootstrapInviteStor
public sealed class InMemoryServiceAccountStore : IAuthorityServiceAccountStore
{
private readonly ConcurrentDictionary<string, AuthorityServiceAccountDocument> _accounts = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
private readonly IAuthorityInMemoryIdGenerator _idGenerator;
public InMemoryServiceAccountStore()
: this(TimeProvider.System, new GuidAuthorityInMemoryIdGenerator())
{
}
public InMemoryServiceAccountStore(TimeProvider timeProvider, IAuthorityInMemoryIdGenerator idGenerator)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
}
public ValueTask<AuthorityServiceAccountDocument?> FindByAccountIdAsync(string accountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
@@ -132,7 +163,17 @@ public sealed class InMemoryServiceAccountStore : IAuthorityServiceAccountStore
public ValueTask UpsertAsync(AuthorityServiceAccountDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
document.UpdatedAt = DateTimeOffset.UtcNow;
if (string.IsNullOrWhiteSpace(document.Id))
{
document.Id = _idGenerator.NextId();
}
if (document.CreatedAt == default)
{
document.CreatedAt = _timeProvider.GetUtcNow();
}
document.UpdatedAt = _timeProvider.GetUtcNow();
_accounts[document.AccountId] = document;
return ValueTask.CompletedTask;
}
@@ -149,6 +190,19 @@ public sealed class InMemoryServiceAccountStore : IAuthorityServiceAccountStore
public sealed class InMemoryClientStore : IAuthorityClientStore
{
private readonly ConcurrentDictionary<string, AuthorityClientDocument> _clients = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
private readonly IAuthorityInMemoryIdGenerator _idGenerator;
public InMemoryClientStore()
: this(TimeProvider.System, new GuidAuthorityInMemoryIdGenerator())
{
}
public InMemoryClientStore(TimeProvider timeProvider, IAuthorityInMemoryIdGenerator idGenerator)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
}
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
@@ -158,7 +212,17 @@ public sealed class InMemoryClientStore : IAuthorityClientStore
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
document.UpdatedAt = DateTimeOffset.UtcNow;
if (string.IsNullOrWhiteSpace(document.Id))
{
document.Id = _idGenerator.NextId();
}
if (document.CreatedAt == default)
{
document.CreatedAt = _timeProvider.GetUtcNow();
}
document.UpdatedAt = _timeProvider.GetUtcNow();
_clients[document.ClientId] = document;
return ValueTask.CompletedTask;
}
@@ -175,9 +239,25 @@ public sealed class InMemoryClientStore : IAuthorityClientStore
public sealed class InMemoryRevocationStore : IAuthorityRevocationStore
{
private readonly ConcurrentDictionary<string, AuthorityRevocationDocument> _revocations = new(StringComparer.OrdinalIgnoreCase);
private readonly IAuthorityInMemoryIdGenerator _idGenerator;
public InMemoryRevocationStore()
: this(new GuidAuthorityInMemoryIdGenerator())
{
}
public InMemoryRevocationStore(IAuthorityInMemoryIdGenerator idGenerator)
{
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
}
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(document.Id))
{
document.Id = _idGenerator.NextId();
}
var key = $"{document.Category}:{document.RevocationId}";
_revocations[key] = document;
return ValueTask.CompletedTask;
@@ -205,9 +285,32 @@ public sealed class InMemoryRevocationStore : IAuthorityRevocationStore
public sealed class InMemoryLoginAttemptStore : IAuthorityLoginAttemptStore
{
private readonly ConcurrentBag<AuthorityLoginAttemptDocument> _attempts = new();
private readonly TimeProvider _timeProvider;
private readonly IAuthorityInMemoryIdGenerator _idGenerator;
public InMemoryLoginAttemptStore()
: this(TimeProvider.System, new GuidAuthorityInMemoryIdGenerator())
{
}
public InMemoryLoginAttemptStore(TimeProvider timeProvider, IAuthorityInMemoryIdGenerator idGenerator)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
}
public ValueTask InsertAsync(AuthorityLoginAttemptDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(document.Id))
{
document.Id = _idGenerator.NextId();
}
if (document.OccurredAt == default)
{
document.OccurredAt = _timeProvider.GetUtcNow();
}
_attempts.Add(document);
return ValueTask.CompletedTask;
}
@@ -229,6 +332,19 @@ public sealed class InMemoryLoginAttemptStore : IAuthorityLoginAttemptStore
public sealed class InMemoryTokenStore : IAuthorityTokenStore
{
private readonly ConcurrentDictionary<string, AuthorityTokenDocument> _tokens = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
private readonly IAuthorityInMemoryIdGenerator _idGenerator;
public InMemoryTokenStore()
: this(TimeProvider.System, new GuidAuthorityInMemoryIdGenerator())
{
}
public InMemoryTokenStore(TimeProvider timeProvider, IAuthorityInMemoryIdGenerator idGenerator)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
}
public ValueTask<AuthorityTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
@@ -279,7 +395,7 @@ public sealed class InMemoryTokenStore : IAuthorityTokenStore
public ValueTask<long> CountActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var count = _tokens.Values
.Where(t => string.Equals(t.Tenant, tenant, StringComparison.Ordinal))
.Where(t => string.IsNullOrWhiteSpace(serviceAccountId) || string.Equals(t.ServiceAccountId, serviceAccountId, StringComparison.Ordinal))
@@ -292,7 +408,7 @@ public sealed class InMemoryTokenStore : IAuthorityTokenStore
public ValueTask<IReadOnlyList<AuthorityTokenDocument>> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var items = _tokens.Values
.Where(t => string.Equals(t.Tenant, tenant, StringComparison.Ordinal))
.Where(t => string.IsNullOrWhiteSpace(serviceAccountId) || string.Equals(t.ServiceAccountId, serviceAccountId, StringComparison.Ordinal))
@@ -308,6 +424,16 @@ public sealed class InMemoryTokenStore : IAuthorityTokenStore
public ValueTask UpsertAsync(AuthorityTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(document.Id))
{
document.Id = _idGenerator.NextId();
}
if (document.CreatedAt == default)
{
document.CreatedAt = _timeProvider.GetUtcNow();
}
_tokens[document.TokenId] = document;
return ValueTask.CompletedTask;
}
@@ -329,7 +455,7 @@ public sealed class InMemoryTokenStore : IAuthorityTokenStore
if (_tokens.TryGetValue(tokenId, out var doc))
{
doc.Status = "revoked";
doc.RevokedAt = DateTimeOffset.UtcNow;
doc.RevokedAt = _timeProvider.GetUtcNow();
return ValueTask.FromResult(true);
}
@@ -338,7 +464,7 @@ public sealed class InMemoryTokenStore : IAuthorityTokenStore
public ValueTask<int> RevokeBySubjectAsync(string subjectId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var revoked = 0;
foreach (var token in _tokens.Values.Where(t => t.SubjectId == subjectId))
{
@@ -352,7 +478,7 @@ public sealed class InMemoryTokenStore : IAuthorityTokenStore
public ValueTask<int> RevokeByClientAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var now = DateTimeOffset.UtcNow;
var now = _timeProvider.GetUtcNow();
var revoked = 0;
foreach (var token in _tokens.Values.Where(t => t.ClientId == clientId))
{
@@ -393,6 +519,19 @@ public sealed class InMemoryTokenStore : IAuthorityTokenStore
public sealed class InMemoryRefreshTokenStore : IAuthorityRefreshTokenStore
{
private readonly ConcurrentDictionary<string, AuthorityRefreshTokenDocument> _tokens = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
private readonly IAuthorityInMemoryIdGenerator _idGenerator;
public InMemoryRefreshTokenStore()
: this(TimeProvider.System, new GuidAuthorityInMemoryIdGenerator())
{
}
public InMemoryRefreshTokenStore(TimeProvider timeProvider, IAuthorityInMemoryIdGenerator idGenerator)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
}
public ValueTask<AuthorityRefreshTokenDocument?> FindByTokenIdAsync(string tokenId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
@@ -408,6 +547,16 @@ public sealed class InMemoryRefreshTokenStore : IAuthorityRefreshTokenStore
public ValueTask UpsertAsync(AuthorityRefreshTokenDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(document.Id))
{
document.Id = _idGenerator.NextId();
}
if (document.CreatedAt == default)
{
document.CreatedAt = _timeProvider.GetUtcNow();
}
_tokens[document.TokenId] = document;
return ValueTask.CompletedTask;
}
@@ -416,7 +565,7 @@ public sealed class InMemoryRefreshTokenStore : IAuthorityRefreshTokenStore
{
if (_tokens.TryGetValue(tokenId, out var doc))
{
doc.ConsumedAt = DateTimeOffset.UtcNow;
doc.ConsumedAt = _timeProvider.GetUtcNow();
return ValueTask.FromResult(true);
}
return ValueTask.FromResult(false);
@@ -439,9 +588,32 @@ public sealed class InMemoryRefreshTokenStore : IAuthorityRefreshTokenStore
public sealed class InMemoryAirgapAuditStore : IAuthorityAirgapAuditStore
{
private readonly ConcurrentBag<AuthorityAirgapAuditDocument> _entries = new();
private readonly TimeProvider _timeProvider;
private readonly IAuthorityInMemoryIdGenerator _idGenerator;
public InMemoryAirgapAuditStore()
: this(TimeProvider.System, new GuidAuthorityInMemoryIdGenerator())
{
}
public InMemoryAirgapAuditStore(TimeProvider timeProvider, IAuthorityInMemoryIdGenerator idGenerator)
{
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
_idGenerator = idGenerator ?? throw new ArgumentNullException(nameof(idGenerator));
}
public ValueTask InsertAsync(AuthorityAirgapAuditDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
if (string.IsNullOrWhiteSpace(document.Id))
{
document.Id = _idGenerator.NextId();
}
if (document.OccurredAt == default)
{
document.OccurredAt = _timeProvider.GetUtcNow();
}
_entries.Add(document);
return ValueTask.CompletedTask;
}

View File

@@ -29,11 +29,21 @@ public sealed class AuthorityDataSource : DataSourceBase
private static PostgresOptions CreateOptions(PostgresOptions baseOptions)
{
// Use default schema if not specified
if (string.IsNullOrWhiteSpace(baseOptions.SchemaName))
var schemaName = string.IsNullOrWhiteSpace(baseOptions.SchemaName)
? DefaultSchemaName
: baseOptions.SchemaName;
return new PostgresOptions
{
baseOptions.SchemaName = DefaultSchemaName;
}
return baseOptions;
ConnectionString = baseOptions.ConnectionString,
CommandTimeoutSeconds = baseOptions.CommandTimeoutSeconds,
MaxPoolSize = baseOptions.MaxPoolSize,
MinPoolSize = baseOptions.MinPoolSize,
ConnectionIdleLifetimeSeconds = baseOptions.ConnectionIdleLifetimeSeconds,
Pooling = baseOptions.Pooling,
SchemaName = schemaName,
AutoMigrate = baseOptions.AutoMigrate,
MigrationsPath = baseOptions.MigrationsPath,
};
}
}

View File

@@ -137,16 +137,16 @@ public sealed class ClientRepository : RepositoryBase<AuthorityDataSource>, ICli
UpdatedAt = reader.GetFieldValue<DateTimeOffset>(20)
};
private static IReadOnlyDictionary<string, string> DeserializeDictionary(NpgsqlDataReader reader, int ordinal)
private static IReadOnlyDictionary<string, string?> DeserializeDictionary(NpgsqlDataReader reader, int ordinal)
{
if (reader.IsDBNull(ordinal))
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
return new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
var json = reader.GetString(ordinal);
return JsonSerializer.Deserialize<Dictionary<string, string>>(json, SerializerOptions) ??
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
return JsonSerializer.Deserialize<Dictionary<string, string?>>(json, SerializerOptions) ??
new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
}
private static T? Deserialize<T>(NpgsqlDataReader reader, int ordinal)

View File

@@ -22,6 +22,11 @@ public interface IUserRepository
/// </summary>
Task<UserEntity?> GetByUsernameAsync(string tenantId, string username, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a user by subject identifier stored in metadata.
/// </summary>
Task<UserEntity?> GetBySubjectIdAsync(string tenantId, string subjectId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets a user by email.
/// </summary>

View File

@@ -163,7 +163,7 @@ public sealed class OidcTokenRepository : RepositoryBase<AuthorityDataSource>, I
},
cancellationToken: cancellationToken).ConfigureAwait(false);
return count ?? 0;
return count;
}
public async Task<IReadOnlyList<OidcTokenEntity>> ListActiveDelegationTokensAsync(string tenant, string? serviceAccountId, DateTimeOffset now, int limit, CancellationToken cancellationToken = default)

View File

@@ -98,6 +98,31 @@ public sealed class UserRepository : RepositoryBase<AuthorityDataSource>, IUserR
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<UserEntity?> GetBySubjectIdAsync(string tenantId, string subjectId, CancellationToken cancellationToken = default)
{
const string sql = """
SELECT id, tenant_id, username, email, display_name, password_hash, password_salt,
enabled, email_verified, mfa_enabled, mfa_secret, mfa_backup_codes,
failed_login_attempts, locked_until, last_login_at, password_changed_at,
settings::text, metadata::text, created_at, updated_at, created_by
FROM authority.users
WHERE tenant_id = @tenant_id AND metadata->>'subjectId' = @subject_id
LIMIT 1
""";
return await QuerySingleOrDefaultAsync(
tenantId,
sql,
cmd =>
{
AddParameter(cmd, "tenant_id", tenantId);
AddParameter(cmd, "subject_id", subjectId);
},
MapUser,
cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task<UserEntity?> GetByEmailAsync(string tenantId, string email, CancellationToken cancellationToken = default)
{

View File

@@ -1,5 +1,6 @@
using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Serialization;
using Npgsql;
using StellaOps.Authority.Core.Verdicts;
@@ -10,14 +11,16 @@ namespace StellaOps.Authority.Persistence.Postgres;
/// </summary>
public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
{
private readonly NpgsqlDataSource _dataSource;
private readonly AuthorityDataSource _dataSource;
private static readonly JsonSerializerOptions s_jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) },
};
public PostgresVerdictManifestStore(NpgsqlDataSource dataSource)
public PostgresVerdictManifestStore(AuthorityDataSource dataSource)
{
_dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
}
@@ -27,7 +30,7 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
ArgumentNullException.ThrowIfNull(manifest);
const string sql = """
INSERT INTO authority.verdict_manifests (
INSERT INTO verdict_manifests (
manifest_id, tenant, asset_digest, vulnerability_id,
inputs_json, status, confidence, result_json,
policy_hash, lattice_version, evaluated_at, manifest_digest,
@@ -51,8 +54,11 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
rekor_log_id = EXCLUDED.rekor_log_id
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
await using var conn = await _dataSource.OpenConnectionAsync(manifest.Tenant, "writer", ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn)
{
CommandTimeout = _dataSource.CommandTimeoutSeconds,
};
cmd.Parameters.AddWithValue("manifestId", manifest.ManifestId);
cmd.Parameters.AddWithValue("tenant", manifest.Tenant);
@@ -83,12 +89,15 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
inputs_json, status, confidence, result_json,
policy_hash, lattice_version, evaluated_at, manifest_digest,
signature_base64, rekor_log_id
FROM authority.verdict_manifests
FROM verdict_manifests
WHERE tenant = @tenant AND manifest_id = @manifestId
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
await using var conn = await _dataSource.OpenConnectionAsync(tenant, "reader", ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn)
{
CommandTimeout = _dataSource.CommandTimeoutSeconds,
};
cmd.Parameters.AddWithValue("tenant", tenant);
cmd.Parameters.AddWithValue("manifestId", manifestId);
@@ -118,7 +127,7 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
inputs_json, status, confidence, result_json,
policy_hash, lattice_version, evaluated_at, manifest_digest,
signature_base64, rekor_log_id
FROM authority.verdict_manifests
FROM verdict_manifests
WHERE tenant = @tenant
AND asset_digest = @assetDigest
AND vulnerability_id = @vulnerabilityId
@@ -136,8 +145,11 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
sql += " ORDER BY evaluated_at DESC LIMIT 1";
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
await using var conn = await _dataSource.OpenConnectionAsync(tenant, "reader", ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn)
{
CommandTimeout = _dataSource.CommandTimeoutSeconds,
};
cmd.Parameters.AddWithValue("tenant", tenant);
cmd.Parameters.AddWithValue("assetDigest", assetDigest);
cmd.Parameters.AddWithValue("vulnerabilityId", vulnerabilityId);
@@ -181,7 +193,7 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
inputs_json, status, confidence, result_json,
policy_hash, lattice_version, evaluated_at, manifest_digest,
signature_base64, rekor_log_id
FROM authority.verdict_manifests
FROM verdict_manifests
WHERE tenant = @tenant
AND policy_hash = @policyHash
AND lattice_version = @latticeVersion
@@ -189,8 +201,11 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
LIMIT @limit OFFSET @offset
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
await using var conn = await _dataSource.OpenConnectionAsync(tenant, "reader", ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn)
{
CommandTimeout = _dataSource.CommandTimeoutSeconds,
};
cmd.Parameters.AddWithValue("tenant", tenant);
cmd.Parameters.AddWithValue("policyHash", policyHash);
cmd.Parameters.AddWithValue("latticeVersion", latticeVersion);
@@ -235,14 +250,17 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
inputs_json, status, confidence, result_json,
policy_hash, lattice_version, evaluated_at, manifest_digest,
signature_base64, rekor_log_id
FROM authority.verdict_manifests
FROM verdict_manifests
WHERE tenant = @tenant AND asset_digest = @assetDigest
ORDER BY evaluated_at DESC, manifest_id
LIMIT @limit OFFSET @offset
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
await using var conn = await _dataSource.OpenConnectionAsync(tenant, "reader", ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn)
{
CommandTimeout = _dataSource.CommandTimeoutSeconds,
};
cmd.Parameters.AddWithValue("tenant", tenant);
cmd.Parameters.AddWithValue("assetDigest", assetDigest);
cmd.Parameters.AddWithValue("limit", limit + 1);
@@ -274,12 +292,15 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
ArgumentException.ThrowIfNullOrWhiteSpace(manifestId);
const string sql = """
DELETE FROM authority.verdict_manifests
DELETE FROM verdict_manifests
WHERE tenant = @tenant AND manifest_id = @manifestId
""";
await using var conn = await _dataSource.OpenConnectionAsync(ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn);
await using var conn = await _dataSource.OpenConnectionAsync(tenant, "writer", ct).ConfigureAwait(false);
await using var cmd = new NpgsqlCommand(sql, conn)
{
CommandTimeout = _dataSource.CommandTimeoutSeconds,
};
cmd.Parameters.AddWithValue("tenant", tenant);
cmd.Parameters.AddWithValue("manifestId", manifestId);
@@ -307,7 +328,7 @@ public sealed class PostgresVerdictManifestStore : IVerdictManifestStore
Result = result,
PolicyHash = reader.GetString(8),
LatticeVersion = reader.GetString(9),
EvaluatedAt = reader.GetDateTime(10),
EvaluatedAt = reader.GetFieldValue<DateTimeOffset>(10),
ManifestDigest = reader.GetString(11),
SignatureBase64 = reader.IsDBNull(12) ? null : reader.GetString(12),
RekorLogId = reader.IsDBNull(13) ? null : reader.GetString(13),

View File

@@ -6,7 +6,7 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Authority.Persistence</RootNamespace>
<AssemblyName>StellaOps.Authority.Persistence</AssemblyName>
<Description>Consolidated persistence layer for StellaOps Authority module (EF Core + Raw SQL)</Description>

View File

@@ -7,4 +7,4 @@ Source of truth: `docs/implplan/SPRINT_20251229_049_BE_csproj_audit_maint_tests.
| --- | --- | --- |
| AUDIT-0088-M | DONE | Maintainability audit for StellaOps.Authority.Persistence. |
| AUDIT-0088-T | DONE | Test coverage audit for StellaOps.Authority.Persistence. |
| AUDIT-0088-A | TODO | Pending approval for changes. |
| AUDIT-0088-A | DONE | Applied updates and tests. |

View File

@@ -12,7 +12,7 @@ public sealed class VerdictManifestBuilderTests
public void Build_CreatesValidManifest()
{
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T12:00:00Z"));
var builder = new VerdictManifestBuilder(() => "test-manifest-id", clock)
var builder = new VerdictManifestBuilder(() => "test-manifest-id")
.WithTenant("tenant-1")
.WithAsset("sha256:abc123", "CVE-2024-1234")
.WithInputs(
@@ -61,7 +61,7 @@ public sealed class VerdictManifestBuilderTests
VerdictManifest BuildManifest(int seed)
{
return new VerdictManifestBuilder(() => "fixed-id", TimeProvider.System)
return new VerdictManifestBuilder(() => "fixed-id")
.WithTenant("tenant")
.WithAsset("sha256:asset", "CVE-2024-0001")
.WithInputs(
@@ -106,7 +106,7 @@ public sealed class VerdictManifestBuilderTests
{
var clock = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
var manifestA = new VerdictManifestBuilder(() => "id", TimeProvider.System)
var manifestA = new VerdictManifestBuilder(() => "id")
.WithTenant("t")
.WithAsset("sha256:a", "CVE-1")
.WithInputs(
@@ -119,7 +119,7 @@ public sealed class VerdictManifestBuilderTests
.WithClock(clock)
.Build();
var manifestB = new VerdictManifestBuilder(() => "id", TimeProvider.System)
var manifestB = new VerdictManifestBuilder(() => "id")
.WithTenant("t")
.WithAsset("sha256:a", "CVE-1")
.WithInputs(
@@ -151,7 +151,7 @@ public sealed class VerdictManifestBuilderTests
public void Build_NormalizesVulnerabilityIdToUpperCase()
{
var clock = DateTimeOffset.Parse("2025-01-01T00:00:00Z");
var manifest = new VerdictManifestBuilder(() => "id", TimeProvider.System)
var manifest = new VerdictManifestBuilder(() => "id")
.WithTenant("t")
.WithAsset("sha256:a", "cve-2024-1234")
.WithInputs(

View File

@@ -0,0 +1,103 @@
using System;
using System.Collections.Generic;
using System.Threading;
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Authority.Persistence.Documents;
using StellaOps.Authority.Persistence.InMemory.Stores;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Authority.Persistence.Tests;
public sealed class InMemoryStoreTests
{
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task BootstrapInviteStore_AssignsIdAndTimestamps()
{
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-02T12:00:00Z"));
var idGenerator = new TestIdGenerator("invite-001");
var store = new InMemoryBootstrapInviteStore(clock, idGenerator);
var document = new AuthorityBootstrapInviteDocument
{
Token = "token-1",
Type = "bootstrap",
ExpiresAt = clock.GetUtcNow().AddHours(1),
};
var created = await store.CreateAsync(document, CancellationToken.None);
created.Id.Should().Be("invite-001");
created.CreatedAt.Should().Be(clock.GetUtcNow());
created.IssuedAt.Should().Be(clock.GetUtcNow());
created.Status.Should().Be(AuthorityBootstrapInviteStatuses.Pending);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ServiceAccountStore_UpsertUsesClockAndIdGenerator()
{
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-02-05T08:30:00Z"));
var idGenerator = new TestIdGenerator("svc-001");
var store = new InMemoryServiceAccountStore(clock, idGenerator);
var document = new AuthorityServiceAccountDocument
{
AccountId = "svc-1",
Tenant = "tenant-1",
DisplayName = "Service",
};
await store.UpsertAsync(document, CancellationToken.None);
var fetched = await store.FindByAccountIdAsync("svc-1", CancellationToken.None);
fetched.Should().NotBeNull();
fetched!.Id.Should().Be("svc-001");
fetched.CreatedAt.Should().Be(clock.GetUtcNow());
fetched.UpdatedAt.Should().Be(clock.GetUtcNow());
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task RefreshTokenStore_ConsumeUsesClock()
{
var clock = new FakeTimeProvider(DateTimeOffset.Parse("2025-03-01T09:15:00Z"));
var idGenerator = new TestIdGenerator("refresh-001");
var store = new InMemoryRefreshTokenStore(clock, idGenerator);
var document = new AuthorityRefreshTokenDocument
{
TokenId = "token-1",
SubjectId = "subject-1",
};
await store.UpsertAsync(document, CancellationToken.None);
var consumed = await store.ConsumeAsync(document.TokenId, CancellationToken.None);
consumed.Should().BeTrue();
var fetched = await store.FindByTokenIdAsync(document.TokenId, CancellationToken.None);
fetched!.ConsumedAt.Should().Be(clock.GetUtcNow());
}
private sealed class TestIdGenerator : IAuthorityInMemoryIdGenerator
{
private readonly Queue<string> _ids;
public TestIdGenerator(params string[] ids)
{
_ids = new Queue<string>(ids);
}
public string NextId()
{
if (_ids.Count == 0)
{
throw new InvalidOperationException("No more IDs available.");
}
return _ids.Dequeue();
}
}
}

View File

@@ -1,6 +1,7 @@
using StellaOps.Authority.Persistence.Postgres.Models;
using StellaOps.Authority.Persistence.Postgres.Repositories;
using System.Collections.Concurrent;
using System.Text.Json;
namespace StellaOps.Authority.Persistence.Tests.TestDoubles;
@@ -146,6 +147,9 @@ internal sealed class InMemoryUserRepository : IUserRepository
public Task<UserEntity?> GetByUsernameAsync(string tenantId, string username, CancellationToken cancellationToken = default)
=> Task.FromResult(_users.Values.FirstOrDefault(u => u.TenantId == tenantId && u.Username == username));
public Task<UserEntity?> GetBySubjectIdAsync(string tenantId, string subjectId, CancellationToken cancellationToken = default)
=> Task.FromResult(_users.Values.FirstOrDefault(u => u.TenantId == tenantId && MatchesSubject(u.Metadata, subjectId)));
public Task<UserEntity?> GetByEmailAsync(string tenantId, string email, CancellationToken cancellationToken = default)
=> Task.FromResult(_users.Values.FirstOrDefault(u => u.TenantId == tenantId && u.Email == email));
@@ -198,6 +202,34 @@ internal sealed class InMemoryUserRepository : IUserRepository
}
public IReadOnlyCollection<UserEntity> Snapshot() => _users.Values.ToList();
private static bool MatchesSubject(string? metadataJson, string subjectId)
{
if (string.IsNullOrWhiteSpace(metadataJson) || string.IsNullOrWhiteSpace(subjectId))
{
return false;
}
try
{
using var document = JsonDocument.Parse(metadataJson);
if (document.RootElement.ValueKind != JsonValueKind.Object)
{
return false;
}
if (document.RootElement.TryGetProperty("subjectId", out var subjectElement)
&& subjectElement.ValueKind == JsonValueKind.String)
{
return string.Equals(subjectElement.GetString(), subjectId, StringComparison.Ordinal);
}
}
catch
{
}
return false;
}
}
internal static class AuthorityCloneHelpers

View File

@@ -0,0 +1,135 @@
using System.Collections.Immutable;
using System.Threading;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Npgsql;
using StellaOps.Authority.Core.Verdicts;
using StellaOps.Authority.Persistence.Postgres;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.Authority.Persistence.Tests;
[Collection(AuthorityPostgresCollection.Name)]
public sealed class VerdictManifestStoreTests : IAsyncLifetime
{
private readonly AuthorityPostgresFixture _fixture;
private readonly AuthorityDataSource _dataSource;
private readonly PostgresVerdictManifestStore _store;
public VerdictManifestStoreTests(AuthorityPostgresFixture fixture)
{
_fixture = fixture;
var options = fixture.CreateOptions();
_dataSource = new AuthorityDataSource(Options.Create(options), NullLogger<AuthorityDataSource>.Instance);
_store = new PostgresVerdictManifestStore(_dataSource);
}
public async ValueTask InitializeAsync()
{
await _fixture.TruncateAllTablesAsync();
}
public ValueTask DisposeAsync() => _dataSource.DisposeAsync();
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StoreAndGetById_RoundTripsManifest()
{
var evaluatedAt = DateTimeOffset.Parse("2025-01-15T10:00:00Z");
var manifest = CreateManifest("tenant-1", "manifest-001", evaluatedAt, VexStatus.NotAffected);
await _store.StoreAsync(manifest);
var fetched = await _store.GetByIdAsync(manifest.Tenant, manifest.ManifestId);
fetched.Should().NotBeNull();
fetched!.ManifestId.Should().Be(manifest.ManifestId);
fetched.AssetDigest.Should().Be(manifest.AssetDigest);
fetched.VulnerabilityId.Should().Be(manifest.VulnerabilityId);
fetched.PolicyHash.Should().Be(manifest.PolicyHash);
fetched.LatticeVersion.Should().Be(manifest.LatticeVersion);
fetched.EvaluatedAt.Should().Be(evaluatedAt);
fetched.Result.Status.Should().Be(VexStatus.NotAffected);
fetched.Inputs.SbomDigests.Should().Contain("sbom:sha256:aaa");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task StoreAsync_WritesStringEnumJson()
{
var evaluatedAt = DateTimeOffset.Parse("2025-01-15T11:00:00Z");
var manifest = CreateManifest("tenant-2", "manifest-002", evaluatedAt, VexStatus.UnderInvestigation);
await _store.StoreAsync(manifest);
await using var conn = await _dataSource.OpenConnectionAsync(manifest.Tenant, "reader", CancellationToken.None);
await using var cmd = new NpgsqlCommand("""
SELECT result_json::text
FROM verdict_manifests
WHERE tenant = @tenant AND manifest_id = @manifestId
""", conn)
{
CommandTimeout = _dataSource.CommandTimeoutSeconds,
};
cmd.Parameters.AddWithValue("tenant", manifest.Tenant);
cmd.Parameters.AddWithValue("manifestId", manifest.ManifestId);
var json = (string?)await cmd.ExecuteScalarAsync();
json.Should().NotBeNull();
json.Should().Contain("\"status\":\"under_investigation\"");
}
private static VerdictManifest CreateManifest(string tenant, string manifestId, DateTimeOffset evaluatedAt, VexStatus status)
{
var inputs = new VerdictInputs
{
SbomDigests = ImmutableArray.Create("sbom:sha256:aaa"),
VulnFeedSnapshotIds = ImmutableArray.Create("feed:1"),
VexDocumentDigests = ImmutableArray.Create("vex:1"),
ReachabilityGraphIds = ImmutableArray.Create("graph:1"),
ClockCutoff = evaluatedAt.AddMinutes(-5),
};
var result = new VerdictResult
{
Status = status,
Confidence = 0.82,
Explanations = ImmutableArray.Create(new VerdictExplanation
{
SourceId = "source-1",
Reason = "policy-pass",
ProvenanceScore = 0.9,
CoverageScore = 0.8,
ReplayabilityScore = 0.95,
StrengthMultiplier = 1.0,
FreshnessMultiplier = 0.97,
ClaimScore = 0.88,
AssertedStatus = status,
Accepted = true,
}),
EvidenceRefs = ImmutableArray.Create("evidence-1"),
HasConflicts = false,
RequiresReplayProof = false,
};
var manifest = new VerdictManifest
{
ManifestId = manifestId,
Tenant = tenant,
AssetDigest = "sha256:asset-1",
VulnerabilityId = "CVE-2025-0001",
Inputs = inputs,
Result = result,
PolicyHash = "policy-hash-1",
LatticeVersion = "lattice-1",
EvaluatedAt = evaluatedAt,
ManifestDigest = string.Empty,
SignatureBase64 = null,
RekorLogId = null,
};
var digest = VerdictManifestSerializer.ComputeDigest(manifest);
return manifest with { ManifestDigest = digest };
}
}