up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -1,50 +1,50 @@
using System;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Represents the outcome of attempting to consume a DPoP nonce.
/// </summary>
public sealed class DpopNonceConsumeResult
{
private DpopNonceConsumeResult(DpopNonceConsumeStatus status, DateTimeOffset? issuedAt, DateTimeOffset? expiresAt)
{
Status = status;
IssuedAt = issuedAt;
ExpiresAt = expiresAt;
}
/// <summary>
/// Consumption status.
/// </summary>
public DpopNonceConsumeStatus Status { get; }
/// <summary>
/// Timestamp the nonce was originally issued (when available).
/// </summary>
public DateTimeOffset? IssuedAt { get; }
/// <summary>
/// Expiry timestamp for the nonce (when available).
/// </summary>
public DateTimeOffset? ExpiresAt { get; }
public static DpopNonceConsumeResult Success(DateTimeOffset issuedAt, DateTimeOffset expiresAt)
=> new(DpopNonceConsumeStatus.Success, issuedAt, expiresAt);
public static DpopNonceConsumeResult Expired(DateTimeOffset? issuedAt, DateTimeOffset expiresAt)
=> new(DpopNonceConsumeStatus.Expired, issuedAt, expiresAt);
public static DpopNonceConsumeResult NotFound()
=> new(DpopNonceConsumeStatus.NotFound, null, null);
}
/// <summary>
/// Known statuses for nonce consumption attempts.
/// </summary>
public enum DpopNonceConsumeStatus
{
Success,
Expired,
NotFound
}
using System;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Represents the outcome of attempting to consume a DPoP nonce.
/// </summary>
public sealed class DpopNonceConsumeResult
{
private DpopNonceConsumeResult(DpopNonceConsumeStatus status, DateTimeOffset? issuedAt, DateTimeOffset? expiresAt)
{
Status = status;
IssuedAt = issuedAt;
ExpiresAt = expiresAt;
}
/// <summary>
/// Consumption status.
/// </summary>
public DpopNonceConsumeStatus Status { get; }
/// <summary>
/// Timestamp the nonce was originally issued (when available).
/// </summary>
public DateTimeOffset? IssuedAt { get; }
/// <summary>
/// Expiry timestamp for the nonce (when available).
/// </summary>
public DateTimeOffset? ExpiresAt { get; }
public static DpopNonceConsumeResult Success(DateTimeOffset issuedAt, DateTimeOffset expiresAt)
=> new(DpopNonceConsumeStatus.Success, issuedAt, expiresAt);
public static DpopNonceConsumeResult Expired(DateTimeOffset? issuedAt, DateTimeOffset expiresAt)
=> new(DpopNonceConsumeStatus.Expired, issuedAt, expiresAt);
public static DpopNonceConsumeResult NotFound()
=> new(DpopNonceConsumeStatus.NotFound, null, null);
}
/// <summary>
/// Known statuses for nonce consumption attempts.
/// </summary>
public enum DpopNonceConsumeStatus
{
Success,
Expired,
NotFound
}

View File

@@ -1,56 +1,56 @@
using System;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Represents the result of issuing a DPoP nonce.
/// </summary>
public sealed class DpopNonceIssueResult
{
private DpopNonceIssueResult(DpopNonceIssueStatus status, string? nonce, DateTimeOffset? expiresAt, string? error)
{
Status = status;
Nonce = nonce;
ExpiresAt = expiresAt;
Error = error;
}
/// <summary>
/// Issue status.
/// </summary>
public DpopNonceIssueStatus Status { get; }
/// <summary>
/// Issued nonce when <see cref="Status"/> is <see cref="DpopNonceIssueStatus.Success"/>.
/// </summary>
public string? Nonce { get; }
/// <summary>
/// Expiry timestamp for the issued nonce (UTC).
/// </summary>
public DateTimeOffset? ExpiresAt { get; }
/// <summary>
/// Additional failure information, where applicable.
/// </summary>
public string? Error { get; }
public static DpopNonceIssueResult Success(string nonce, DateTimeOffset expiresAt)
=> new(DpopNonceIssueStatus.Success, nonce, expiresAt, null);
public static DpopNonceIssueResult RateLimited(string? error = null)
=> new(DpopNonceIssueStatus.RateLimited, null, null, error);
public static DpopNonceIssueResult Failure(string? error = null)
=> new(DpopNonceIssueStatus.Failure, null, null, error);
}
/// <summary>
/// Known statuses for nonce issuance.
/// </summary>
public enum DpopNonceIssueStatus
{
Success,
RateLimited,
Failure
}
using System;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Represents the result of issuing a DPoP nonce.
/// </summary>
public sealed class DpopNonceIssueResult
{
private DpopNonceIssueResult(DpopNonceIssueStatus status, string? nonce, DateTimeOffset? expiresAt, string? error)
{
Status = status;
Nonce = nonce;
ExpiresAt = expiresAt;
Error = error;
}
/// <summary>
/// Issue status.
/// </summary>
public DpopNonceIssueStatus Status { get; }
/// <summary>
/// Issued nonce when <see cref="Status"/> is <see cref="DpopNonceIssueStatus.Success"/>.
/// </summary>
public string? Nonce { get; }
/// <summary>
/// Expiry timestamp for the issued nonce (UTC).
/// </summary>
public DateTimeOffset? ExpiresAt { get; }
/// <summary>
/// Additional failure information, where applicable.
/// </summary>
public string? Error { get; }
public static DpopNonceIssueResult Success(string nonce, DateTimeOffset expiresAt)
=> new(DpopNonceIssueStatus.Success, nonce, expiresAt, null);
public static DpopNonceIssueResult RateLimited(string? error = null)
=> new(DpopNonceIssueStatus.RateLimited, null, null, error);
public static DpopNonceIssueResult Failure(string? error = null)
=> new(DpopNonceIssueStatus.Failure, null, null, error);
}
/// <summary>
/// Known statuses for nonce issuance.
/// </summary>
public enum DpopNonceIssueStatus
{
Success,
RateLimited,
Failure
}

View File

@@ -1,66 +1,66 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Auth.Security.Dpop;
internal static class DpopNonceUtilities
{
private static readonly char[] Base64Padding = { '=' };
internal static string GenerateNonce()
{
Span<byte> buffer = stackalloc byte[32];
RandomNumberGenerator.Fill(buffer);
return Convert.ToBase64String(buffer)
.TrimEnd(Base64Padding)
.Replace('+', '-')
.Replace('/', '_');
}
internal static byte[] ComputeNonceHash(string nonce)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
var bytes = Encoding.UTF8.GetBytes(nonce);
return SHA256.HashData(bytes);
}
internal static string EncodeHash(ReadOnlySpan<byte> hash)
=> Convert.ToHexString(hash);
internal static string ComputeStorageKey(string audience, string clientId, string keyThumbprint)
{
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
return string.Create(
"dpop-nonce:".Length + audience.Length + clientId.Length + keyThumbprint.Length + 2,
(audience.Trim(), clientId.Trim(), keyThumbprint.Trim()),
static (span, parts) =>
{
var index = 0;
const string Prefix = "dpop-nonce:";
Prefix.CopyTo(span);
index += Prefix.Length;
index = Append(span, index, parts.Item1);
span[index++] = ':';
index = Append(span, index, parts.Item2);
span[index++] = ':';
_ = Append(span, index, parts.Item3);
});
static int Append(Span<char> span, int index, string value)
{
if (value.Length == 0)
{
throw new ArgumentException("Value must not be empty after trimming.");
}
value.AsSpan().CopyTo(span[index..]);
return index + value.Length;
}
}
}
using System;
using System.Security.Cryptography;
using System.Text;
namespace StellaOps.Auth.Security.Dpop;
internal static class DpopNonceUtilities
{
private static readonly char[] Base64Padding = { '=' };
internal static string GenerateNonce()
{
Span<byte> buffer = stackalloc byte[32];
RandomNumberGenerator.Fill(buffer);
return Convert.ToBase64String(buffer)
.TrimEnd(Base64Padding)
.Replace('+', '-')
.Replace('/', '_');
}
internal static byte[] ComputeNonceHash(string nonce)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
var bytes = Encoding.UTF8.GetBytes(nonce);
return SHA256.HashData(bytes);
}
internal static string EncodeHash(ReadOnlySpan<byte> hash)
=> Convert.ToHexString(hash);
internal static string ComputeStorageKey(string audience, string clientId, string keyThumbprint)
{
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
return string.Create(
"dpop-nonce:".Length + audience.Length + clientId.Length + keyThumbprint.Length + 2,
(audience.Trim(), clientId.Trim(), keyThumbprint.Trim()),
static (span, parts) =>
{
var index = 0;
const string Prefix = "dpop-nonce:";
Prefix.CopyTo(span);
index += Prefix.Length;
index = Append(span, index, parts.Item1);
span[index++] = ':';
index = Append(span, index, parts.Item2);
span[index++] = ':';
_ = Append(span, index, parts.Item3);
});
static int Append(Span<char> span, int index, string value)
{
if (value.Length == 0)
{
throw new ArgumentException("Value must not be empty after trimming.");
}
value.AsSpan().CopyTo(span[index..]);
return index + value.Length;
}
}
}

View File

@@ -1,258 +1,258 @@
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Validates DPoP proofs following RFC 9449.
/// </summary>
public sealed class DpopProofValidator : IDpopProofValidator
{
private static readonly string ProofType = "dpop+jwt";
private readonly DpopValidationOptions options;
private readonly IDpopReplayCache replayCache;
private readonly TimeProvider timeProvider;
private readonly ILogger<DpopProofValidator>? logger;
private readonly JwtSecurityTokenHandler tokenHandler = new();
public DpopProofValidator(
IOptions<DpopValidationOptions> options,
IDpopReplayCache? replayCache = null,
TimeProvider? timeProvider = null,
ILogger<DpopProofValidator>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
var cloned = options.Value ?? throw new InvalidOperationException("DPoP options must be provided.");
cloned.Validate();
this.options = cloned;
this.replayCache = replayCache ?? NullReplayCache.Instance;
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger;
}
public async ValueTask<DpopValidationResult> ValidateAsync(string proof, string httpMethod, Uri httpUri, string? nonce = null, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(proof);
ArgumentException.ThrowIfNullOrWhiteSpace(httpMethod);
ArgumentNullException.ThrowIfNull(httpUri);
var now = timeProvider.GetUtcNow();
if (!TryDecodeSegment(proof, segmentIndex: 0, out var headerElement, out var headerError))
{
logger?.LogWarning("DPoP header decode failure: {Error}", headerError);
return DpopValidationResult.Failure("invalid_header", headerError ?? "Unable to decode header.");
}
if (!headerElement.TryGetProperty("typ", out var typElement) || !string.Equals(typElement.GetString(), ProofType, StringComparison.OrdinalIgnoreCase))
{
return DpopValidationResult.Failure("invalid_header", "DPoP proof missing typ=dpop+jwt header.");
}
if (!headerElement.TryGetProperty("alg", out var algElement))
{
return DpopValidationResult.Failure("invalid_header", "DPoP proof missing alg header.");
}
var algorithm = algElement.GetString()?.Trim().ToUpperInvariant();
if (string.IsNullOrEmpty(algorithm) || !options.NormalizedAlgorithms.Contains(algorithm))
{
return DpopValidationResult.Failure("invalid_header", "Unsupported DPoP algorithm.");
}
if (!headerElement.TryGetProperty("jwk", out var jwkElement))
{
return DpopValidationResult.Failure("invalid_header", "DPoP proof missing jwk header.");
}
JsonWebKey jwk;
try
{
jwk = new JsonWebKey(jwkElement.GetRawText());
}
catch (Exception ex)
{
logger?.LogWarning(ex, "Failed to parse DPoP jwk header.");
return DpopValidationResult.Failure("invalid_header", "DPoP proof jwk header is invalid.");
}
if (!TryDecodeSegment(proof, segmentIndex: 1, out var payloadElement, out var payloadError))
{
logger?.LogWarning("DPoP payload decode failure: {Error}", payloadError);
return DpopValidationResult.Failure("invalid_payload", payloadError ?? "Unable to decode payload.");
}
if (!payloadElement.TryGetProperty("htm", out var htmElement))
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing htm claim.");
}
var method = httpMethod.Trim().ToUpperInvariant();
if (!string.Equals(htmElement.GetString(), method, StringComparison.Ordinal))
{
return DpopValidationResult.Failure("invalid_payload", "DPoP htm does not match request method.");
}
if (!payloadElement.TryGetProperty("htu", out var htuElement))
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing htu claim.");
}
var normalizedHtu = NormalizeHtu(httpUri);
if (!string.Equals(htuElement.GetString(), normalizedHtu, StringComparison.Ordinal))
{
return DpopValidationResult.Failure("invalid_payload", "DPoP htu does not match request URI.");
}
if (!payloadElement.TryGetProperty("iat", out var iatElement) || iatElement.ValueKind is not JsonValueKind.Number)
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing iat claim.");
}
if (!payloadElement.TryGetProperty("jti", out var jtiElement) || jtiElement.ValueKind != JsonValueKind.String)
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing jti claim.");
}
long iatSeconds;
try
{
iatSeconds = iatElement.GetInt64();
}
catch (Exception)
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof iat claim is not a valid number.");
}
var issuedAt = DateTimeOffset.FromUnixTimeSeconds(iatSeconds).ToUniversalTime();
if (issuedAt - options.AllowedClockSkew > now)
{
return DpopValidationResult.Failure("invalid_token", "DPoP proof issued in the future.");
}
if (now - issuedAt > options.GetMaximumAge())
{
return DpopValidationResult.Failure("invalid_token", "DPoP proof expired.");
}
string? actualNonce = null;
if (nonce is not null)
{
if (!payloadElement.TryGetProperty("nonce", out var nonceElement) || nonceElement.ValueKind != JsonValueKind.String)
{
return DpopValidationResult.Failure("invalid_token", "DPoP proof missing nonce claim.");
}
actualNonce = nonceElement.GetString();
if (!string.Equals(actualNonce, nonce, StringComparison.Ordinal))
{
return DpopValidationResult.Failure("invalid_token", "DPoP nonce mismatch.");
}
}
else if (payloadElement.TryGetProperty("nonce", out var nonceElement) && nonceElement.ValueKind == JsonValueKind.String)
{
actualNonce = nonceElement.GetString();
}
var jwtId = jtiElement.GetString()!;
try
{
var parameters = new TokenValidationParameters
{
ValidateAudience = false,
ValidateIssuer = false,
ValidateLifetime = false,
ValidateTokenReplay = false,
RequireSignedTokens = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = jwk,
ValidAlgorithms = options.NormalizedAlgorithms.ToArray()
};
tokenHandler.ValidateToken(proof, parameters, out _);
}
catch (Exception ex)
{
logger?.LogWarning(ex, "DPoP proof signature validation failed.");
return DpopValidationResult.Failure("invalid_signature", "DPoP proof signature validation failed.");
}
if (!await replayCache.TryStoreAsync(jwtId, issuedAt + options.ReplayWindow, cancellationToken).ConfigureAwait(false))
{
return DpopValidationResult.Failure("replay", "DPoP proof already used.");
}
return DpopValidationResult.Success(jwk, jwtId, issuedAt, actualNonce);
}
private static string NormalizeHtu(Uri uri)
{
var builder = new UriBuilder(uri)
{
Fragment = null,
Query = null
};
return builder.Uri.ToString();
}
private static bool TryDecodeSegment(string token, int segmentIndex, out JsonElement element, out string? error)
{
element = default;
error = null;
var segments = token.Split('.');
if (segments.Length != 3)
{
error = "Token must contain three segments.";
return false;
}
if (segmentIndex < 0 || segmentIndex > 2)
{
error = "Segment index out of range.";
return false;
}
try
{
var json = Base64UrlEncoder.Decode(segments[segmentIndex]);
using var document = JsonDocument.Parse(json);
element = document.RootElement.Clone();
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
private static class NullReplayCache
{
public static readonly IDpopReplayCache Instance = new Noop();
private sealed class Noop : IDpopReplayCache
{
public ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(jwtId);
return ValueTask.FromResult(true);
}
}
}
}
file static class DpopValidationOptionsExtensions
{
public static TimeSpan GetMaximumAge(this DpopValidationOptions options)
=> options.ProofLifetime + options.AllowedClockSkew;
}
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Validates DPoP proofs following RFC 9449.
/// </summary>
public sealed class DpopProofValidator : IDpopProofValidator
{
private static readonly string ProofType = "dpop+jwt";
private readonly DpopValidationOptions options;
private readonly IDpopReplayCache replayCache;
private readonly TimeProvider timeProvider;
private readonly ILogger<DpopProofValidator>? logger;
private readonly JwtSecurityTokenHandler tokenHandler = new();
public DpopProofValidator(
IOptions<DpopValidationOptions> options,
IDpopReplayCache? replayCache = null,
TimeProvider? timeProvider = null,
ILogger<DpopProofValidator>? logger = null)
{
ArgumentNullException.ThrowIfNull(options);
var cloned = options.Value ?? throw new InvalidOperationException("DPoP options must be provided.");
cloned.Validate();
this.options = cloned;
this.replayCache = replayCache ?? NullReplayCache.Instance;
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger;
}
public async ValueTask<DpopValidationResult> ValidateAsync(string proof, string httpMethod, Uri httpUri, string? nonce = null, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(proof);
ArgumentException.ThrowIfNullOrWhiteSpace(httpMethod);
ArgumentNullException.ThrowIfNull(httpUri);
var now = timeProvider.GetUtcNow();
if (!TryDecodeSegment(proof, segmentIndex: 0, out var headerElement, out var headerError))
{
logger?.LogWarning("DPoP header decode failure: {Error}", headerError);
return DpopValidationResult.Failure("invalid_header", headerError ?? "Unable to decode header.");
}
if (!headerElement.TryGetProperty("typ", out var typElement) || !string.Equals(typElement.GetString(), ProofType, StringComparison.OrdinalIgnoreCase))
{
return DpopValidationResult.Failure("invalid_header", "DPoP proof missing typ=dpop+jwt header.");
}
if (!headerElement.TryGetProperty("alg", out var algElement))
{
return DpopValidationResult.Failure("invalid_header", "DPoP proof missing alg header.");
}
var algorithm = algElement.GetString()?.Trim().ToUpperInvariant();
if (string.IsNullOrEmpty(algorithm) || !options.NormalizedAlgorithms.Contains(algorithm))
{
return DpopValidationResult.Failure("invalid_header", "Unsupported DPoP algorithm.");
}
if (!headerElement.TryGetProperty("jwk", out var jwkElement))
{
return DpopValidationResult.Failure("invalid_header", "DPoP proof missing jwk header.");
}
JsonWebKey jwk;
try
{
jwk = new JsonWebKey(jwkElement.GetRawText());
}
catch (Exception ex)
{
logger?.LogWarning(ex, "Failed to parse DPoP jwk header.");
return DpopValidationResult.Failure("invalid_header", "DPoP proof jwk header is invalid.");
}
if (!TryDecodeSegment(proof, segmentIndex: 1, out var payloadElement, out var payloadError))
{
logger?.LogWarning("DPoP payload decode failure: {Error}", payloadError);
return DpopValidationResult.Failure("invalid_payload", payloadError ?? "Unable to decode payload.");
}
if (!payloadElement.TryGetProperty("htm", out var htmElement))
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing htm claim.");
}
var method = httpMethod.Trim().ToUpperInvariant();
if (!string.Equals(htmElement.GetString(), method, StringComparison.Ordinal))
{
return DpopValidationResult.Failure("invalid_payload", "DPoP htm does not match request method.");
}
if (!payloadElement.TryGetProperty("htu", out var htuElement))
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing htu claim.");
}
var normalizedHtu = NormalizeHtu(httpUri);
if (!string.Equals(htuElement.GetString(), normalizedHtu, StringComparison.Ordinal))
{
return DpopValidationResult.Failure("invalid_payload", "DPoP htu does not match request URI.");
}
if (!payloadElement.TryGetProperty("iat", out var iatElement) || iatElement.ValueKind is not JsonValueKind.Number)
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing iat claim.");
}
if (!payloadElement.TryGetProperty("jti", out var jtiElement) || jtiElement.ValueKind != JsonValueKind.String)
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof missing jti claim.");
}
long iatSeconds;
try
{
iatSeconds = iatElement.GetInt64();
}
catch (Exception)
{
return DpopValidationResult.Failure("invalid_payload", "DPoP proof iat claim is not a valid number.");
}
var issuedAt = DateTimeOffset.FromUnixTimeSeconds(iatSeconds).ToUniversalTime();
if (issuedAt - options.AllowedClockSkew > now)
{
return DpopValidationResult.Failure("invalid_token", "DPoP proof issued in the future.");
}
if (now - issuedAt > options.GetMaximumAge())
{
return DpopValidationResult.Failure("invalid_token", "DPoP proof expired.");
}
string? actualNonce = null;
if (nonce is not null)
{
if (!payloadElement.TryGetProperty("nonce", out var nonceElement) || nonceElement.ValueKind != JsonValueKind.String)
{
return DpopValidationResult.Failure("invalid_token", "DPoP proof missing nonce claim.");
}
actualNonce = nonceElement.GetString();
if (!string.Equals(actualNonce, nonce, StringComparison.Ordinal))
{
return DpopValidationResult.Failure("invalid_token", "DPoP nonce mismatch.");
}
}
else if (payloadElement.TryGetProperty("nonce", out var nonceElement) && nonceElement.ValueKind == JsonValueKind.String)
{
actualNonce = nonceElement.GetString();
}
var jwtId = jtiElement.GetString()!;
try
{
var parameters = new TokenValidationParameters
{
ValidateAudience = false,
ValidateIssuer = false,
ValidateLifetime = false,
ValidateTokenReplay = false,
RequireSignedTokens = true,
ValidateIssuerSigningKey = true,
IssuerSigningKey = jwk,
ValidAlgorithms = options.NormalizedAlgorithms.ToArray()
};
tokenHandler.ValidateToken(proof, parameters, out _);
}
catch (Exception ex)
{
logger?.LogWarning(ex, "DPoP proof signature validation failed.");
return DpopValidationResult.Failure("invalid_signature", "DPoP proof signature validation failed.");
}
if (!await replayCache.TryStoreAsync(jwtId, issuedAt + options.ReplayWindow, cancellationToken).ConfigureAwait(false))
{
return DpopValidationResult.Failure("replay", "DPoP proof already used.");
}
return DpopValidationResult.Success(jwk, jwtId, issuedAt, actualNonce);
}
private static string NormalizeHtu(Uri uri)
{
var builder = new UriBuilder(uri)
{
Fragment = null,
Query = null
};
return builder.Uri.ToString();
}
private static bool TryDecodeSegment(string token, int segmentIndex, out JsonElement element, out string? error)
{
element = default;
error = null;
var segments = token.Split('.');
if (segments.Length != 3)
{
error = "Token must contain three segments.";
return false;
}
if (segmentIndex < 0 || segmentIndex > 2)
{
error = "Segment index out of range.";
return false;
}
try
{
var json = Base64UrlEncoder.Decode(segments[segmentIndex]);
using var document = JsonDocument.Parse(json);
element = document.RootElement.Clone();
return true;
}
catch (Exception ex)
{
error = ex.Message;
return false;
}
}
private static class NullReplayCache
{
public static readonly IDpopReplayCache Instance = new Noop();
private sealed class Noop : IDpopReplayCache
{
public ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(jwtId);
return ValueTask.FromResult(true);
}
}
}
}
file static class DpopValidationOptionsExtensions
{
public static TimeSpan GetMaximumAge(this DpopValidationOptions options)
=> options.ProofLifetime + options.AllowedClockSkew;
}

View File

@@ -1,77 +1,77 @@
using System.Collections.Immutable;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Configures acceptable algorithms and replay windows for DPoP proof validation.
/// </summary>
public sealed class DpopValidationOptions
{
private readonly HashSet<string> allowedAlgorithms = new(StringComparer.Ordinal);
public DpopValidationOptions()
{
allowedAlgorithms.Add("ES256");
allowedAlgorithms.Add("ES384");
}
/// <summary>
/// Maximum age a proof is considered valid relative to <see cref="IssuedAt"/>.
/// </summary>
public TimeSpan ProofLifetime { get; set; } = TimeSpan.FromMinutes(2);
/// <summary>
/// Allowed clock skew when evaluating <c>iat</c>.
/// </summary>
public TimeSpan AllowedClockSkew { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Duration a successfully validated proof is tracked to prevent replay.
/// </summary>
public TimeSpan ReplayWindow { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Algorithms (JWA) permitted for DPoP proofs.
/// </summary>
public ISet<string> AllowedAlgorithms => allowedAlgorithms;
/// <summary>
/// Normalised, upper-case representation of allowed algorithms.
/// </summary>
public IReadOnlySet<string> NormalizedAlgorithms { get; private set; } = ImmutableHashSet<string>.Empty;
public void Validate()
{
if (ProofLifetime <= TimeSpan.Zero)
{
throw new InvalidOperationException("DPoP proof lifetime must be greater than zero.");
}
if (AllowedClockSkew < TimeSpan.Zero || AllowedClockSkew > TimeSpan.FromMinutes(5))
{
throw new InvalidOperationException("DPoP allowed clock skew must be between 0 seconds and 5 minutes.");
}
if (ReplayWindow < TimeSpan.Zero)
{
throw new InvalidOperationException("DPoP replay window must be greater than or equal to zero.");
}
if (allowedAlgorithms.Count == 0)
{
throw new InvalidOperationException("At least one allowed DPoP algorithm must be configured.");
}
NormalizedAlgorithms = allowedAlgorithms
.Select(static algorithm => algorithm.Trim().ToUpperInvariant())
.Where(static algorithm => algorithm.Length > 0)
.ToImmutableHashSet(StringComparer.Ordinal);
if (NormalizedAlgorithms.Count == 0)
{
throw new InvalidOperationException("Allowed DPoP algorithms cannot be empty after normalization.");
}
}
}
using System.Collections.Immutable;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Configures acceptable algorithms and replay windows for DPoP proof validation.
/// </summary>
public sealed class DpopValidationOptions
{
private readonly HashSet<string> allowedAlgorithms = new(StringComparer.Ordinal);
public DpopValidationOptions()
{
allowedAlgorithms.Add("ES256");
allowedAlgorithms.Add("ES384");
}
/// <summary>
/// Maximum age a proof is considered valid relative to <see cref="IssuedAt"/>.
/// </summary>
public TimeSpan ProofLifetime { get; set; } = TimeSpan.FromMinutes(2);
/// <summary>
/// Allowed clock skew when evaluating <c>iat</c>.
/// </summary>
public TimeSpan AllowedClockSkew { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Duration a successfully validated proof is tracked to prevent replay.
/// </summary>
public TimeSpan ReplayWindow { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Algorithms (JWA) permitted for DPoP proofs.
/// </summary>
public ISet<string> AllowedAlgorithms => allowedAlgorithms;
/// <summary>
/// Normalised, upper-case representation of allowed algorithms.
/// </summary>
public IReadOnlySet<string> NormalizedAlgorithms { get; private set; } = ImmutableHashSet<string>.Empty;
public void Validate()
{
if (ProofLifetime <= TimeSpan.Zero)
{
throw new InvalidOperationException("DPoP proof lifetime must be greater than zero.");
}
if (AllowedClockSkew < TimeSpan.Zero || AllowedClockSkew > TimeSpan.FromMinutes(5))
{
throw new InvalidOperationException("DPoP allowed clock skew must be between 0 seconds and 5 minutes.");
}
if (ReplayWindow < TimeSpan.Zero)
{
throw new InvalidOperationException("DPoP replay window must be greater than or equal to zero.");
}
if (allowedAlgorithms.Count == 0)
{
throw new InvalidOperationException("At least one allowed DPoP algorithm must be configured.");
}
NormalizedAlgorithms = allowedAlgorithms
.Select(static algorithm => algorithm.Trim().ToUpperInvariant())
.Where(static algorithm => algorithm.Length > 0)
.ToImmutableHashSet(StringComparer.Ordinal);
if (NormalizedAlgorithms.Count == 0)
{
throw new InvalidOperationException("Allowed DPoP algorithms cannot be empty after normalization.");
}
}
}

View File

@@ -1,40 +1,40 @@
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Represents the outcome of DPoP proof validation.
/// </summary>
public sealed class DpopValidationResult
{
private DpopValidationResult(bool success, string? errorCode, string? errorDescription, SecurityKey? key, string? jwtId, DateTimeOffset? issuedAt, string? nonce)
{
IsValid = success;
ErrorCode = errorCode;
ErrorDescription = errorDescription;
PublicKey = key;
JwtId = jwtId;
IssuedAt = issuedAt;
Nonce = nonce;
}
public bool IsValid { get; }
public string? ErrorCode { get; }
public string? ErrorDescription { get; }
public SecurityKey? PublicKey { get; }
public string? JwtId { get; }
public DateTimeOffset? IssuedAt { get; }
public string? Nonce { get; }
public static DpopValidationResult Success(SecurityKey key, string jwtId, DateTimeOffset issuedAt, string? nonce)
=> new(true, null, null, key, jwtId, issuedAt, nonce);
public static DpopValidationResult Failure(string code, string description)
=> new(false, code, description, null, null, null, null);
}
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Represents the outcome of DPoP proof validation.
/// </summary>
public sealed class DpopValidationResult
{
private DpopValidationResult(bool success, string? errorCode, string? errorDescription, SecurityKey? key, string? jwtId, DateTimeOffset? issuedAt, string? nonce)
{
IsValid = success;
ErrorCode = errorCode;
ErrorDescription = errorDescription;
PublicKey = key;
JwtId = jwtId;
IssuedAt = issuedAt;
Nonce = nonce;
}
public bool IsValid { get; }
public string? ErrorCode { get; }
public string? ErrorDescription { get; }
public SecurityKey? PublicKey { get; }
public string? JwtId { get; }
public DateTimeOffset? IssuedAt { get; }
public string? Nonce { get; }
public static DpopValidationResult Success(SecurityKey key, string jwtId, DateTimeOffset issuedAt, string? nonce)
=> new(true, null, null, key, jwtId, issuedAt, nonce);
public static DpopValidationResult Failure(string code, string description)
=> new(false, code, description, null, null, null, null);
}

View File

@@ -1,45 +1,45 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Provides persistence and validation for DPoP nonces.
/// </summary>
public interface IDpopNonceStore
{
/// <summary>
/// Issues a nonce tied to the specified audience, client, and DPoP key thumbprint.
/// </summary>
/// <param name="audience">Audience the nonce applies to.</param>
/// <param name="clientId">Client identifier requesting the nonce.</param>
/// <param name="keyThumbprint">Thumbprint of the DPoP public key.</param>
/// <param name="ttl">Time-to-live for the nonce.</param>
/// <param name="maxIssuancePerMinute">Maximum number of nonces that can be issued within a one-minute window for the tuple.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Outcome describing the issued nonce.</returns>
ValueTask<DpopNonceIssueResult> IssueAsync(
string audience,
string clientId,
string keyThumbprint,
TimeSpan ttl,
int maxIssuancePerMinute,
CancellationToken cancellationToken = default);
/// <summary>
/// Attempts to consume a nonce previously issued for the tuple.
/// </summary>
/// <param name="nonce">Nonce supplied by the client.</param>
/// <param name="audience">Audience the nonce should match.</param>
/// <param name="clientId">Client identifier.</param>
/// <param name="keyThumbprint">Thumbprint of the DPoP public key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Outcome describing whether the nonce was accepted.</returns>
ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
string nonce,
string audience,
string clientId,
string keyThumbprint,
CancellationToken cancellationToken = default);
}
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Provides persistence and validation for DPoP nonces.
/// </summary>
public interface IDpopNonceStore
{
/// <summary>
/// Issues a nonce tied to the specified audience, client, and DPoP key thumbprint.
/// </summary>
/// <param name="audience">Audience the nonce applies to.</param>
/// <param name="clientId">Client identifier requesting the nonce.</param>
/// <param name="keyThumbprint">Thumbprint of the DPoP public key.</param>
/// <param name="ttl">Time-to-live for the nonce.</param>
/// <param name="maxIssuancePerMinute">Maximum number of nonces that can be issued within a one-minute window for the tuple.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Outcome describing the issued nonce.</returns>
ValueTask<DpopNonceIssueResult> IssueAsync(
string audience,
string clientId,
string keyThumbprint,
TimeSpan ttl,
int maxIssuancePerMinute,
CancellationToken cancellationToken = default);
/// <summary>
/// Attempts to consume a nonce previously issued for the tuple.
/// </summary>
/// <param name="nonce">Nonce supplied by the client.</param>
/// <param name="audience">Audience the nonce should match.</param>
/// <param name="clientId">Client identifier.</param>
/// <param name="keyThumbprint">Thumbprint of the DPoP public key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Outcome describing whether the nonce was accepted.</returns>
ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
string nonce,
string audience,
string clientId,
string keyThumbprint,
CancellationToken cancellationToken = default);
}

View File

@@ -1,6 +1,6 @@
namespace StellaOps.Auth.Security.Dpop;
public interface IDpopProofValidator
{
ValueTask<DpopValidationResult> ValidateAsync(string proof, string httpMethod, Uri httpUri, string? nonce = null, CancellationToken cancellationToken = default);
}
namespace StellaOps.Auth.Security.Dpop;
public interface IDpopProofValidator
{
ValueTask<DpopValidationResult> ValidateAsync(string proof, string httpMethod, Uri httpUri, string? nonce = null, CancellationToken cancellationToken = default);
}

View File

@@ -1,6 +1,6 @@
namespace StellaOps.Auth.Security.Dpop;
public interface IDpopReplayCache
{
ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default);
}
namespace StellaOps.Auth.Security.Dpop;
public interface IDpopReplayCache
{
ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default);
}

View File

@@ -1,176 +1,176 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using Microsoft.Extensions.Logging;
using System.Threading.Tasks;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// In-memory implementation of <see cref="IDpopNonceStore"/> suitable for single-host or test environments.
/// </summary>
public sealed class InMemoryDpopNonceStore : IDpopNonceStore
{
private static readonly TimeSpan IssuanceWindow = TimeSpan.FromMinutes(1);
private readonly ConcurrentDictionary<string, StoredNonce> nonces = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, IssuanceBucket> issuanceBuckets = new(StringComparer.Ordinal);
private readonly TimeProvider timeProvider;
private readonly ILogger<InMemoryDpopNonceStore>? logger;
public InMemoryDpopNonceStore(TimeProvider? timeProvider = null, ILogger<InMemoryDpopNonceStore>? logger = null)
{
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger;
}
public ValueTask<DpopNonceIssueResult> IssueAsync(
string audience,
string clientId,
string keyThumbprint,
TimeSpan ttl,
int maxIssuancePerMinute,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
if (ttl <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(ttl), "Nonce TTL must be greater than zero.");
}
if (maxIssuancePerMinute < 1)
{
throw new ArgumentOutOfRangeException(nameof(maxIssuancePerMinute), "Max issuance per minute must be at least 1.");
}
cancellationToken.ThrowIfCancellationRequested();
var now = timeProvider.GetUtcNow();
var bucketKey = BuildBucketKey(audience, clientId, keyThumbprint);
var bucket = issuanceBuckets.GetOrAdd(bucketKey, static _ => new IssuanceBucket());
bool allowed;
lock (bucket.SyncRoot)
{
bucket.Prune(now - IssuanceWindow);
if (bucket.IssuanceTimes.Count >= maxIssuancePerMinute)
{
allowed = false;
}
else
{
bucket.IssuanceTimes.Enqueue(now);
allowed = true;
}
}
if (!allowed)
{
logger?.LogDebug("DPoP nonce issuance throttled for {BucketKey}.", bucketKey);
return ValueTask.FromResult(DpopNonceIssueResult.RateLimited("rate_limited"));
}
var nonce = GenerateNonce();
var nonceKey = BuildNonceKey(audience, clientId, keyThumbprint, nonce);
var expiresAt = now + ttl;
nonces[nonceKey] = new StoredNonce(now, expiresAt);
return ValueTask.FromResult(DpopNonceIssueResult.Success(nonce, expiresAt));
}
public ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
string nonce,
string audience,
string clientId,
string keyThumbprint,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
cancellationToken.ThrowIfCancellationRequested();
var now = timeProvider.GetUtcNow();
var nonceKey = BuildNonceKey(audience, clientId, keyThumbprint, nonce);
if (!nonces.TryRemove(nonceKey, out var stored))
{
logger?.LogDebug("DPoP nonce {NonceKey} not found during consumption.", nonceKey);
return ValueTask.FromResult(DpopNonceConsumeResult.NotFound());
}
if (stored.ExpiresAt <= now)
{
logger?.LogDebug("DPoP nonce {NonceKey} expired at {ExpiresAt:o}.", nonceKey, stored.ExpiresAt);
return ValueTask.FromResult(DpopNonceConsumeResult.Expired(stored.IssuedAt, stored.ExpiresAt));
}
return ValueTask.FromResult(DpopNonceConsumeResult.Success(stored.IssuedAt, stored.ExpiresAt));
}
private static string BuildBucketKey(string audience, string clientId, string keyThumbprint)
=> $"{audience.Trim().ToLowerInvariant()}::{clientId.Trim().ToLowerInvariant()}::{keyThumbprint.Trim().ToLowerInvariant()}";
private static string BuildNonceKey(string audience, string clientId, string keyThumbprint, string nonce)
{
var bucketKey = BuildBucketKey(audience, clientId, keyThumbprint);
var digest = ComputeSha256(nonce);
return $"{bucketKey}::{digest}";
}
private static string ComputeSha256(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
var hash = SHA256.HashData(bytes);
return Base64UrlEncode(hash);
}
private static string Base64UrlEncode(ReadOnlySpan<byte> bytes)
{
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static string GenerateNonce()
{
Span<byte> buffer = stackalloc byte[32];
RandomNumberGenerator.Fill(buffer);
return Base64UrlEncode(buffer);
}
private sealed class StoredNonce
{
internal StoredNonce(DateTimeOffset issuedAt, DateTimeOffset expiresAt)
{
IssuedAt = issuedAt;
ExpiresAt = expiresAt;
}
internal DateTimeOffset IssuedAt { get; }
internal DateTimeOffset ExpiresAt { get; }
}
private sealed class IssuanceBucket
{
internal object SyncRoot { get; } = new();
internal Queue<DateTimeOffset> IssuanceTimes { get; } = new();
internal void Prune(DateTimeOffset threshold)
{
while (IssuanceTimes.Count > 0 && IssuanceTimes.Peek() < threshold)
{
IssuanceTimes.Dequeue();
}
}
}
}
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using Microsoft.Extensions.Logging;
using System.Threading.Tasks;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// In-memory implementation of <see cref="IDpopNonceStore"/> suitable for single-host or test environments.
/// </summary>
public sealed class InMemoryDpopNonceStore : IDpopNonceStore
{
private static readonly TimeSpan IssuanceWindow = TimeSpan.FromMinutes(1);
private readonly ConcurrentDictionary<string, StoredNonce> nonces = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, IssuanceBucket> issuanceBuckets = new(StringComparer.Ordinal);
private readonly TimeProvider timeProvider;
private readonly ILogger<InMemoryDpopNonceStore>? logger;
public InMemoryDpopNonceStore(TimeProvider? timeProvider = null, ILogger<InMemoryDpopNonceStore>? logger = null)
{
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger;
}
public ValueTask<DpopNonceIssueResult> IssueAsync(
string audience,
string clientId,
string keyThumbprint,
TimeSpan ttl,
int maxIssuancePerMinute,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
if (ttl <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(ttl), "Nonce TTL must be greater than zero.");
}
if (maxIssuancePerMinute < 1)
{
throw new ArgumentOutOfRangeException(nameof(maxIssuancePerMinute), "Max issuance per minute must be at least 1.");
}
cancellationToken.ThrowIfCancellationRequested();
var now = timeProvider.GetUtcNow();
var bucketKey = BuildBucketKey(audience, clientId, keyThumbprint);
var bucket = issuanceBuckets.GetOrAdd(bucketKey, static _ => new IssuanceBucket());
bool allowed;
lock (bucket.SyncRoot)
{
bucket.Prune(now - IssuanceWindow);
if (bucket.IssuanceTimes.Count >= maxIssuancePerMinute)
{
allowed = false;
}
else
{
bucket.IssuanceTimes.Enqueue(now);
allowed = true;
}
}
if (!allowed)
{
logger?.LogDebug("DPoP nonce issuance throttled for {BucketKey}.", bucketKey);
return ValueTask.FromResult(DpopNonceIssueResult.RateLimited("rate_limited"));
}
var nonce = GenerateNonce();
var nonceKey = BuildNonceKey(audience, clientId, keyThumbprint, nonce);
var expiresAt = now + ttl;
nonces[nonceKey] = new StoredNonce(now, expiresAt);
return ValueTask.FromResult(DpopNonceIssueResult.Success(nonce, expiresAt));
}
public ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
string nonce,
string audience,
string clientId,
string keyThumbprint,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
cancellationToken.ThrowIfCancellationRequested();
var now = timeProvider.GetUtcNow();
var nonceKey = BuildNonceKey(audience, clientId, keyThumbprint, nonce);
if (!nonces.TryRemove(nonceKey, out var stored))
{
logger?.LogDebug("DPoP nonce {NonceKey} not found during consumption.", nonceKey);
return ValueTask.FromResult(DpopNonceConsumeResult.NotFound());
}
if (stored.ExpiresAt <= now)
{
logger?.LogDebug("DPoP nonce {NonceKey} expired at {ExpiresAt:o}.", nonceKey, stored.ExpiresAt);
return ValueTask.FromResult(DpopNonceConsumeResult.Expired(stored.IssuedAt, stored.ExpiresAt));
}
return ValueTask.FromResult(DpopNonceConsumeResult.Success(stored.IssuedAt, stored.ExpiresAt));
}
private static string BuildBucketKey(string audience, string clientId, string keyThumbprint)
=> $"{audience.Trim().ToLowerInvariant()}::{clientId.Trim().ToLowerInvariant()}::{keyThumbprint.Trim().ToLowerInvariant()}";
private static string BuildNonceKey(string audience, string clientId, string keyThumbprint, string nonce)
{
var bucketKey = BuildBucketKey(audience, clientId, keyThumbprint);
var digest = ComputeSha256(nonce);
return $"{bucketKey}::{digest}";
}
private static string ComputeSha256(string value)
{
var bytes = Encoding.UTF8.GetBytes(value);
var hash = SHA256.HashData(bytes);
return Base64UrlEncode(hash);
}
private static string Base64UrlEncode(ReadOnlySpan<byte> bytes)
{
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
private static string GenerateNonce()
{
Span<byte> buffer = stackalloc byte[32];
RandomNumberGenerator.Fill(buffer);
return Base64UrlEncode(buffer);
}
private sealed class StoredNonce
{
internal StoredNonce(DateTimeOffset issuedAt, DateTimeOffset expiresAt)
{
IssuedAt = issuedAt;
ExpiresAt = expiresAt;
}
internal DateTimeOffset IssuedAt { get; }
internal DateTimeOffset ExpiresAt { get; }
}
private sealed class IssuanceBucket
{
internal object SyncRoot { get; } = new();
internal Queue<DateTimeOffset> IssuanceTimes { get; } = new();
internal void Prune(DateTimeOffset threshold)
{
while (IssuanceTimes.Count > 0 && IssuanceTimes.Peek() < threshold)
{
IssuanceTimes.Dequeue();
}
}
}
}

View File

@@ -1,66 +1,66 @@
using System.Collections.Concurrent;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// In-memory replay cache intended for single-process deployments or tests.
/// </summary>
public sealed class InMemoryDpopReplayCache : IDpopReplayCache
{
private readonly ConcurrentDictionary<string, DateTimeOffset> entries = new(StringComparer.Ordinal);
private readonly TimeProvider timeProvider;
public InMemoryDpopReplayCache(TimeProvider? timeProvider = null)
{
this.timeProvider = timeProvider ?? TimeProvider.System;
}
public ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(jwtId);
var now = timeProvider.GetUtcNow();
RemoveExpired(now);
if (entries.TryAdd(jwtId, expiresAt))
{
return ValueTask.FromResult(true);
}
while (!cancellationToken.IsCancellationRequested)
{
if (!entries.TryGetValue(jwtId, out var existing))
{
if (entries.TryAdd(jwtId, expiresAt))
{
return ValueTask.FromResult(true);
}
continue;
}
if (existing > now)
{
return ValueTask.FromResult(false);
}
if (entries.TryUpdate(jwtId, expiresAt, existing))
{
return ValueTask.FromResult(true);
}
}
return ValueTask.FromResult(false);
}
private void RemoveExpired(DateTimeOffset now)
{
foreach (var entry in entries)
{
if (entry.Value <= now)
{
entries.TryRemove(entry.Key, out _);
}
}
}
}
using System.Collections.Concurrent;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// In-memory replay cache intended for single-process deployments or tests.
/// </summary>
public sealed class InMemoryDpopReplayCache : IDpopReplayCache
{
private readonly ConcurrentDictionary<string, DateTimeOffset> entries = new(StringComparer.Ordinal);
private readonly TimeProvider timeProvider;
public InMemoryDpopReplayCache(TimeProvider? timeProvider = null)
{
this.timeProvider = timeProvider ?? TimeProvider.System;
}
public ValueTask<bool> TryStoreAsync(string jwtId, DateTimeOffset expiresAt, CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(jwtId);
var now = timeProvider.GetUtcNow();
RemoveExpired(now);
if (entries.TryAdd(jwtId, expiresAt))
{
return ValueTask.FromResult(true);
}
while (!cancellationToken.IsCancellationRequested)
{
if (!entries.TryGetValue(jwtId, out var existing))
{
if (entries.TryAdd(jwtId, expiresAt))
{
return ValueTask.FromResult(true);
}
continue;
}
if (existing > now)
{
return ValueTask.FromResult(false);
}
if (entries.TryUpdate(jwtId, expiresAt, existing))
{
return ValueTask.FromResult(true);
}
}
return ValueTask.FromResult(false);
}
private void RemoveExpired(DateTimeOffset now)
{
foreach (var entry in entries)
{
if (entry.Value <= now)
{
entries.TryRemove(entry.Key, out _);
}
}
}
}

View File

@@ -1,138 +1,138 @@
using System;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using StackExchange.Redis;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Redis-backed implementation of <see cref="IDpopNonceStore"/> that supports multi-node deployments.
/// </summary>
public sealed class RedisDpopNonceStore : IDpopNonceStore
{
private const string ConsumeScript = @"
local value = redis.call('GET', KEYS[1])
if value ~= false and value == ARGV[1] then
redis.call('DEL', KEYS[1])
return 1
end
return 0";
private readonly IConnectionMultiplexer connection;
private readonly TimeProvider timeProvider;
public RedisDpopNonceStore(IConnectionMultiplexer connection, TimeProvider? timeProvider = null)
{
this.connection = connection ?? throw new ArgumentNullException(nameof(connection));
this.timeProvider = timeProvider ?? TimeProvider.System;
}
public async ValueTask<DpopNonceIssueResult> IssueAsync(
string audience,
string clientId,
string keyThumbprint,
TimeSpan ttl,
int maxIssuancePerMinute,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
if (ttl <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(ttl), "Nonce TTL must be greater than zero.");
}
if (maxIssuancePerMinute < 1)
{
throw new ArgumentOutOfRangeException(nameof(maxIssuancePerMinute), "Max issuance per minute must be at least 1.");
}
cancellationToken.ThrowIfCancellationRequested();
var database = connection.GetDatabase();
var issuedAt = timeProvider.GetUtcNow();
var baseKey = DpopNonceUtilities.ComputeStorageKey(audience, clientId, keyThumbprint);
var nonceKey = (RedisKey)baseKey;
var metadataKey = (RedisKey)(baseKey + ":meta");
var rateKey = (RedisKey)(baseKey + ":rate");
var rateCount = await database.StringIncrementAsync(rateKey, flags: CommandFlags.DemandMaster).ConfigureAwait(false);
if (rateCount == 1)
{
await database.KeyExpireAsync(rateKey, TimeSpan.FromMinutes(1), CommandFlags.DemandMaster).ConfigureAwait(false);
}
if (rateCount > maxIssuancePerMinute)
{
return DpopNonceIssueResult.RateLimited("rate_limited");
}
var nonce = DpopNonceUtilities.GenerateNonce();
var hash = (RedisValue)DpopNonceUtilities.EncodeHash(DpopNonceUtilities.ComputeNonceHash(nonce));
var expiresAt = issuedAt + ttl;
await database.StringSetAsync(nonceKey, hash, ttl, When.Always, CommandFlags.DemandMaster).ConfigureAwait(false);
var metadataValue = FormattableString.Invariant($"{issuedAt.UtcTicks}|{ttl.Ticks}");
await database.StringSetAsync(metadataKey, metadataValue, ttl, When.Always, CommandFlags.DemandMaster).ConfigureAwait(false);
return DpopNonceIssueResult.Success(nonce, expiresAt);
}
public async ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
string nonce,
string audience,
string clientId,
string keyThumbprint,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
cancellationToken.ThrowIfCancellationRequested();
var database = connection.GetDatabase();
var baseKey = DpopNonceUtilities.ComputeStorageKey(audience, clientId, keyThumbprint);
var nonceKey = (RedisKey)baseKey;
var metadataKey = (RedisKey)(baseKey + ":meta");
var hash = (RedisValue)DpopNonceUtilities.EncodeHash(DpopNonceUtilities.ComputeNonceHash(nonce));
var rawResult = await database.ScriptEvaluateAsync(
ConsumeScript,
new[] { nonceKey },
new RedisValue[] { hash }).ConfigureAwait(false);
if (rawResult.IsNull || (long)rawResult != 1)
{
return DpopNonceConsumeResult.NotFound();
}
var metadata = await database.StringGetAsync(metadataKey).ConfigureAwait(false);
await database.KeyDeleteAsync(metadataKey, CommandFlags.DemandMaster).ConfigureAwait(false);
if (!metadata.IsNull)
{
var parts = metadata.ToString()
.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 2 &&
long.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var issuedTicks) &&
long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ttlTicks))
{
var issuedAt = new DateTimeOffset(issuedTicks, TimeSpan.Zero);
var expiresAt = issuedAt + TimeSpan.FromTicks(ttlTicks);
return expiresAt <= timeProvider.GetUtcNow()
? DpopNonceConsumeResult.Expired(issuedAt, expiresAt)
: DpopNonceConsumeResult.Success(issuedAt, expiresAt);
}
}
return DpopNonceConsumeResult.Success(timeProvider.GetUtcNow(), timeProvider.GetUtcNow());
}
}
using System;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using StackExchange.Redis;
namespace StellaOps.Auth.Security.Dpop;
/// <summary>
/// Redis-backed implementation of <see cref="IDpopNonceStore"/> that supports multi-node deployments.
/// </summary>
public sealed class RedisDpopNonceStore : IDpopNonceStore
{
private const string ConsumeScript = @"
local value = redis.call('GET', KEYS[1])
if value ~= false and value == ARGV[1] then
redis.call('DEL', KEYS[1])
return 1
end
return 0";
private readonly IConnectionMultiplexer connection;
private readonly TimeProvider timeProvider;
public RedisDpopNonceStore(IConnectionMultiplexer connection, TimeProvider? timeProvider = null)
{
this.connection = connection ?? throw new ArgumentNullException(nameof(connection));
this.timeProvider = timeProvider ?? TimeProvider.System;
}
public async ValueTask<DpopNonceIssueResult> IssueAsync(
string audience,
string clientId,
string keyThumbprint,
TimeSpan ttl,
int maxIssuancePerMinute,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
if (ttl <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(ttl), "Nonce TTL must be greater than zero.");
}
if (maxIssuancePerMinute < 1)
{
throw new ArgumentOutOfRangeException(nameof(maxIssuancePerMinute), "Max issuance per minute must be at least 1.");
}
cancellationToken.ThrowIfCancellationRequested();
var database = connection.GetDatabase();
var issuedAt = timeProvider.GetUtcNow();
var baseKey = DpopNonceUtilities.ComputeStorageKey(audience, clientId, keyThumbprint);
var nonceKey = (RedisKey)baseKey;
var metadataKey = (RedisKey)(baseKey + ":meta");
var rateKey = (RedisKey)(baseKey + ":rate");
var rateCount = await database.StringIncrementAsync(rateKey, flags: CommandFlags.DemandMaster).ConfigureAwait(false);
if (rateCount == 1)
{
await database.KeyExpireAsync(rateKey, TimeSpan.FromMinutes(1), CommandFlags.DemandMaster).ConfigureAwait(false);
}
if (rateCount > maxIssuancePerMinute)
{
return DpopNonceIssueResult.RateLimited("rate_limited");
}
var nonce = DpopNonceUtilities.GenerateNonce();
var hash = (RedisValue)DpopNonceUtilities.EncodeHash(DpopNonceUtilities.ComputeNonceHash(nonce));
var expiresAt = issuedAt + ttl;
await database.StringSetAsync(nonceKey, hash, ttl, When.Always, CommandFlags.DemandMaster).ConfigureAwait(false);
var metadataValue = FormattableString.Invariant($"{issuedAt.UtcTicks}|{ttl.Ticks}");
await database.StringSetAsync(metadataKey, metadataValue, ttl, When.Always, CommandFlags.DemandMaster).ConfigureAwait(false);
return DpopNonceIssueResult.Success(nonce, expiresAt);
}
public async ValueTask<DpopNonceConsumeResult> TryConsumeAsync(
string nonce,
string audience,
string clientId,
string keyThumbprint,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(nonce);
ArgumentException.ThrowIfNullOrWhiteSpace(audience);
ArgumentException.ThrowIfNullOrWhiteSpace(clientId);
ArgumentException.ThrowIfNullOrWhiteSpace(keyThumbprint);
cancellationToken.ThrowIfCancellationRequested();
var database = connection.GetDatabase();
var baseKey = DpopNonceUtilities.ComputeStorageKey(audience, clientId, keyThumbprint);
var nonceKey = (RedisKey)baseKey;
var metadataKey = (RedisKey)(baseKey + ":meta");
var hash = (RedisValue)DpopNonceUtilities.EncodeHash(DpopNonceUtilities.ComputeNonceHash(nonce));
var rawResult = await database.ScriptEvaluateAsync(
ConsumeScript,
new[] { nonceKey },
new RedisValue[] { hash }).ConfigureAwait(false);
if (rawResult.IsNull || (long)rawResult != 1)
{
return DpopNonceConsumeResult.NotFound();
}
var metadata = await database.StringGetAsync(metadataKey).ConfigureAwait(false);
await database.KeyDeleteAsync(metadataKey, CommandFlags.DemandMaster).ConfigureAwait(false);
if (!metadata.IsNull)
{
var parts = metadata.ToString()
.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 2 &&
long.TryParse(parts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var issuedTicks) &&
long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var ttlTicks))
{
var issuedAt = new DateTimeOffset(issuedTicks, TimeSpan.Zero);
var expiresAt = issuedAt + TimeSpan.FromTicks(ttlTicks);
return expiresAt <= timeProvider.GetUtcNow()
? DpopNonceConsumeResult.Expired(issuedAt, expiresAt)
: DpopNonceConsumeResult.Success(issuedAt, expiresAt);
}
}
return DpopNonceConsumeResult.Success(timeProvider.GetUtcNow(), timeProvider.GetUtcNow());
}
}

View File

@@ -1,64 +1,64 @@
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Configuration;
namespace StellaOps.Configuration;
public sealed class StellaOpsBootstrapOptions<TOptions>
where TOptions : class, new()
{
public StellaOpsBootstrapOptions()
{
ConfigurationOptions = new StellaOpsConfigurationOptions();
}
internal StellaOpsConfigurationOptions ConfigurationOptions { get; }
public string? BasePath
{
get => ConfigurationOptions.BasePath;
set => ConfigurationOptions.BasePath = value;
}
public bool IncludeJsonFiles
{
get => ConfigurationOptions.IncludeJsonFiles;
set => ConfigurationOptions.IncludeJsonFiles = value;
}
public bool IncludeYamlFiles
{
get => ConfigurationOptions.IncludeYamlFiles;
set => ConfigurationOptions.IncludeYamlFiles = value;
}
public bool IncludeEnvironmentVariables
{
get => ConfigurationOptions.IncludeEnvironmentVariables;
set => ConfigurationOptions.IncludeEnvironmentVariables = value;
}
public string? EnvironmentPrefix
{
get => ConfigurationOptions.EnvironmentPrefix;
set => ConfigurationOptions.EnvironmentPrefix = value;
}
public IList<JsonConfigurationFile> JsonFiles => ConfigurationOptions.JsonFiles;
public IList<YamlConfigurationFile> YamlFiles => ConfigurationOptions.YamlFiles;
public string? BindingSection
{
get => ConfigurationOptions.BindingSection;
set => ConfigurationOptions.BindingSection = value;
}
public Action<IConfigurationBuilder>? ConfigureBuilder
{
get => ConfigurationOptions.ConfigureBuilder;
set => ConfigurationOptions.ConfigureBuilder = value;
}
public Action<TOptions, IConfiguration>? PostBind { get; set; }
}
using System;
using System.Collections.Generic;
using Microsoft.Extensions.Configuration;
namespace StellaOps.Configuration;
public sealed class StellaOpsBootstrapOptions<TOptions>
where TOptions : class, new()
{
public StellaOpsBootstrapOptions()
{
ConfigurationOptions = new StellaOpsConfigurationOptions();
}
internal StellaOpsConfigurationOptions ConfigurationOptions { get; }
public string? BasePath
{
get => ConfigurationOptions.BasePath;
set => ConfigurationOptions.BasePath = value;
}
public bool IncludeJsonFiles
{
get => ConfigurationOptions.IncludeJsonFiles;
set => ConfigurationOptions.IncludeJsonFiles = value;
}
public bool IncludeYamlFiles
{
get => ConfigurationOptions.IncludeYamlFiles;
set => ConfigurationOptions.IncludeYamlFiles = value;
}
public bool IncludeEnvironmentVariables
{
get => ConfigurationOptions.IncludeEnvironmentVariables;
set => ConfigurationOptions.IncludeEnvironmentVariables = value;
}
public string? EnvironmentPrefix
{
get => ConfigurationOptions.EnvironmentPrefix;
set => ConfigurationOptions.EnvironmentPrefix = value;
}
public IList<JsonConfigurationFile> JsonFiles => ConfigurationOptions.JsonFiles;
public IList<YamlConfigurationFile> YamlFiles => ConfigurationOptions.YamlFiles;
public string? BindingSection
{
get => ConfigurationOptions.BindingSection;
set => ConfigurationOptions.BindingSection = value;
}
public Action<IConfigurationBuilder>? ConfigureBuilder
{
get => ConfigurationOptions.ConfigureBuilder;
set => ConfigurationOptions.ConfigureBuilder = value;
}
public Action<TOptions, IConfiguration>? PostBind { get; set; }
}

View File

@@ -1,106 +1,106 @@
using System;
using Microsoft.Extensions.Configuration;
using NetEscapades.Configuration.Yaml;
namespace StellaOps.Configuration;
public static class StellaOpsConfigurationBootstrapper
{
public static StellaOpsConfigurationContext<TOptions> Build<TOptions>(
Action<StellaOpsBootstrapOptions<TOptions>>? configure = null)
where TOptions : class, new()
{
var bootstrapOptions = new StellaOpsBootstrapOptions<TOptions>();
configure?.Invoke(bootstrapOptions);
var configurationOptions = bootstrapOptions.ConfigurationOptions;
var builder = new ConfigurationBuilder();
if (!string.IsNullOrWhiteSpace(configurationOptions.BasePath))
{
builder.SetBasePath(configurationOptions.BasePath!);
}
if (configurationOptions.IncludeJsonFiles)
{
foreach (var file in configurationOptions.JsonFiles)
{
builder.AddJsonFile(file.Path, optional: file.Optional, reloadOnChange: file.ReloadOnChange);
}
}
if (configurationOptions.IncludeYamlFiles)
{
foreach (var file in configurationOptions.YamlFiles)
{
builder.AddYamlFile(file.Path, optional: file.Optional);
}
}
configurationOptions.ConfigureBuilder?.Invoke(builder);
if (configurationOptions.IncludeEnvironmentVariables)
{
builder.AddEnvironmentVariables(configurationOptions.EnvironmentPrefix);
}
var configuration = builder.Build();
IConfiguration bindingSource;
if (string.IsNullOrWhiteSpace(configurationOptions.BindingSection))
{
bindingSource = configuration;
}
else
{
bindingSource = configuration.GetSection(configurationOptions.BindingSection!);
}
var options = new TOptions();
bindingSource.Bind(options);
bootstrapOptions.PostBind?.Invoke(options, configuration);
return new StellaOpsConfigurationContext<TOptions>(configuration, options);
}
public static IConfigurationBuilder AddStellaOpsDefaults(
this IConfigurationBuilder builder,
Action<StellaOpsConfigurationOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(builder);
var options = new StellaOpsConfigurationOptions();
configure?.Invoke(options);
if (!string.IsNullOrWhiteSpace(options.BasePath))
{
builder.SetBasePath(options.BasePath!);
}
if (options.IncludeJsonFiles)
{
foreach (var file in options.JsonFiles)
{
builder.AddJsonFile(file.Path, optional: file.Optional, reloadOnChange: file.ReloadOnChange);
}
}
if (options.IncludeYamlFiles)
{
foreach (var file in options.YamlFiles)
{
builder.AddYamlFile(file.Path, optional: file.Optional);
}
}
options.ConfigureBuilder?.Invoke(builder);
if (options.IncludeEnvironmentVariables)
{
builder.AddEnvironmentVariables(options.EnvironmentPrefix);
}
return builder;
}
}
using System;
using Microsoft.Extensions.Configuration;
using NetEscapades.Configuration.Yaml;
namespace StellaOps.Configuration;
public static class StellaOpsConfigurationBootstrapper
{
public static StellaOpsConfigurationContext<TOptions> Build<TOptions>(
Action<StellaOpsBootstrapOptions<TOptions>>? configure = null)
where TOptions : class, new()
{
var bootstrapOptions = new StellaOpsBootstrapOptions<TOptions>();
configure?.Invoke(bootstrapOptions);
var configurationOptions = bootstrapOptions.ConfigurationOptions;
var builder = new ConfigurationBuilder();
if (!string.IsNullOrWhiteSpace(configurationOptions.BasePath))
{
builder.SetBasePath(configurationOptions.BasePath!);
}
if (configurationOptions.IncludeJsonFiles)
{
foreach (var file in configurationOptions.JsonFiles)
{
builder.AddJsonFile(file.Path, optional: file.Optional, reloadOnChange: file.ReloadOnChange);
}
}
if (configurationOptions.IncludeYamlFiles)
{
foreach (var file in configurationOptions.YamlFiles)
{
builder.AddYamlFile(file.Path, optional: file.Optional);
}
}
configurationOptions.ConfigureBuilder?.Invoke(builder);
if (configurationOptions.IncludeEnvironmentVariables)
{
builder.AddEnvironmentVariables(configurationOptions.EnvironmentPrefix);
}
var configuration = builder.Build();
IConfiguration bindingSource;
if (string.IsNullOrWhiteSpace(configurationOptions.BindingSection))
{
bindingSource = configuration;
}
else
{
bindingSource = configuration.GetSection(configurationOptions.BindingSection!);
}
var options = new TOptions();
bindingSource.Bind(options);
bootstrapOptions.PostBind?.Invoke(options, configuration);
return new StellaOpsConfigurationContext<TOptions>(configuration, options);
}
public static IConfigurationBuilder AddStellaOpsDefaults(
this IConfigurationBuilder builder,
Action<StellaOpsConfigurationOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(builder);
var options = new StellaOpsConfigurationOptions();
configure?.Invoke(options);
if (!string.IsNullOrWhiteSpace(options.BasePath))
{
builder.SetBasePath(options.BasePath!);
}
if (options.IncludeJsonFiles)
{
foreach (var file in options.JsonFiles)
{
builder.AddJsonFile(file.Path, optional: file.Optional, reloadOnChange: file.ReloadOnChange);
}
}
if (options.IncludeYamlFiles)
{
foreach (var file in options.YamlFiles)
{
builder.AddYamlFile(file.Path, optional: file.Optional);
}
}
options.ConfigureBuilder?.Invoke(builder);
if (options.IncludeEnvironmentVariables)
{
builder.AddEnvironmentVariables(options.EnvironmentPrefix);
}
return builder;
}
}

View File

@@ -1,18 +1,18 @@
using System;
using Microsoft.Extensions.Configuration;
namespace StellaOps.Configuration;
public sealed class StellaOpsConfigurationContext<TOptions>
where TOptions : class, new()
{
public StellaOpsConfigurationContext(IConfigurationRoot configuration, TOptions options)
{
Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
Options = options ?? throw new ArgumentNullException(nameof(options));
}
public IConfigurationRoot Configuration { get; }
public TOptions Options { get; }
}
using System;
using Microsoft.Extensions.Configuration;
namespace StellaOps.Configuration;
public sealed class StellaOpsConfigurationContext<TOptions>
where TOptions : class, new()
{
public StellaOpsConfigurationContext(IConfigurationRoot configuration, TOptions options)
{
Configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
Options = options ?? throw new ArgumentNullException(nameof(options));
}
public IConfigurationRoot Configuration { get; }
public TOptions Options { get; }
}

View File

@@ -1,49 +1,49 @@
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.Extensions.Configuration;
namespace StellaOps.Configuration;
/// <summary>
/// Defines how default StellaOps configuration sources are composed.
/// </summary>
public sealed class StellaOpsConfigurationOptions
{
public string? BasePath { get; set; } = Directory.GetCurrentDirectory();
public bool IncludeJsonFiles { get; set; } = true;
public bool IncludeYamlFiles { get; set; } = true;
public bool IncludeEnvironmentVariables { get; set; } = true;
public string? EnvironmentPrefix { get; set; }
public IList<JsonConfigurationFile> JsonFiles { get; } = new List<JsonConfigurationFile>
{
new("appsettings.json", true, false),
new("appsettings.local.json", true, false)
};
public IList<YamlConfigurationFile> YamlFiles { get; } = new List<YamlConfigurationFile>
{
new("appsettings.yaml", true),
new("appsettings.local.yaml", true)
};
/// <summary>
/// Optional hook to register additional configuration sources (e.g. module-specific YAML files).
/// </summary>
public Action<IConfigurationBuilder>? ConfigureBuilder { get; set; }
/// <summary>
/// Optional configuration section name used when binding strongly typed options.
/// Null or empty indicates the root.
/// </summary>
public string? BindingSection { get; set; }
}
public sealed record JsonConfigurationFile(string Path, bool Optional = true, bool ReloadOnChange = false);
public sealed record YamlConfigurationFile(string Path, bool Optional = true);
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.Extensions.Configuration;
namespace StellaOps.Configuration;
/// <summary>
/// Defines how default StellaOps configuration sources are composed.
/// </summary>
public sealed class StellaOpsConfigurationOptions
{
public string? BasePath { get; set; } = Directory.GetCurrentDirectory();
public bool IncludeJsonFiles { get; set; } = true;
public bool IncludeYamlFiles { get; set; } = true;
public bool IncludeEnvironmentVariables { get; set; } = true;
public string? EnvironmentPrefix { get; set; }
public IList<JsonConfigurationFile> JsonFiles { get; } = new List<JsonConfigurationFile>
{
new("appsettings.json", true, false),
new("appsettings.local.json", true, false)
};
public IList<YamlConfigurationFile> YamlFiles { get; } = new List<YamlConfigurationFile>
{
new("appsettings.yaml", true),
new("appsettings.local.yaml", true)
};
/// <summary>
/// Optional hook to register additional configuration sources (e.g. module-specific YAML files).
/// </summary>
public Action<IConfigurationBuilder>? ConfigureBuilder { get; set; }
/// <summary>
/// Optional configuration section name used when binding strongly typed options.
/// Null or empty indicates the root.
/// </summary>
public string? BindingSection { get; set; }
}
public sealed record JsonConfigurationFile(string Path, bool Optional = true, bool ReloadOnChange = false);
public sealed record YamlConfigurationFile(string Path, bool Optional = true);

View File

@@ -1,26 +1,26 @@
using System;
using Microsoft.Extensions.Configuration;
namespace StellaOps.Configuration;
public static class StellaOpsOptionsBinder
{
public static TOptions BindOptions<TOptions>(
this IConfiguration configuration,
string? section = null,
Action<TOptions, IConfiguration>? postConfigure = null)
where TOptions : class, new()
{
ArgumentNullException.ThrowIfNull(configuration);
var options = new TOptions();
var bindingSource = string.IsNullOrWhiteSpace(section)
? configuration
: configuration.GetSection(section);
bindingSource.Bind(options);
postConfigure?.Invoke(options, configuration);
return options;
}
}
using System;
using Microsoft.Extensions.Configuration;
namespace StellaOps.Configuration;
public static class StellaOpsOptionsBinder
{
public static TOptions BindOptions<TOptions>(
this IConfiguration configuration,
string? section = null,
Action<TOptions, IConfiguration>? postConfigure = null)
where TOptions : class, new()
{
ArgumentNullException.ThrowIfNull(configuration);
var options = new TOptions();
var bindingSource = string.IsNullOrWhiteSpace(section)
? configuration
: configuration.GetSection(section);
bindingSource.Bind(options);
postConfigure?.Invoke(options, configuration);
return options;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,27 @@
namespace StellaOps.Cryptography.Kms;
/// <summary>
/// Options for the <see cref="FileKmsClient"/>.
/// </summary>
public sealed class FileKmsOptions
{
/// <summary>
/// Root directory for storing key material.
/// </summary>
public string RootPath { get; set; } = string.Empty;
/// <summary>
/// Password used to encrypt private key material at rest.
/// </summary>
public required string Password { get; set; }
/// <summary>
/// Signing algorithm identifier (default ES256).
/// </summary>
public string Algorithm { get; set; } = KmsAlgorithms.Es256;
/// <summary>
/// PBKDF2 iteration count for envelope encryption.
/// </summary>
public int KeyDerivationIterations { get; set; } = 600_000;
}
namespace StellaOps.Cryptography.Kms;
/// <summary>
/// Options for the <see cref="FileKmsClient"/>.
/// </summary>
public sealed class FileKmsOptions
{
/// <summary>
/// Root directory for storing key material.
/// </summary>
public string RootPath { get; set; } = string.Empty;
/// <summary>
/// Password used to encrypt private key material at rest.
/// </summary>
public required string Password { get; set; }
/// <summary>
/// Signing algorithm identifier (default ES256).
/// </summary>
public string Algorithm { get; set; } = KmsAlgorithms.Es256;
/// <summary>
/// PBKDF2 iteration count for envelope encryption.
/// </summary>
public int KeyDerivationIterations { get; set; } = 600_000;
}

View File

@@ -1,21 +1,21 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Cryptography;
namespace StellaOps.Cryptography.Plugin.BouncyCastle;
/// <summary>
/// Dependency injection helpers for registering the BouncyCastle Ed25519 crypto provider.
/// </summary>
public static class BouncyCastleCryptoServiceCollectionExtensions
{
public static IServiceCollection AddBouncyCastleEd25519Provider(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<BouncyCastleEd25519CryptoProvider>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, BouncyCastleEd25519CryptoProvider>());
return services;
}
}
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using StellaOps.Cryptography;
namespace StellaOps.Cryptography.Plugin.BouncyCastle;
/// <summary>
/// Dependency injection helpers for registering the BouncyCastle Ed25519 crypto provider.
/// </summary>
public static class BouncyCastleCryptoServiceCollectionExtensions
{
public static IServiceCollection AddBouncyCastleEd25519Provider(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<BouncyCastleEd25519CryptoProvider>();
services.TryAddEnumerable(ServiceDescriptor.Singleton<ICryptoProvider, BouncyCastleEd25519CryptoProvider>());
return services;
}
}

View File

@@ -1,214 +1,214 @@
using System.Collections.Concurrent;
using Microsoft.IdentityModel.Tokens;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Crypto.Signers;
using StellaOps.Cryptography;
namespace StellaOps.Cryptography.Plugin.BouncyCastle;
/// <summary>
/// Ed25519 signing provider backed by BouncyCastle primitives.
/// </summary>
public sealed class BouncyCastleEd25519CryptoProvider : ICryptoProvider
{
private static readonly HashSet<string> SupportedAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
SignatureAlgorithms.Ed25519,
SignatureAlgorithms.EdDsa
};
private static readonly string[] DefaultKeyOps = { "sign", "verify" };
private readonly ConcurrentDictionary<string, KeyEntry> signingKeys = new(StringComparer.Ordinal);
public string Name => "bouncycastle.ed25519";
public bool Supports(CryptoCapability capability, string algorithmId)
{
if (string.IsNullOrWhiteSpace(algorithmId))
{
return false;
}
return capability switch
{
CryptoCapability.Signing or CryptoCapability.Verification => SupportedAlgorithms.Contains(algorithmId),
_ => false
};
}
public ICryptoHasher GetHasher(string algorithmId)
=> throw new NotSupportedException("BouncyCastle Ed25519 provider does not expose hashing capabilities.");
public IPasswordHasher GetPasswordHasher(string algorithmId)
=> throw new NotSupportedException("BouncyCastle provider does not expose password hashing capabilities.");
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
{
ArgumentException.ThrowIfNullOrWhiteSpace(algorithmId);
ArgumentNullException.ThrowIfNull(keyReference);
if (!signingKeys.TryGetValue(keyReference.KeyId, out var entry))
{
throw new KeyNotFoundException($"Signing key '{keyReference.KeyId}' is not registered with provider '{Name}'.");
}
EnsureAlgorithmSupported(algorithmId);
var normalized = NormalizeAlgorithm(algorithmId);
if (!string.Equals(entry.Descriptor.AlgorithmId, normalized, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Signing key '{keyReference.KeyId}' is registered for algorithm '{entry.Descriptor.AlgorithmId}', not '{algorithmId}'.");
}
return new Ed25519SignerWrapper(entry);
}
public void UpsertSigningKey(CryptoSigningKey signingKey)
{
ArgumentNullException.ThrowIfNull(signingKey);
EnsureAlgorithmSupported(signingKey.AlgorithmId);
if (signingKey.Kind != CryptoSigningKeyKind.Raw)
{
throw new InvalidOperationException($"Provider '{Name}' requires raw Ed25519 private key material.");
}
var privateKey = NormalizePrivateKey(signingKey.PrivateKey);
var publicKey = NormalizePublicKey(signingKey.PublicKey, privateKey);
var privateKeyParameters = new Ed25519PrivateKeyParameters(privateKey, 0);
var publicKeyParameters = new Ed25519PublicKeyParameters(publicKey, 0);
var descriptor = new CryptoSigningKey(
signingKey.Reference,
NormalizeAlgorithm(signingKey.AlgorithmId),
privateKey,
signingKey.CreatedAt,
signingKey.ExpiresAt,
publicKey,
signingKey.Metadata);
signingKeys.AddOrUpdate(
signingKey.Reference.KeyId,
_ => new KeyEntry(descriptor, privateKeyParameters, publicKeyParameters),
(_, _) => new KeyEntry(descriptor, privateKeyParameters, publicKeyParameters));
}
public bool RemoveSigningKey(string keyId)
{
if (string.IsNullOrWhiteSpace(keyId))
{
return false;
}
return signingKeys.TryRemove(keyId, out _);
}
public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys()
=> signingKeys.Values.Select(static entry => entry.Descriptor).ToArray();
private static void EnsureAlgorithmSupported(string algorithmId)
{
if (!SupportedAlgorithms.Contains(algorithmId))
{
throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider 'bouncycastle.ed25519'.");
}
}
private static string NormalizeAlgorithm(string algorithmId)
=> string.Equals(algorithmId, SignatureAlgorithms.EdDsa, StringComparison.OrdinalIgnoreCase)
? SignatureAlgorithms.Ed25519
: SignatureAlgorithms.Ed25519;
private static byte[] NormalizePrivateKey(ReadOnlyMemory<byte> privateKey)
{
var span = privateKey.Span;
return span.Length switch
{
32 => span.ToArray(),
64 => span[..32].ToArray(),
_ => throw new InvalidOperationException("Ed25519 private key must be 32 or 64 bytes.")
};
}
private static byte[] NormalizePublicKey(ReadOnlyMemory<byte> publicKey, byte[] privateKey)
{
if (publicKey.IsEmpty)
{
var privateParams = new Ed25519PrivateKeyParameters(privateKey, 0);
return privateParams.GeneratePublicKey().GetEncoded();
}
if (publicKey.Span.Length != 32)
{
throw new InvalidOperationException("Ed25519 public key must be 32 bytes.");
}
return publicKey.ToArray();
}
private sealed record KeyEntry(
CryptoSigningKey Descriptor,
Ed25519PrivateKeyParameters PrivateKey,
Ed25519PublicKeyParameters PublicKey);
private sealed class Ed25519SignerWrapper : ICryptoSigner
{
private readonly KeyEntry entry;
public Ed25519SignerWrapper(KeyEntry entry)
{
this.entry = entry ?? throw new ArgumentNullException(nameof(entry));
}
public string KeyId => entry.Descriptor.Reference.KeyId;
public string AlgorithmId => entry.Descriptor.AlgorithmId;
public ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var signer = new Ed25519Signer();
var buffer = data.ToArray();
signer.Init(true, entry.PrivateKey);
signer.BlockUpdate(buffer, 0, buffer.Length);
var signature = signer.GenerateSignature();
return ValueTask.FromResult(signature);
}
public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var verifier = new Ed25519Signer();
var buffer = data.ToArray();
verifier.Init(false, entry.PublicKey);
verifier.BlockUpdate(buffer, 0, buffer.Length);
var verified = verifier.VerifySignature(signature.ToArray());
return ValueTask.FromResult(verified);
}
public JsonWebKey ExportPublicJsonWebKey()
{
var jwk = new JsonWebKey
{
Kid = entry.Descriptor.Reference.KeyId,
Alg = SignatureAlgorithms.EdDsa,
Kty = "OKP",
Use = JsonWebKeyUseNames.Sig,
Crv = "Ed25519"
};
foreach (var op in DefaultKeyOps)
{
jwk.KeyOps.Add(op);
}
jwk.X = Base64UrlEncoder.Encode(entry.PublicKey.GetEncoded());
return jwk;
}
}
}
using System.Collections.Concurrent;
using Microsoft.IdentityModel.Tokens;
using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.Crypto.Signers;
using StellaOps.Cryptography;
namespace StellaOps.Cryptography.Plugin.BouncyCastle;
/// <summary>
/// Ed25519 signing provider backed by BouncyCastle primitives.
/// </summary>
public sealed class BouncyCastleEd25519CryptoProvider : ICryptoProvider
{
private static readonly HashSet<string> SupportedAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
SignatureAlgorithms.Ed25519,
SignatureAlgorithms.EdDsa
};
private static readonly string[] DefaultKeyOps = { "sign", "verify" };
private readonly ConcurrentDictionary<string, KeyEntry> signingKeys = new(StringComparer.Ordinal);
public string Name => "bouncycastle.ed25519";
public bool Supports(CryptoCapability capability, string algorithmId)
{
if (string.IsNullOrWhiteSpace(algorithmId))
{
return false;
}
return capability switch
{
CryptoCapability.Signing or CryptoCapability.Verification => SupportedAlgorithms.Contains(algorithmId),
_ => false
};
}
public ICryptoHasher GetHasher(string algorithmId)
=> throw new NotSupportedException("BouncyCastle Ed25519 provider does not expose hashing capabilities.");
public IPasswordHasher GetPasswordHasher(string algorithmId)
=> throw new NotSupportedException("BouncyCastle provider does not expose password hashing capabilities.");
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
{
ArgumentException.ThrowIfNullOrWhiteSpace(algorithmId);
ArgumentNullException.ThrowIfNull(keyReference);
if (!signingKeys.TryGetValue(keyReference.KeyId, out var entry))
{
throw new KeyNotFoundException($"Signing key '{keyReference.KeyId}' is not registered with provider '{Name}'.");
}
EnsureAlgorithmSupported(algorithmId);
var normalized = NormalizeAlgorithm(algorithmId);
if (!string.Equals(entry.Descriptor.AlgorithmId, normalized, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Signing key '{keyReference.KeyId}' is registered for algorithm '{entry.Descriptor.AlgorithmId}', not '{algorithmId}'.");
}
return new Ed25519SignerWrapper(entry);
}
public void UpsertSigningKey(CryptoSigningKey signingKey)
{
ArgumentNullException.ThrowIfNull(signingKey);
EnsureAlgorithmSupported(signingKey.AlgorithmId);
if (signingKey.Kind != CryptoSigningKeyKind.Raw)
{
throw new InvalidOperationException($"Provider '{Name}' requires raw Ed25519 private key material.");
}
var privateKey = NormalizePrivateKey(signingKey.PrivateKey);
var publicKey = NormalizePublicKey(signingKey.PublicKey, privateKey);
var privateKeyParameters = new Ed25519PrivateKeyParameters(privateKey, 0);
var publicKeyParameters = new Ed25519PublicKeyParameters(publicKey, 0);
var descriptor = new CryptoSigningKey(
signingKey.Reference,
NormalizeAlgorithm(signingKey.AlgorithmId),
privateKey,
signingKey.CreatedAt,
signingKey.ExpiresAt,
publicKey,
signingKey.Metadata);
signingKeys.AddOrUpdate(
signingKey.Reference.KeyId,
_ => new KeyEntry(descriptor, privateKeyParameters, publicKeyParameters),
(_, _) => new KeyEntry(descriptor, privateKeyParameters, publicKeyParameters));
}
public bool RemoveSigningKey(string keyId)
{
if (string.IsNullOrWhiteSpace(keyId))
{
return false;
}
return signingKeys.TryRemove(keyId, out _);
}
public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys()
=> signingKeys.Values.Select(static entry => entry.Descriptor).ToArray();
private static void EnsureAlgorithmSupported(string algorithmId)
{
if (!SupportedAlgorithms.Contains(algorithmId))
{
throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider 'bouncycastle.ed25519'.");
}
}
private static string NormalizeAlgorithm(string algorithmId)
=> string.Equals(algorithmId, SignatureAlgorithms.EdDsa, StringComparison.OrdinalIgnoreCase)
? SignatureAlgorithms.Ed25519
: SignatureAlgorithms.Ed25519;
private static byte[] NormalizePrivateKey(ReadOnlyMemory<byte> privateKey)
{
var span = privateKey.Span;
return span.Length switch
{
32 => span.ToArray(),
64 => span[..32].ToArray(),
_ => throw new InvalidOperationException("Ed25519 private key must be 32 or 64 bytes.")
};
}
private static byte[] NormalizePublicKey(ReadOnlyMemory<byte> publicKey, byte[] privateKey)
{
if (publicKey.IsEmpty)
{
var privateParams = new Ed25519PrivateKeyParameters(privateKey, 0);
return privateParams.GeneratePublicKey().GetEncoded();
}
if (publicKey.Span.Length != 32)
{
throw new InvalidOperationException("Ed25519 public key must be 32 bytes.");
}
return publicKey.ToArray();
}
private sealed record KeyEntry(
CryptoSigningKey Descriptor,
Ed25519PrivateKeyParameters PrivateKey,
Ed25519PublicKeyParameters PublicKey);
private sealed class Ed25519SignerWrapper : ICryptoSigner
{
private readonly KeyEntry entry;
public Ed25519SignerWrapper(KeyEntry entry)
{
this.entry = entry ?? throw new ArgumentNullException(nameof(entry));
}
public string KeyId => entry.Descriptor.Reference.KeyId;
public string AlgorithmId => entry.Descriptor.AlgorithmId;
public ValueTask<byte[]> SignAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var signer = new Ed25519Signer();
var buffer = data.ToArray();
signer.Init(true, entry.PrivateKey);
signer.BlockUpdate(buffer, 0, buffer.Length);
var signature = signer.GenerateSignature();
return ValueTask.FromResult(signature);
}
public ValueTask<bool> VerifyAsync(ReadOnlyMemory<byte> data, ReadOnlyMemory<byte> signature, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
var verifier = new Ed25519Signer();
var buffer = data.ToArray();
verifier.Init(false, entry.PublicKey);
verifier.BlockUpdate(buffer, 0, buffer.Length);
var verified = verifier.VerifySignature(signature.ToArray());
return ValueTask.FromResult(verified);
}
public JsonWebKey ExportPublicJsonWebKey()
{
var jwk = new JsonWebKey
{
Kid = entry.Descriptor.Reference.KeyId,
Alg = SignatureAlgorithms.EdDsa,
Kty = "OKP",
Use = JsonWebKeyUseNames.Sig,
Crv = "Ed25519"
};
foreach (var op in DefaultKeyOps)
{
jwk.KeyOps.Add(op);
}
jwk.X = Base64UrlEncoder.Encode(entry.PublicKey.GetEncoded());
return jwk;
}
}
}

View File

@@ -1,95 +1,95 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Cryptography.Audit;
/// <summary>
/// Represents a structured security event emitted by the Authority host and plugins.
/// </summary>
public sealed record AuthEventRecord
{
/// <summary>
/// Canonical event identifier (e.g. <c>authority.password.grant</c>).
/// </summary>
public required string EventType { get; init; }
/// <summary>
/// UTC timestamp captured when the event occurred.
/// </summary>
public DateTimeOffset OccurredAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Stable correlation identifier that links the event across logs, traces, and persistence.
/// </summary>
public string? CorrelationId { get; init; }
/// <summary>
/// Outcome classification for the audited operation.
/// </summary>
public AuthEventOutcome Outcome { get; init; } = AuthEventOutcome.Unknown;
/// <summary>
/// Optional human-readable reason or failure descriptor.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// Identity of the end-user (subject) involved in the event, when applicable.
/// </summary>
public AuthEventSubject? Subject { get; init; }
/// <summary>
/// OAuth/OIDC client metadata associated with the event, when applicable.
/// </summary>
public AuthEventClient? Client { get; init; }
/// <summary>
/// Tenant identifier associated with the authenticated principal or client.
/// </summary>
public ClassifiedString Tenant { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Project identifier associated with the authenticated principal or client (optional).
/// </summary>
public ClassifiedString Project { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Granted or requested scopes tied to the event.
/// </summary>
public IReadOnlyList<string> Scopes { get; init; } = Array.Empty<string>();
/// <summary>
/// Network attributes (remote IP, forwarded headers, user agent) captured for the request.
/// </summary>
public AuthEventNetwork? Network { get; init; }
/// <summary>
/// Additional classified properties carried with the event.
/// </summary>
public IReadOnlyList<AuthEventProperty> Properties { get; init; } = Array.Empty<AuthEventProperty>();
}
/// <summary>
/// Describes the outcome of an audited flow.
/// </summary>
public enum AuthEventOutcome
{
/// <summary>
/// Outcome has not been set.
/// </summary>
Unknown = 0,
/// <summary>
/// Operation succeeded.
/// </summary>
Success,
/// <summary>
/// Operation failed (invalid credentials, configuration issues, etc.).
/// </summary>
Failure,
/// <summary>
using System;
using System.Collections.Generic;
namespace StellaOps.Cryptography.Audit;
/// <summary>
/// Represents a structured security event emitted by the Authority host and plugins.
/// </summary>
public sealed record AuthEventRecord
{
/// <summary>
/// Canonical event identifier (e.g. <c>authority.password.grant</c>).
/// </summary>
public required string EventType { get; init; }
/// <summary>
/// UTC timestamp captured when the event occurred.
/// </summary>
public DateTimeOffset OccurredAt { get; init; } = DateTimeOffset.UtcNow;
/// <summary>
/// Stable correlation identifier that links the event across logs, traces, and persistence.
/// </summary>
public string? CorrelationId { get; init; }
/// <summary>
/// Outcome classification for the audited operation.
/// </summary>
public AuthEventOutcome Outcome { get; init; } = AuthEventOutcome.Unknown;
/// <summary>
/// Optional human-readable reason or failure descriptor.
/// </summary>
public string? Reason { get; init; }
/// <summary>
/// Identity of the end-user (subject) involved in the event, when applicable.
/// </summary>
public AuthEventSubject? Subject { get; init; }
/// <summary>
/// OAuth/OIDC client metadata associated with the event, when applicable.
/// </summary>
public AuthEventClient? Client { get; init; }
/// <summary>
/// Tenant identifier associated with the authenticated principal or client.
/// </summary>
public ClassifiedString Tenant { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Project identifier associated with the authenticated principal or client (optional).
/// </summary>
public ClassifiedString Project { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Granted or requested scopes tied to the event.
/// </summary>
public IReadOnlyList<string> Scopes { get; init; } = Array.Empty<string>();
/// <summary>
/// Network attributes (remote IP, forwarded headers, user agent) captured for the request.
/// </summary>
public AuthEventNetwork? Network { get; init; }
/// <summary>
/// Additional classified properties carried with the event.
/// </summary>
public IReadOnlyList<AuthEventProperty> Properties { get; init; } = Array.Empty<AuthEventProperty>();
}
/// <summary>
/// Describes the outcome of an audited flow.
/// </summary>
public enum AuthEventOutcome
{
/// <summary>
/// Outcome has not been set.
/// </summary>
Unknown = 0,
/// <summary>
/// Operation succeeded.
/// </summary>
Success,
/// <summary>
/// Operation failed (invalid credentials, configuration issues, etc.).
/// </summary>
Failure,
/// <summary>
/// Operation failed due to a lockout policy.
/// </summary>
LockedOut,
@@ -108,171 +108,171 @@ public enum AuthEventOutcome
/// Operation was rejected due to rate limiting or throttling.
/// </summary>
RateLimited,
/// <summary>
/// Operation encountered an unexpected error.
/// </summary>
Error
}
/// <summary>
/// Represents a string value enriched with a data classification tag.
/// </summary>
public readonly record struct ClassifiedString(string? Value, AuthEventDataClassification Classification)
{
/// <summary>
/// An empty classified string.
/// </summary>
public static ClassifiedString Empty => new(null, AuthEventDataClassification.None);
/// <summary>
/// Indicates whether the classified string carries a non-empty value.
/// </summary>
public bool HasValue => !string.IsNullOrWhiteSpace(Value);
/// <summary>
/// Creates a classified string for public/non-sensitive data.
/// </summary>
public static ClassifiedString Public(string? value) => Create(value, AuthEventDataClassification.None);
/// <summary>
/// Creates a classified string tagged as personally identifiable information (PII).
/// </summary>
public static ClassifiedString Personal(string? value) => Create(value, AuthEventDataClassification.Personal);
/// <summary>
/// Creates a classified string tagged as sensitive (e.g. credentials, secrets).
/// </summary>
public static ClassifiedString Sensitive(string? value) => Create(value, AuthEventDataClassification.Sensitive);
private static ClassifiedString Create(string? value, AuthEventDataClassification classification)
{
return new ClassifiedString(Normalize(value), classification);
}
private static string? Normalize(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value;
}
}
/// <summary>
/// Supported classifications for audit data values.
/// </summary>
public enum AuthEventDataClassification
{
/// <summary>
/// Data is not considered sensitive.
/// </summary>
None = 0,
/// <summary>
/// Personally identifiable information (PII) that warrants redaction in certain sinks.
/// </summary>
Personal,
/// <summary>
/// Highly sensitive information (credentials, secrets, tokens).
/// </summary>
Sensitive
}
/// <summary>
/// Captures subject metadata for an audit event.
/// </summary>
public sealed record AuthEventSubject
{
/// <summary>
/// Stable subject identifier (PII).
/// </summary>
public ClassifiedString SubjectId { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Username or login name (PII).
/// </summary>
public ClassifiedString Username { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Optional display name (PII).
/// </summary>
public ClassifiedString DisplayName { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Optional plugin or tenant realm controlling the subject namespace.
/// </summary>
public ClassifiedString Realm { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Additional classified attributes (e.g. email, phone).
/// </summary>
public IReadOnlyList<AuthEventProperty> Attributes { get; init; } = Array.Empty<AuthEventProperty>();
}
/// <summary>
/// Captures OAuth/OIDC client metadata for an audit event.
/// </summary>
public sealed record AuthEventClient
{
/// <summary>
/// Client identifier (PII for confidential clients).
/// </summary>
public ClassifiedString ClientId { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Friendly client name (may be public).
/// </summary>
public ClassifiedString Name { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Identity provider/plugin originating the client.
/// </summary>
public ClassifiedString Provider { get; init; } = ClassifiedString.Empty;
}
/// <summary>
/// Captures network metadata for an audit event.
/// </summary>
public sealed record AuthEventNetwork
{
/// <summary>
/// Remote address observed for the request (PII).
/// </summary>
public ClassifiedString RemoteAddress { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Forwarded address supplied by proxies (PII).
/// </summary>
public ClassifiedString ForwardedFor { get; init; } = ClassifiedString.Empty;
/// <summary>
/// User agent string associated with the request.
/// </summary>
public ClassifiedString UserAgent { get; init; } = ClassifiedString.Empty;
}
/// <summary>
/// Represents an additional classified property associated with the audit event.
/// </summary>
public sealed record AuthEventProperty
{
/// <summary>
/// Property name (canonical snake-case identifier).
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Classified value assigned to the property.
/// </summary>
public ClassifiedString Value { get; init; } = ClassifiedString.Empty;
}
/// <summary>
/// Sink that receives completed audit event records.
/// </summary>
public interface IAuthEventSink
{
/// <summary>
/// Persists the supplied audit event.
/// </summary>
ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken);
}
/// <summary>
/// Operation encountered an unexpected error.
/// </summary>
Error
}
/// <summary>
/// Represents a string value enriched with a data classification tag.
/// </summary>
public readonly record struct ClassifiedString(string? Value, AuthEventDataClassification Classification)
{
/// <summary>
/// An empty classified string.
/// </summary>
public static ClassifiedString Empty => new(null, AuthEventDataClassification.None);
/// <summary>
/// Indicates whether the classified string carries a non-empty value.
/// </summary>
public bool HasValue => !string.IsNullOrWhiteSpace(Value);
/// <summary>
/// Creates a classified string for public/non-sensitive data.
/// </summary>
public static ClassifiedString Public(string? value) => Create(value, AuthEventDataClassification.None);
/// <summary>
/// Creates a classified string tagged as personally identifiable information (PII).
/// </summary>
public static ClassifiedString Personal(string? value) => Create(value, AuthEventDataClassification.Personal);
/// <summary>
/// Creates a classified string tagged as sensitive (e.g. credentials, secrets).
/// </summary>
public static ClassifiedString Sensitive(string? value) => Create(value, AuthEventDataClassification.Sensitive);
private static ClassifiedString Create(string? value, AuthEventDataClassification classification)
{
return new ClassifiedString(Normalize(value), classification);
}
private static string? Normalize(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value;
}
}
/// <summary>
/// Supported classifications for audit data values.
/// </summary>
public enum AuthEventDataClassification
{
/// <summary>
/// Data is not considered sensitive.
/// </summary>
None = 0,
/// <summary>
/// Personally identifiable information (PII) that warrants redaction in certain sinks.
/// </summary>
Personal,
/// <summary>
/// Highly sensitive information (credentials, secrets, tokens).
/// </summary>
Sensitive
}
/// <summary>
/// Captures subject metadata for an audit event.
/// </summary>
public sealed record AuthEventSubject
{
/// <summary>
/// Stable subject identifier (PII).
/// </summary>
public ClassifiedString SubjectId { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Username or login name (PII).
/// </summary>
public ClassifiedString Username { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Optional display name (PII).
/// </summary>
public ClassifiedString DisplayName { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Optional plugin or tenant realm controlling the subject namespace.
/// </summary>
public ClassifiedString Realm { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Additional classified attributes (e.g. email, phone).
/// </summary>
public IReadOnlyList<AuthEventProperty> Attributes { get; init; } = Array.Empty<AuthEventProperty>();
}
/// <summary>
/// Captures OAuth/OIDC client metadata for an audit event.
/// </summary>
public sealed record AuthEventClient
{
/// <summary>
/// Client identifier (PII for confidential clients).
/// </summary>
public ClassifiedString ClientId { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Friendly client name (may be public).
/// </summary>
public ClassifiedString Name { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Identity provider/plugin originating the client.
/// </summary>
public ClassifiedString Provider { get; init; } = ClassifiedString.Empty;
}
/// <summary>
/// Captures network metadata for an audit event.
/// </summary>
public sealed record AuthEventNetwork
{
/// <summary>
/// Remote address observed for the request (PII).
/// </summary>
public ClassifiedString RemoteAddress { get; init; } = ClassifiedString.Empty;
/// <summary>
/// Forwarded address supplied by proxies (PII).
/// </summary>
public ClassifiedString ForwardedFor { get; init; } = ClassifiedString.Empty;
/// <summary>
/// User agent string associated with the request.
/// </summary>
public ClassifiedString UserAgent { get; init; } = ClassifiedString.Empty;
}
/// <summary>
/// Represents an additional classified property associated with the audit event.
/// </summary>
public sealed record AuthEventProperty
{
/// <summary>
/// Property name (canonical snake-case identifier).
/// </summary>
public required string Name { get; init; }
/// <summary>
/// Classified value assigned to the property.
/// </summary>
public ClassifiedString Value { get; init; } = ClassifiedString.Empty;
}
/// <summary>
/// Sink that receives completed audit event records.
/// </summary>
public interface IAuthEventSink
{
/// <summary>
/// Persists the supplied audit event.
/// </summary>
ValueTask WriteAsync(AuthEventRecord record, CancellationToken cancellationToken);
}

View File

@@ -1,176 +1,176 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Security.Cryptography;
namespace StellaOps.Cryptography;
/// <summary>
/// Describes the underlying key material for a <see cref="CryptoSigningKey"/>.
/// </summary>
public enum CryptoSigningKeyKind
{
Ec,
Raw
}
/// <summary>
/// Represents asymmetric signing key material managed by a crypto provider.
/// </summary>
public sealed class CryptoSigningKey
{
private static readonly ReadOnlyDictionary<string, string?> EmptyMetadata =
new(new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase));
private static readonly byte[] EmptyKey = Array.Empty<byte>();
private readonly byte[] privateKeyBytes;
private readonly byte[] publicKeyBytes;
public CryptoSigningKey(
CryptoKeyReference reference,
string algorithmId,
in ECParameters privateParameters,
DateTimeOffset createdAt,
DateTimeOffset? expiresAt = null,
IReadOnlyDictionary<string, string?>? metadata = null)
{
Reference = reference ?? throw new ArgumentNullException(nameof(reference));
if (string.IsNullOrWhiteSpace(algorithmId))
{
throw new ArgumentException("Algorithm identifier is required.", nameof(algorithmId));
}
if (privateParameters.D is null || privateParameters.D.Length == 0)
{
throw new ArgumentException("Private key parameters must include the scalar component.", nameof(privateParameters));
}
AlgorithmId = algorithmId;
CreatedAt = createdAt;
ExpiresAt = expiresAt;
Kind = CryptoSigningKeyKind.Ec;
privateKeyBytes = EmptyKey;
publicKeyBytes = EmptyKey;
PrivateParameters = CloneParameters(privateParameters, includePrivate: true);
PublicParameters = CloneParameters(privateParameters, includePrivate: false);
Metadata = metadata is null
? EmptyMetadata
: new ReadOnlyDictionary<string, string?>(metadata.ToDictionary(
static pair => pair.Key,
static pair => pair.Value,
StringComparer.OrdinalIgnoreCase));
}
public CryptoSigningKey(
CryptoKeyReference reference,
string algorithmId,
ReadOnlyMemory<byte> privateKey,
DateTimeOffset createdAt,
DateTimeOffset? expiresAt = null,
ReadOnlyMemory<byte> publicKey = default,
IReadOnlyDictionary<string, string?>? metadata = null)
{
Reference = reference ?? throw new ArgumentNullException(nameof(reference));
if (string.IsNullOrWhiteSpace(algorithmId))
{
throw new ArgumentException("Algorithm identifier is required.", nameof(algorithmId));
}
if (privateKey.IsEmpty)
{
throw new ArgumentException("Private key material must be provided.", nameof(privateKey));
}
AlgorithmId = algorithmId;
CreatedAt = createdAt;
ExpiresAt = expiresAt;
Kind = CryptoSigningKeyKind.Raw;
privateKeyBytes = privateKey.ToArray();
publicKeyBytes = publicKey.IsEmpty ? EmptyKey : publicKey.ToArray();
PrivateParameters = default;
PublicParameters = default;
Metadata = metadata is null
? EmptyMetadata
: new ReadOnlyDictionary<string, string?>(metadata.ToDictionary(
static pair => pair.Key,
static pair => pair.Value,
StringComparer.OrdinalIgnoreCase));
}
/// <summary>
/// Gets the key reference (id + provider hint).
/// </summary>
public CryptoKeyReference Reference { get; }
/// <summary>
/// Gets the algorithm identifier (e.g., ES256).
/// </summary>
public string AlgorithmId { get; }
/// <summary>
/// Gets the private EC parameters (cloned).
/// </summary>
public ECParameters PrivateParameters { get; }
/// <summary>
/// Gets the public EC parameters (cloned, no private scalar).
/// </summary>
public ECParameters PublicParameters { get; }
/// <summary>
/// Indicates the underlying key material representation.
/// </summary>
public CryptoSigningKeyKind Kind { get; }
/// <summary>
/// Gets the raw private key bytes when available (empty for EC-backed keys).
/// </summary>
public ReadOnlyMemory<byte> PrivateKey => privateKeyBytes;
/// <summary>
/// Gets the raw public key bytes when available (empty for EC-backed keys or when not supplied).
/// </summary>
public ReadOnlyMemory<byte> PublicKey => publicKeyBytes;
/// <summary>
/// Gets the timestamp when the key was created/imported.
/// </summary>
public DateTimeOffset CreatedAt { get; }
/// <summary>
/// Gets the optional expiry timestamp for the key.
/// </summary>
public DateTimeOffset? ExpiresAt { get; }
/// <summary>
/// Gets arbitrary metadata entries associated with the key.
/// </summary>
public IReadOnlyDictionary<string, string?> Metadata { get; }
private static ECParameters CloneParameters(ECParameters source, bool includePrivate)
{
var clone = new ECParameters
{
Curve = source.Curve,
Q = new ECPoint
{
X = source.Q.X is null ? null : (byte[])source.Q.X.Clone(),
Y = source.Q.Y is null ? null : (byte[])source.Q.Y.Clone()
}
};
if (includePrivate && source.D is not null)
{
clone.D = (byte[])source.D.Clone();
}
return clone;
}
}
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Security.Cryptography;
namespace StellaOps.Cryptography;
/// <summary>
/// Describes the underlying key material for a <see cref="CryptoSigningKey"/>.
/// </summary>
public enum CryptoSigningKeyKind
{
Ec,
Raw
}
/// <summary>
/// Represents asymmetric signing key material managed by a crypto provider.
/// </summary>
public sealed class CryptoSigningKey
{
private static readonly ReadOnlyDictionary<string, string?> EmptyMetadata =
new(new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase));
private static readonly byte[] EmptyKey = Array.Empty<byte>();
private readonly byte[] privateKeyBytes;
private readonly byte[] publicKeyBytes;
public CryptoSigningKey(
CryptoKeyReference reference,
string algorithmId,
in ECParameters privateParameters,
DateTimeOffset createdAt,
DateTimeOffset? expiresAt = null,
IReadOnlyDictionary<string, string?>? metadata = null)
{
Reference = reference ?? throw new ArgumentNullException(nameof(reference));
if (string.IsNullOrWhiteSpace(algorithmId))
{
throw new ArgumentException("Algorithm identifier is required.", nameof(algorithmId));
}
if (privateParameters.D is null || privateParameters.D.Length == 0)
{
throw new ArgumentException("Private key parameters must include the scalar component.", nameof(privateParameters));
}
AlgorithmId = algorithmId;
CreatedAt = createdAt;
ExpiresAt = expiresAt;
Kind = CryptoSigningKeyKind.Ec;
privateKeyBytes = EmptyKey;
publicKeyBytes = EmptyKey;
PrivateParameters = CloneParameters(privateParameters, includePrivate: true);
PublicParameters = CloneParameters(privateParameters, includePrivate: false);
Metadata = metadata is null
? EmptyMetadata
: new ReadOnlyDictionary<string, string?>(metadata.ToDictionary(
static pair => pair.Key,
static pair => pair.Value,
StringComparer.OrdinalIgnoreCase));
}
public CryptoSigningKey(
CryptoKeyReference reference,
string algorithmId,
ReadOnlyMemory<byte> privateKey,
DateTimeOffset createdAt,
DateTimeOffset? expiresAt = null,
ReadOnlyMemory<byte> publicKey = default,
IReadOnlyDictionary<string, string?>? metadata = null)
{
Reference = reference ?? throw new ArgumentNullException(nameof(reference));
if (string.IsNullOrWhiteSpace(algorithmId))
{
throw new ArgumentException("Algorithm identifier is required.", nameof(algorithmId));
}
if (privateKey.IsEmpty)
{
throw new ArgumentException("Private key material must be provided.", nameof(privateKey));
}
AlgorithmId = algorithmId;
CreatedAt = createdAt;
ExpiresAt = expiresAt;
Kind = CryptoSigningKeyKind.Raw;
privateKeyBytes = privateKey.ToArray();
publicKeyBytes = publicKey.IsEmpty ? EmptyKey : publicKey.ToArray();
PrivateParameters = default;
PublicParameters = default;
Metadata = metadata is null
? EmptyMetadata
: new ReadOnlyDictionary<string, string?>(metadata.ToDictionary(
static pair => pair.Key,
static pair => pair.Value,
StringComparer.OrdinalIgnoreCase));
}
/// <summary>
/// Gets the key reference (id + provider hint).
/// </summary>
public CryptoKeyReference Reference { get; }
/// <summary>
/// Gets the algorithm identifier (e.g., ES256).
/// </summary>
public string AlgorithmId { get; }
/// <summary>
/// Gets the private EC parameters (cloned).
/// </summary>
public ECParameters PrivateParameters { get; }
/// <summary>
/// Gets the public EC parameters (cloned, no private scalar).
/// </summary>
public ECParameters PublicParameters { get; }
/// <summary>
/// Indicates the underlying key material representation.
/// </summary>
public CryptoSigningKeyKind Kind { get; }
/// <summary>
/// Gets the raw private key bytes when available (empty for EC-backed keys).
/// </summary>
public ReadOnlyMemory<byte> PrivateKey => privateKeyBytes;
/// <summary>
/// Gets the raw public key bytes when available (empty for EC-backed keys or when not supplied).
/// </summary>
public ReadOnlyMemory<byte> PublicKey => publicKeyBytes;
/// <summary>
/// Gets the timestamp when the key was created/imported.
/// </summary>
public DateTimeOffset CreatedAt { get; }
/// <summary>
/// Gets the optional expiry timestamp for the key.
/// </summary>
public DateTimeOffset? ExpiresAt { get; }
/// <summary>
/// Gets arbitrary metadata entries associated with the key.
/// </summary>
public IReadOnlyDictionary<string, string?> Metadata { get; }
private static ECParameters CloneParameters(ECParameters source, bool includePrivate)
{
var clone = new ECParameters
{
Curve = source.Curve,
Q = new ECPoint
{
X = source.Q.X is null ? null : (byte[])source.Q.X.Clone(),
Y = source.Q.Y is null ? null : (byte[])source.Q.Y.Clone()
}
};
if (includePrivate && source.D is not null)
{
clone.D = (byte[])source.D.Clone();
}
return clone;
}
}

View File

@@ -1,181 +1,181 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
namespace StellaOps.Cryptography;
/// <summary>
/// Default in-process crypto provider exposing password hashing capabilities.
/// </summary>
public sealed class DefaultCryptoProvider : ICryptoProvider, ICryptoProviderDiagnostics
{
private readonly ConcurrentDictionary<string, IPasswordHasher> passwordHashers;
private readonly ConcurrentDictionary<string, CryptoSigningKey> signingKeys;
private static readonly HashSet<string> SupportedSigningAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
SignatureAlgorithms.Es256
};
private static readonly HashSet<string> SupportedHashAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
HashAlgorithms.Sha256,
HashAlgorithms.Sha384,
HashAlgorithms.Sha512
};
public DefaultCryptoProvider()
{
passwordHashers = new ConcurrentDictionary<string, IPasswordHasher>(StringComparer.OrdinalIgnoreCase);
signingKeys = new ConcurrentDictionary<string, CryptoSigningKey>(StringComparer.Ordinal);
var argon = new Argon2idPasswordHasher();
var pbkdf2 = new Pbkdf2PasswordHasher();
passwordHashers.TryAdd(PasswordHashAlgorithm.Argon2id.ToString(), argon);
passwordHashers.TryAdd(PasswordHashAlgorithms.Argon2id, argon);
passwordHashers.TryAdd(PasswordHashAlgorithm.Pbkdf2.ToString(), pbkdf2);
passwordHashers.TryAdd(PasswordHashAlgorithms.Pbkdf2Sha256, pbkdf2);
}
public string Name => "default";
public bool Supports(CryptoCapability capability, string algorithmId)
{
if (string.IsNullOrWhiteSpace(algorithmId))
{
return false;
}
return capability switch
{
CryptoCapability.PasswordHashing => passwordHashers.ContainsKey(algorithmId),
CryptoCapability.Signing or CryptoCapability.Verification => SupportedSigningAlgorithms.Contains(algorithmId),
CryptoCapability.ContentHashing => SupportedHashAlgorithms.Contains(algorithmId),
_ => false
};
}
public IPasswordHasher GetPasswordHasher(string algorithmId)
{
if (!Supports(CryptoCapability.PasswordHashing, algorithmId))
{
throw new InvalidOperationException($"Password hashing algorithm '{algorithmId}' is not supported by provider '{Name}'.");
}
return passwordHashers[algorithmId];
}
public ICryptoHasher GetHasher(string algorithmId)
{
if (!Supports(CryptoCapability.ContentHashing, algorithmId))
{
throw new InvalidOperationException($"Hash algorithm '{algorithmId}' is not supported by provider '{Name}'.");
}
return new DefaultCryptoHasher(algorithmId);
}
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
{
ArgumentNullException.ThrowIfNull(keyReference);
if (!Supports(CryptoCapability.Signing, algorithmId))
{
throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider '{Name}'.");
}
if (!signingKeys.TryGetValue(keyReference.KeyId, out var signingKey))
{
throw new KeyNotFoundException($"Signing key '{keyReference.KeyId}' is not registered with provider '{Name}'.");
}
if (!string.Equals(signingKey.AlgorithmId, algorithmId, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Signing key '{keyReference.KeyId}' is registered for algorithm '{signingKey.AlgorithmId}', not '{algorithmId}'.");
}
return EcdsaSigner.Create(signingKey);
}
public void UpsertSigningKey(CryptoSigningKey signingKey)
{
ArgumentNullException.ThrowIfNull(signingKey);
EnsureSigningSupported(signingKey.AlgorithmId);
if (signingKey.Kind != CryptoSigningKeyKind.Ec)
{
throw new InvalidOperationException($"Provider '{Name}' only accepts EC signing keys.");
}
ValidateSigningKey(signingKey);
signingKeys.AddOrUpdate(signingKey.Reference.KeyId, signingKey, (_, _) => signingKey);
}
public bool RemoveSigningKey(string keyId)
{
if (string.IsNullOrWhiteSpace(keyId))
{
return false;
}
return signingKeys.TryRemove(keyId, out _);
}
public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys()
=> signingKeys.Values.ToArray();
public IEnumerable<CryptoProviderKeyDescriptor> DescribeKeys()
{
foreach (var key in signingKeys.Values)
{
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["kind"] = key.Kind.ToString(),
["createdAt"] = key.CreatedAt.UtcDateTime.ToString("O"),
["providerHint"] = key.Reference.ProviderHint,
["provider"] = Name
};
if (key.ExpiresAt.HasValue)
{
metadata["expiresAt"] = key.ExpiresAt.Value.UtcDateTime.ToString("O");
}
foreach (var pair in key.Metadata)
{
metadata[$"meta.{pair.Key}"] = pair.Value;
}
yield return new CryptoProviderKeyDescriptor(
Name,
key.Reference.KeyId,
key.AlgorithmId,
metadata);
}
}
private static void EnsureSigningSupported(string algorithmId)
{
if (!SupportedSigningAlgorithms.Contains(algorithmId))
{
throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider 'default'.");
}
}
private static void ValidateSigningKey(CryptoSigningKey signingKey)
{
if (!string.Equals(signingKey.AlgorithmId, SignatureAlgorithms.Es256, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Only ES256 signing keys are currently supported by provider 'default'.");
}
var expected = ECCurve.NamedCurves.nistP256;
var curve = signingKey.PrivateParameters.Curve;
if (!curve.IsNamed || !string.Equals(curve.Oid.Value, expected.Oid.Value, StringComparison.Ordinal))
{
throw new InvalidOperationException("ES256 signing keys must use the NIST P-256 curve.");
}
}
}
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Security.Cryptography;
namespace StellaOps.Cryptography;
/// <summary>
/// Default in-process crypto provider exposing password hashing capabilities.
/// </summary>
public sealed class DefaultCryptoProvider : ICryptoProvider, ICryptoProviderDiagnostics
{
private readonly ConcurrentDictionary<string, IPasswordHasher> passwordHashers;
private readonly ConcurrentDictionary<string, CryptoSigningKey> signingKeys;
private static readonly HashSet<string> SupportedSigningAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
SignatureAlgorithms.Es256
};
private static readonly HashSet<string> SupportedHashAlgorithms = new(StringComparer.OrdinalIgnoreCase)
{
HashAlgorithms.Sha256,
HashAlgorithms.Sha384,
HashAlgorithms.Sha512
};
public DefaultCryptoProvider()
{
passwordHashers = new ConcurrentDictionary<string, IPasswordHasher>(StringComparer.OrdinalIgnoreCase);
signingKeys = new ConcurrentDictionary<string, CryptoSigningKey>(StringComparer.Ordinal);
var argon = new Argon2idPasswordHasher();
var pbkdf2 = new Pbkdf2PasswordHasher();
passwordHashers.TryAdd(PasswordHashAlgorithm.Argon2id.ToString(), argon);
passwordHashers.TryAdd(PasswordHashAlgorithms.Argon2id, argon);
passwordHashers.TryAdd(PasswordHashAlgorithm.Pbkdf2.ToString(), pbkdf2);
passwordHashers.TryAdd(PasswordHashAlgorithms.Pbkdf2Sha256, pbkdf2);
}
public string Name => "default";
public bool Supports(CryptoCapability capability, string algorithmId)
{
if (string.IsNullOrWhiteSpace(algorithmId))
{
return false;
}
return capability switch
{
CryptoCapability.PasswordHashing => passwordHashers.ContainsKey(algorithmId),
CryptoCapability.Signing or CryptoCapability.Verification => SupportedSigningAlgorithms.Contains(algorithmId),
CryptoCapability.ContentHashing => SupportedHashAlgorithms.Contains(algorithmId),
_ => false
};
}
public IPasswordHasher GetPasswordHasher(string algorithmId)
{
if (!Supports(CryptoCapability.PasswordHashing, algorithmId))
{
throw new InvalidOperationException($"Password hashing algorithm '{algorithmId}' is not supported by provider '{Name}'.");
}
return passwordHashers[algorithmId];
}
public ICryptoHasher GetHasher(string algorithmId)
{
if (!Supports(CryptoCapability.ContentHashing, algorithmId))
{
throw new InvalidOperationException($"Hash algorithm '{algorithmId}' is not supported by provider '{Name}'.");
}
return new DefaultCryptoHasher(algorithmId);
}
public ICryptoSigner GetSigner(string algorithmId, CryptoKeyReference keyReference)
{
ArgumentNullException.ThrowIfNull(keyReference);
if (!Supports(CryptoCapability.Signing, algorithmId))
{
throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider '{Name}'.");
}
if (!signingKeys.TryGetValue(keyReference.KeyId, out var signingKey))
{
throw new KeyNotFoundException($"Signing key '{keyReference.KeyId}' is not registered with provider '{Name}'.");
}
if (!string.Equals(signingKey.AlgorithmId, algorithmId, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"Signing key '{keyReference.KeyId}' is registered for algorithm '{signingKey.AlgorithmId}', not '{algorithmId}'.");
}
return EcdsaSigner.Create(signingKey);
}
public void UpsertSigningKey(CryptoSigningKey signingKey)
{
ArgumentNullException.ThrowIfNull(signingKey);
EnsureSigningSupported(signingKey.AlgorithmId);
if (signingKey.Kind != CryptoSigningKeyKind.Ec)
{
throw new InvalidOperationException($"Provider '{Name}' only accepts EC signing keys.");
}
ValidateSigningKey(signingKey);
signingKeys.AddOrUpdate(signingKey.Reference.KeyId, signingKey, (_, _) => signingKey);
}
public bool RemoveSigningKey(string keyId)
{
if (string.IsNullOrWhiteSpace(keyId))
{
return false;
}
return signingKeys.TryRemove(keyId, out _);
}
public IReadOnlyCollection<CryptoSigningKey> GetSigningKeys()
=> signingKeys.Values.ToArray();
public IEnumerable<CryptoProviderKeyDescriptor> DescribeKeys()
{
foreach (var key in signingKeys.Values)
{
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["kind"] = key.Kind.ToString(),
["createdAt"] = key.CreatedAt.UtcDateTime.ToString("O"),
["providerHint"] = key.Reference.ProviderHint,
["provider"] = Name
};
if (key.ExpiresAt.HasValue)
{
metadata["expiresAt"] = key.ExpiresAt.Value.UtcDateTime.ToString("O");
}
foreach (var pair in key.Metadata)
{
metadata[$"meta.{pair.Key}"] = pair.Value;
}
yield return new CryptoProviderKeyDescriptor(
Name,
key.Reference.KeyId,
key.AlgorithmId,
metadata);
}
}
private static void EnsureSigningSupported(string algorithmId)
{
if (!SupportedSigningAlgorithms.Contains(algorithmId))
{
throw new InvalidOperationException($"Signing algorithm '{algorithmId}' is not supported by provider 'default'.");
}
}
private static void ValidateSigningKey(CryptoSigningKey signingKey)
{
if (!string.Equals(signingKey.AlgorithmId, SignatureAlgorithms.Es256, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException($"Only ES256 signing keys are currently supported by provider 'default'.");
}
var expected = ECCurve.NamedCurves.nistP256;
var curve = signingKey.PrivateParameters.Curve;
if (!curve.IsNamed || !string.Equals(curve.Oid.Value, expected.Oid.Value, StringComparison.Ordinal))
{
throw new InvalidOperationException("ES256 signing keys must use the NIST P-256 curve.");
}
}
}

View File

@@ -1,11 +1,11 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.DependencyInjection;
public interface IDependencyInjectionRoutine
{
IServiceCollection Register(
IServiceCollection services,
IConfiguration configuration);
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.DependencyInjection;
public interface IDependencyInjectionRoutine
{
IServiceCollection Register(
IServiceCollection services,
IConfiguration configuration);
}

View File

@@ -1,64 +1,64 @@
using System;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.DependencyInjection;
/// <summary>
/// Declares how a plug-in type should be registered with the host dependency injection container.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
public sealed class ServiceBindingAttribute : Attribute
{
/// <summary>
/// Creates a binding that registers the decorated type as itself with a singleton lifetime.
/// </summary>
public ServiceBindingAttribute()
: this(null, ServiceLifetime.Singleton)
{
}
/// <summary>
/// Creates a binding that registers the decorated type as itself with the specified lifetime.
/// </summary>
public ServiceBindingAttribute(ServiceLifetime lifetime)
: this(null, lifetime)
{
}
/// <summary>
/// Creates a binding that registers the decorated type as the specified service type with a singleton lifetime.
/// </summary>
public ServiceBindingAttribute(Type serviceType)
: this(serviceType, ServiceLifetime.Singleton)
{
}
/// <summary>
/// Creates a binding that registers the decorated type as the specified service type.
/// </summary>
public ServiceBindingAttribute(Type? serviceType, ServiceLifetime lifetime)
{
ServiceType = serviceType;
Lifetime = lifetime;
}
/// <summary>
/// The service contract that should resolve to the decorated implementation. When <c>null</c>, the implementation registers itself.
/// </summary>
public Type? ServiceType { get; }
/// <summary>
/// The lifetime that should be used when registering the decorated implementation.
/// </summary>
public ServiceLifetime Lifetime { get; }
/// <summary>
/// Indicates whether existing descriptors for the same service type should be removed before this binding is applied.
/// </summary>
public bool ReplaceExisting { get; init; }
/// <summary>
/// When true, the implementation is also registered as itself even if a service type is specified.
/// </summary>
public bool RegisterAsSelf { get; init; }
}
using System;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.DependencyInjection;
/// <summary>
/// Declares how a plug-in type should be registered with the host dependency injection container.
/// </summary>
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
public sealed class ServiceBindingAttribute : Attribute
{
/// <summary>
/// Creates a binding that registers the decorated type as itself with a singleton lifetime.
/// </summary>
public ServiceBindingAttribute()
: this(null, ServiceLifetime.Singleton)
{
}
/// <summary>
/// Creates a binding that registers the decorated type as itself with the specified lifetime.
/// </summary>
public ServiceBindingAttribute(ServiceLifetime lifetime)
: this(null, lifetime)
{
}
/// <summary>
/// Creates a binding that registers the decorated type as the specified service type with a singleton lifetime.
/// </summary>
public ServiceBindingAttribute(Type serviceType)
: this(serviceType, ServiceLifetime.Singleton)
{
}
/// <summary>
/// Creates a binding that registers the decorated type as the specified service type.
/// </summary>
public ServiceBindingAttribute(Type? serviceType, ServiceLifetime lifetime)
{
ServiceType = serviceType;
Lifetime = lifetime;
}
/// <summary>
/// The service contract that should resolve to the decorated implementation. When <c>null</c>, the implementation registers itself.
/// </summary>
public Type? ServiceType { get; }
/// <summary>
/// The lifetime that should be used when registering the decorated implementation.
/// </summary>
public ServiceLifetime Lifetime { get; }
/// <summary>
/// Indicates whether existing descriptors for the same service type should be removed before this binding is applied.
/// </summary>
public bool ReplaceExisting { get; init; }
/// <summary>
/// When true, the implementation is also registered as itself even if a service type is specified.
/// </summary>
public bool RegisterAsSelf { get; init; }
}

View File

@@ -0,0 +1,40 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Messaging.Transport.Valkey;
/// <summary>
/// Configuration options for the Valkey/Redis transport.
/// </summary>
public class ValkeyTransportOptions
{
/// <summary>
/// Gets or sets the connection string (e.g., "localhost:6379" or "valkey:6379,password=secret").
/// </summary>
[Required]
public string ConnectionString { get; set; } = "localhost:6379";
/// <summary>
/// Gets or sets the default database number.
/// </summary>
public int? Database { get; set; }
/// <summary>
/// Gets or sets the connection initialization timeout.
/// </summary>
public TimeSpan InitializationTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets or sets the number of connection retries.
/// </summary>
public int ConnectRetry { get; set; } = 3;
/// <summary>
/// Gets or sets whether to abort on connect fail.
/// </summary>
public bool AbortOnConnectFail { get; set; } = false;
/// <summary>
/// Gets or sets the prefix for idempotency keys.
/// </summary>
public string IdempotencyKeyPrefix { get; set; } = "msgq:idem:";
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Messaging.Transport.Valkey</RootNamespace>
<AssemblyName>StellaOps.Messaging.Transport.Valkey</AssemblyName>
<Description>Valkey/Redis transport plugin for StellaOps.Messaging</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
<PackageReference Include="StackExchange.Redis" Version="2.8.37" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Messaging/StellaOps.Messaging.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,110 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
namespace StellaOps.Messaging.Transport.Valkey;
/// <summary>
/// Factory for creating and managing Valkey/Redis connections.
/// </summary>
public sealed class ValkeyConnectionFactory : IAsyncDisposable
{
private readonly ValkeyTransportOptions _options;
private readonly ILogger<ValkeyConnectionFactory>? _logger;
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private readonly Func<ConfigurationOptions, Task<IConnectionMultiplexer>> _connectionFactory;
private IConnectionMultiplexer? _connection;
private bool _disposed;
public ValkeyConnectionFactory(
IOptions<ValkeyTransportOptions> options,
ILogger<ValkeyConnectionFactory>? logger = null,
Func<ConfigurationOptions, Task<IConnectionMultiplexer>>? connectionFactory = null)
{
_options = options.Value;
_logger = logger;
_connectionFactory = connectionFactory ??
(config => Task.FromResult<IConnectionMultiplexer>(ConnectionMultiplexer.Connect(config)));
}
/// <summary>
/// Gets a database connection.
/// </summary>
public async ValueTask<IDatabase> GetDatabaseAsync(CancellationToken cancellationToken = default)
{
var connection = await GetConnectionAsync(cancellationToken).ConfigureAwait(false);
return connection.GetDatabase(_options.Database ?? -1);
}
/// <summary>
/// Gets the underlying connection multiplexer.
/// </summary>
public async ValueTask<IConnectionMultiplexer> GetConnectionAsync(CancellationToken cancellationToken = default)
{
if (_connection is not null && _connection.IsConnected)
{
return _connection;
}
await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_connection is null || !_connection.IsConnected)
{
if (_connection is not null)
{
await _connection.CloseAsync().ConfigureAwait(false);
_connection.Dispose();
}
var config = ConfigurationOptions.Parse(_options.ConnectionString);
config.AbortOnConnectFail = _options.AbortOnConnectFail;
config.ConnectTimeout = (int)_options.InitializationTimeout.TotalMilliseconds;
config.ConnectRetry = _options.ConnectRetry;
if (_options.Database.HasValue)
{
config.DefaultDatabase = _options.Database.Value;
}
_logger?.LogDebug("Connecting to Valkey at {Endpoint}", _options.ConnectionString);
_connection = await _connectionFactory(config).ConfigureAwait(false);
_logger?.LogInformation("Connected to Valkey");
}
}
finally
{
_connectionLock.Release();
}
return _connection;
}
/// <summary>
/// Tests the connection by sending a PING command.
/// </summary>
public async ValueTask PingAsync(CancellationToken cancellationToken = default)
{
var db = await GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await db.ExecuteAsync("PING").ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_connection is not null)
{
await _connection.CloseAsync().ConfigureAwait(false);
_connection.Dispose();
}
_connectionLock.Dispose();
}
}

View File

@@ -0,0 +1,98 @@
using StellaOps.Messaging.Abstractions;
namespace StellaOps.Messaging.Transport.Valkey;
/// <summary>
/// Valkey/Redis implementation of a message lease.
/// </summary>
/// <typeparam name="TMessage">The message type.</typeparam>
internal sealed class ValkeyMessageLease<TMessage> : IMessageLease<TMessage> where TMessage : class
{
private readonly ValkeyMessageQueue<TMessage> _queue;
private int _completed;
internal ValkeyMessageLease(
ValkeyMessageQueue<TMessage> queue,
string messageId,
TMessage message,
int attempt,
DateTimeOffset enqueuedAt,
DateTimeOffset leaseExpiresAt,
string consumer,
string? tenantId,
string? correlationId,
IReadOnlyDictionary<string, string>? headers)
{
_queue = queue;
MessageId = messageId;
Message = message;
Attempt = attempt;
EnqueuedAt = enqueuedAt;
LeaseExpiresAt = leaseExpiresAt;
Consumer = consumer;
TenantId = tenantId;
CorrelationId = correlationId;
Headers = headers;
}
/// <inheritdoc />
public string MessageId { get; }
/// <inheritdoc />
public TMessage Message { get; }
/// <inheritdoc />
public int Attempt { get; private set; }
/// <inheritdoc />
public DateTimeOffset EnqueuedAt { get; }
/// <inheritdoc />
public DateTimeOffset LeaseExpiresAt { get; private set; }
/// <inheritdoc />
public string Consumer { get; }
/// <inheritdoc />
public string? TenantId { get; }
/// <inheritdoc />
public string? CorrelationId { get; }
/// <summary>
/// Gets the message headers.
/// </summary>
public IReadOnlyDictionary<string, string>? Headers { get; }
/// <inheritdoc />
public ValueTask AcknowledgeAsync(CancellationToken cancellationToken = default)
=> _queue.AcknowledgeAsync(this, cancellationToken);
/// <inheritdoc />
public ValueTask RenewAsync(TimeSpan extension, CancellationToken cancellationToken = default)
=> _queue.RenewLeaseAsync(this, extension, cancellationToken);
/// <inheritdoc />
public ValueTask ReleaseAsync(ReleaseDisposition disposition, CancellationToken cancellationToken = default)
=> _queue.ReleaseAsync(this, disposition, cancellationToken);
/// <inheritdoc />
public ValueTask DeadLetterAsync(string reason, CancellationToken cancellationToken = default)
=> _queue.DeadLetterAsync(this, reason, cancellationToken);
/// <inheritdoc />
public ValueTask DisposeAsync()
{
// No resources to dispose - lease state is managed by the queue
return ValueTask.CompletedTask;
}
internal bool TryBeginCompletion()
=> Interlocked.CompareExchange(ref _completed, 1, 0) == 0;
internal void RefreshLease(DateTimeOffset expiresAt)
=> LeaseExpiresAt = expiresAt;
internal void IncrementAttempt()
=> Attempt++;
}

View File

@@ -0,0 +1,640 @@
using System.Buffers;
using System.Collections.ObjectModel;
using System.Text.Json;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Messaging.Abstractions;
using StackExchange.Redis;
namespace StellaOps.Messaging.Transport.Valkey;
/// <summary>
/// Valkey/Redis Streams implementation of <see cref="IMessageQueue{TMessage}"/>.
/// </summary>
/// <typeparam name="TMessage">The message type.</typeparam>
public sealed class ValkeyMessageQueue<TMessage> : IMessageQueue<TMessage>, IAsyncDisposable
where TMessage : class
{
private const string ProviderNameValue = "valkey";
private static class Fields
{
public const string Payload = "payload";
public const string TenantId = "tenant";
public const string CorrelationId = "correlation";
public const string IdempotencyKey = "idem";
public const string Attempt = "attempt";
public const string EnqueuedAt = "enq_at";
public const string HeaderPrefix = "h:";
}
private readonly ValkeyConnectionFactory _connectionFactory;
private readonly MessageQueueOptions _queueOptions;
private readonly ValkeyTransportOptions _transportOptions;
private readonly ILogger<ValkeyMessageQueue<TMessage>>? _logger;
private readonly TimeProvider _timeProvider;
private readonly SemaphoreSlim _groupInitLock = new(1, 1);
private readonly JsonSerializerOptions _jsonOptions;
private volatile bool _groupInitialized;
private bool _disposed;
public ValkeyMessageQueue(
ValkeyConnectionFactory connectionFactory,
MessageQueueOptions queueOptions,
ValkeyTransportOptions transportOptions,
ILogger<ValkeyMessageQueue<TMessage>>? logger = null,
TimeProvider? timeProvider = null,
JsonSerializerOptions? jsonOptions = null)
{
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
_queueOptions = queueOptions ?? throw new ArgumentNullException(nameof(queueOptions));
_transportOptions = transportOptions ?? throw new ArgumentNullException(nameof(transportOptions));
_logger = logger;
_timeProvider = timeProvider ?? TimeProvider.System;
_jsonOptions = jsonOptions ?? new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = false
};
}
/// <inheritdoc />
public string ProviderName => ProviderNameValue;
/// <inheritdoc />
public string QueueName => _queueOptions.QueueName;
/// <inheritdoc />
public async ValueTask<EnqueueResult> EnqueueAsync(
TMessage message,
EnqueueOptions? options = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
cancellationToken.ThrowIfCancellationRequested();
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);
var now = _timeProvider.GetUtcNow();
var entries = BuildEntries(message, now, 1, options);
var messageId = await AddToStreamAsync(
db,
_queueOptions.QueueName,
entries,
_queueOptions.ApproximateMaxLength)
.ConfigureAwait(false);
// Handle idempotency if key provided
if (!string.IsNullOrWhiteSpace(options?.IdempotencyKey))
{
var idempotencyKey = BuildIdempotencyKey(options.IdempotencyKey);
var stored = await db.StringSetAsync(
idempotencyKey,
messageId,
when: When.NotExists,
expiry: _queueOptions.IdempotencyWindow)
.ConfigureAwait(false);
if (!stored)
{
// Duplicate detected - delete the message we just added and return existing
await db.StreamDeleteAsync(_queueOptions.QueueName, [(RedisValue)messageId]).ConfigureAwait(false);
var existing = await db.StringGetAsync(idempotencyKey).ConfigureAwait(false);
var existingId = existing.IsNullOrEmpty ? messageId : existing.ToString();
_logger?.LogDebug(
"Duplicate enqueue detected for queue {Queue} with key {Key}; returning existing id {MessageId}",
_queueOptions.QueueName, idempotencyKey, existingId);
return EnqueueResult.Duplicate(existingId);
}
}
_logger?.LogDebug("Enqueued message to {Queue} with id {MessageId}", _queueOptions.QueueName, messageId);
return EnqueueResult.Succeeded(messageId);
}
/// <inheritdoc />
public async ValueTask<IReadOnlyList<IMessageLease<TMessage>>> LeaseAsync(
LeaseRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
cancellationToken.ThrowIfCancellationRequested();
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);
var consumer = _queueOptions.ConsumerName ?? $"{Environment.MachineName}-{Environment.ProcessId}";
StreamEntry[] entries;
if (request.PendingOnly)
{
// Read from pending only (redeliveries)
entries = await db.StreamReadGroupAsync(
_queueOptions.QueueName,
_queueOptions.ConsumerGroup,
consumer,
position: "0",
count: request.BatchSize)
.ConfigureAwait(false);
}
else
{
// Read new messages
entries = await db.StreamReadGroupAsync(
_queueOptions.QueueName,
_queueOptions.ConsumerGroup,
consumer,
position: ">",
count: request.BatchSize)
.ConfigureAwait(false);
}
if (entries is null || entries.Length == 0)
{
return [];
}
var now = _timeProvider.GetUtcNow();
var leaseDuration = request.LeaseDuration ?? _queueOptions.DefaultLeaseDuration;
var leases = new List<IMessageLease<TMessage>>(entries.Length);
foreach (var entry in entries)
{
var lease = TryMapLease(entry, consumer, now, leaseDuration, attemptOverride: null);
if (lease is null)
{
await HandlePoisonEntryAsync(db, entry.Id).ConfigureAwait(false);
continue;
}
leases.Add(lease);
}
return leases;
}
/// <inheritdoc />
public async ValueTask<IReadOnlyList<IMessageLease<TMessage>>> ClaimExpiredAsync(
ClaimRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
cancellationToken.ThrowIfCancellationRequested();
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await EnsureConsumerGroupAsync(db, cancellationToken).ConfigureAwait(false);
var consumer = _queueOptions.ConsumerName ?? $"{Environment.MachineName}-{Environment.ProcessId}";
var pending = await db.StreamPendingMessagesAsync(
_queueOptions.QueueName,
_queueOptions.ConsumerGroup,
request.BatchSize,
RedisValue.Null,
(long)request.MinIdleTime.TotalMilliseconds)
.ConfigureAwait(false);
if (pending is null || pending.Length == 0)
{
return [];
}
var eligible = pending
.Where(info => info.IdleTimeInMilliseconds >= request.MinIdleTime.TotalMilliseconds
&& info.DeliveryCount >= request.MinDeliveryAttempts)
.ToArray();
if (eligible.Length == 0)
{
return [];
}
var messageIds = eligible.Select(info => (RedisValue)info.MessageId).ToArray();
var claimed = await db.StreamClaimAsync(
_queueOptions.QueueName,
_queueOptions.ConsumerGroup,
consumer,
0,
messageIds)
.ConfigureAwait(false);
if (claimed is null || claimed.Length == 0)
{
return [];
}
var now = _timeProvider.GetUtcNow();
var leaseDuration = request.LeaseDuration ?? _queueOptions.DefaultLeaseDuration;
var attemptLookup = eligible.ToDictionary(
info => info.MessageId.IsNullOrEmpty ? string.Empty : info.MessageId.ToString(),
info => (int)Math.Max(1, info.DeliveryCount),
StringComparer.Ordinal);
var leases = new List<IMessageLease<TMessage>>(claimed.Length);
foreach (var entry in claimed)
{
var entryId = entry.Id.ToString();
attemptLookup.TryGetValue(entryId, out var attempt);
var lease = TryMapLease(entry, consumer, now, leaseDuration, attemptOverride: attempt);
if (lease is null)
{
await HandlePoisonEntryAsync(db, entry.Id).ConfigureAwait(false);
continue;
}
leases.Add(lease);
}
return leases;
}
/// <inheritdoc />
public async ValueTask<long> GetPendingCountAsync(CancellationToken cancellationToken = default)
{
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
var info = await db.StreamPendingAsync(_queueOptions.QueueName, _queueOptions.ConsumerGroup).ConfigureAwait(false);
return info.PendingMessageCount;
}
internal async ValueTask AcknowledgeAsync(ValkeyMessageLease<TMessage> lease, CancellationToken cancellationToken)
{
if (!lease.TryBeginCompletion())
{
return;
}
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await db.StreamAcknowledgeAsync(
_queueOptions.QueueName,
_queueOptions.ConsumerGroup,
[(RedisValue)lease.MessageId])
.ConfigureAwait(false);
await db.StreamDeleteAsync(_queueOptions.QueueName, [(RedisValue)lease.MessageId]).ConfigureAwait(false);
_logger?.LogDebug("Acknowledged message {MessageId} from queue {Queue}", lease.MessageId, _queueOptions.QueueName);
}
internal async ValueTask RenewLeaseAsync(ValkeyMessageLease<TMessage> lease, TimeSpan extension, CancellationToken cancellationToken)
{
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
await db.StreamClaimAsync(
_queueOptions.QueueName,
_queueOptions.ConsumerGroup,
lease.Consumer,
0,
[(RedisValue)lease.MessageId])
.ConfigureAwait(false);
var expires = _timeProvider.GetUtcNow().Add(extension);
lease.RefreshLease(expires);
}
internal async ValueTask ReleaseAsync(
ValkeyMessageLease<TMessage> lease,
ReleaseDisposition disposition,
CancellationToken cancellationToken)
{
if (disposition == ReleaseDisposition.Retry && lease.Attempt >= _queueOptions.MaxDeliveryAttempts)
{
await DeadLetterAsync(lease, $"max-delivery-attempts:{lease.Attempt}", cancellationToken).ConfigureAwait(false);
return;
}
if (!lease.TryBeginCompletion())
{
return;
}
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
// Acknowledge and delete the current entry
await db.StreamAcknowledgeAsync(
_queueOptions.QueueName,
_queueOptions.ConsumerGroup,
[(RedisValue)lease.MessageId])
.ConfigureAwait(false);
await db.StreamDeleteAsync(_queueOptions.QueueName, [(RedisValue)lease.MessageId]).ConfigureAwait(false);
if (disposition == ReleaseDisposition.Retry)
{
lease.IncrementAttempt();
// Calculate backoff delay
var backoff = CalculateBackoff(lease.Attempt);
if (backoff > TimeSpan.Zero)
{
try
{
await Task.Delay(backoff, cancellationToken).ConfigureAwait(false);
}
catch (TaskCanceledException)
{
return;
}
}
// Re-enqueue with incremented attempt
var now = _timeProvider.GetUtcNow();
var entries = BuildEntries(lease.Message, now, lease.Attempt, null);
await AddToStreamAsync(db, _queueOptions.QueueName, entries, _queueOptions.ApproximateMaxLength)
.ConfigureAwait(false);
_logger?.LogDebug("Retrying message {MessageId}, attempt {Attempt}", lease.MessageId, lease.Attempt);
}
}
internal async ValueTask DeadLetterAsync(ValkeyMessageLease<TMessage> lease, string reason, CancellationToken cancellationToken)
{
if (!lease.TryBeginCompletion())
{
return;
}
var db = await _connectionFactory.GetDatabaseAsync(cancellationToken).ConfigureAwait(false);
// Acknowledge and delete from main queue
await db.StreamAcknowledgeAsync(
_queueOptions.QueueName,
_queueOptions.ConsumerGroup,
[(RedisValue)lease.MessageId])
.ConfigureAwait(false);
await db.StreamDeleteAsync(_queueOptions.QueueName, [(RedisValue)lease.MessageId]).ConfigureAwait(false);
// Move to dead-letter queue if configured
if (!string.IsNullOrWhiteSpace(_queueOptions.DeadLetterQueue))
{
var now = _timeProvider.GetUtcNow();
var entries = BuildEntries(lease.Message, now, lease.Attempt, null);
await AddToStreamAsync(db, _queueOptions.DeadLetterQueue, entries, null).ConfigureAwait(false);
_logger?.LogWarning(
"Dead-lettered message {MessageId} after {Attempt} attempt(s): {Reason}",
lease.MessageId, lease.Attempt, reason);
}
else
{
_logger?.LogWarning(
"Dropped message {MessageId} after {Attempt} attempt(s); dead-letter queue not configured. Reason: {Reason}",
lease.MessageId, lease.Attempt, reason);
}
}
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
_groupInitLock.Dispose();
}
private string BuildIdempotencyKey(string key) => $"{_transportOptions.IdempotencyKeyPrefix}{key}";
private TimeSpan CalculateBackoff(int attempt)
{
if (attempt <= 1)
{
return _queueOptions.RetryInitialBackoff;
}
var initial = _queueOptions.RetryInitialBackoff;
var max = _queueOptions.RetryMaxBackoff;
var multiplier = _queueOptions.RetryBackoffMultiplier;
var scaledTicks = initial.Ticks * Math.Pow(multiplier, attempt - 1);
var cappedTicks = Math.Min(max.Ticks, scaledTicks);
return TimeSpan.FromTicks((long)Math.Max(initial.Ticks, cappedTicks));
}
private async Task EnsureConsumerGroupAsync(IDatabase database, CancellationToken cancellationToken)
{
if (_groupInitialized)
{
return;
}
await _groupInitLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_groupInitialized)
{
return;
}
try
{
await database.StreamCreateConsumerGroupAsync(
_queueOptions.QueueName,
_queueOptions.ConsumerGroup,
StreamPosition.Beginning,
createStream: true)
.ConfigureAwait(false);
}
catch (RedisServerException ex) when (ex.Message.Contains("BUSYGROUP", StringComparison.OrdinalIgnoreCase))
{
// Group already exists
}
_groupInitialized = true;
}
finally
{
_groupInitLock.Release();
}
}
private NameValueEntry[] BuildEntries(TMessage message, DateTimeOffset enqueuedAt, int attempt, EnqueueOptions? options)
{
var headerCount = options?.Headers?.Count ?? 0;
var entries = ArrayPool<NameValueEntry>.Shared.Rent(6 + headerCount);
var index = 0;
entries[index++] = new NameValueEntry(Fields.Payload, JsonSerializer.Serialize(message, _jsonOptions));
entries[index++] = new NameValueEntry(Fields.Attempt, attempt);
entries[index++] = new NameValueEntry(Fields.EnqueuedAt, enqueuedAt.ToUnixTimeMilliseconds());
if (!string.IsNullOrWhiteSpace(options?.TenantId))
{
entries[index++] = new NameValueEntry(Fields.TenantId, options.TenantId);
}
if (!string.IsNullOrWhiteSpace(options?.CorrelationId))
{
entries[index++] = new NameValueEntry(Fields.CorrelationId, options.CorrelationId);
}
if (!string.IsNullOrWhiteSpace(options?.IdempotencyKey))
{
entries[index++] = new NameValueEntry(Fields.IdempotencyKey, options.IdempotencyKey);
}
if (options?.Headers is not null)
{
foreach (var kvp in options.Headers)
{
entries[index++] = new NameValueEntry(Fields.HeaderPrefix + kvp.Key, kvp.Value);
}
}
var result = entries.AsSpan(0, index).ToArray();
ArrayPool<NameValueEntry>.Shared.Return(entries, clearArray: true);
return result;
}
private ValkeyMessageLease<TMessage>? TryMapLease(
StreamEntry entry,
string consumer,
DateTimeOffset now,
TimeSpan leaseDuration,
int? attemptOverride)
{
if (entry.Values is null || entry.Values.Length == 0)
{
return null;
}
string? payload = null;
string? tenantId = null;
string? correlationId = null;
long? enqueuedAtUnix = null;
var attempt = attemptOverride ?? 1;
Dictionary<string, string>? headers = null;
foreach (var field in entry.Values)
{
var name = field.Name.ToString();
var value = field.Value;
if (name.Equals(Fields.Payload, StringComparison.Ordinal))
{
payload = value.ToString();
}
else if (name.Equals(Fields.TenantId, StringComparison.Ordinal))
{
tenantId = NormalizeOptional(value.ToString());
}
else if (name.Equals(Fields.CorrelationId, StringComparison.Ordinal))
{
correlationId = NormalizeOptional(value.ToString());
}
else if (name.Equals(Fields.EnqueuedAt, StringComparison.Ordinal))
{
if (long.TryParse(value.ToString(), out var unixMs))
{
enqueuedAtUnix = unixMs;
}
}
else if (name.Equals(Fields.Attempt, StringComparison.Ordinal))
{
if (int.TryParse(value.ToString(), out var parsedAttempt))
{
attempt = attemptOverride.HasValue
? Math.Max(attemptOverride.Value, parsedAttempt)
: Math.Max(1, parsedAttempt);
}
}
else if (name.StartsWith(Fields.HeaderPrefix, StringComparison.Ordinal))
{
headers ??= new Dictionary<string, string>(StringComparer.Ordinal);
var key = name[Fields.HeaderPrefix.Length..];
headers[key] = value.ToString();
}
}
if (payload is null || enqueuedAtUnix is null)
{
return null;
}
TMessage message;
try
{
message = JsonSerializer.Deserialize<TMessage>(payload, _jsonOptions)!;
}
catch
{
return null;
}
var enqueuedAt = DateTimeOffset.FromUnixTimeMilliseconds(enqueuedAtUnix.Value);
var leaseExpires = now.Add(leaseDuration);
IReadOnlyDictionary<string, string>? headersView = headers is null || headers.Count == 0
? null
: new ReadOnlyDictionary<string, string>(headers);
return new ValkeyMessageLease<TMessage>(
this,
entry.Id.ToString(),
message,
attempt,
enqueuedAt,
leaseExpires,
consumer,
tenantId,
correlationId,
headersView);
}
private async Task HandlePoisonEntryAsync(IDatabase database, RedisValue entryId)
{
await database.StreamAcknowledgeAsync(
_queueOptions.QueueName,
_queueOptions.ConsumerGroup,
[entryId])
.ConfigureAwait(false);
await database.StreamDeleteAsync(_queueOptions.QueueName, [entryId]).ConfigureAwait(false);
_logger?.LogWarning("Removed poison entry {EntryId} from queue {Queue}", entryId, _queueOptions.QueueName);
}
private async Task<string> AddToStreamAsync(
IDatabase database,
string stream,
NameValueEntry[] entries,
int? maxLength)
{
var capacity = 4 + (entries.Length * 2);
var args = new List<object>(capacity) { (RedisKey)stream };
if (maxLength.HasValue)
{
args.Add("MAXLEN");
args.Add("~");
args.Add(maxLength.Value);
}
args.Add("*");
foreach (var entry in entries)
{
args.Add((RedisValue)entry.Name);
args.Add(entry.Value);
}
var result = await database.ExecuteAsync("XADD", [.. args]).ConfigureAwait(false);
return result!.ToString()!;
}
private static string? NormalizeOptional(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value;
}

View File

@@ -0,0 +1,69 @@
namespace StellaOps.Messaging.Abstractions;
/// <summary>
/// Transport-agnostic distributed cache interface.
/// </summary>
/// <typeparam name="TKey">The key type.</typeparam>
/// <typeparam name="TValue">The value type.</typeparam>
public interface IDistributedCache<TKey, TValue>
{
/// <summary>
/// Gets the provider name for diagnostics (e.g., "valkey", "postgres").
/// </summary>
string ProviderName { get; }
/// <summary>
/// Gets a value from the cache.
/// </summary>
/// <param name="key">The cache key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The cache result.</returns>
ValueTask<CacheResult<TValue>> GetAsync(TKey key, CancellationToken cancellationToken = default);
/// <summary>
/// Sets a value in the cache.
/// </summary>
/// <param name="key">The cache key.</param>
/// <param name="value">The value to cache.</param>
/// <param name="options">Optional cache entry options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask SetAsync(TKey key, TValue value, CacheEntryOptions? options = null, CancellationToken cancellationToken = default);
/// <summary>
/// Removes a value from the cache.
/// </summary>
/// <param name="key">The cache key.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if the key existed and was removed.</returns>
ValueTask<bool> InvalidateAsync(TKey key, CancellationToken cancellationToken = default);
/// <summary>
/// Removes values matching a pattern from the cache.
/// </summary>
/// <param name="pattern">The key pattern (supports wildcards).</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The number of keys invalidated.</returns>
ValueTask<long> InvalidateByPatternAsync(string pattern, CancellationToken cancellationToken = default);
/// <summary>
/// Gets or sets a value in the cache, using a factory function if the value is not present.
/// </summary>
/// <param name="key">The cache key.</param>
/// <param name="factory">Factory function to create the value if not cached.</param>
/// <param name="options">Optional cache entry options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The cached or newly created value.</returns>
ValueTask<TValue> GetOrSetAsync(
TKey key,
Func<CancellationToken, ValueTask<TValue>> factory,
CacheEntryOptions? options = null,
CancellationToken cancellationToken = default);
}
/// <summary>
/// Simple string-keyed distributed cache interface.
/// </summary>
/// <typeparam name="TValue">The value type.</typeparam>
public interface IDistributedCache<TValue> : IDistributedCache<string, TValue>
{
}

View File

@@ -0,0 +1,99 @@
namespace StellaOps.Messaging.Abstractions;
/// <summary>
/// Represents a leased message from a queue.
/// The lease provides exclusive access to process the message.
/// </summary>
/// <typeparam name="TMessage">The message type.</typeparam>
public interface IMessageLease<out TMessage> : IAsyncDisposable where TMessage : class
{
/// <summary>
/// Gets the unique message identifier.
/// </summary>
string MessageId { get; }
/// <summary>
/// Gets the message payload.
/// </summary>
TMessage Message { get; }
/// <summary>
/// Gets the delivery attempt number (1-based).
/// </summary>
int Attempt { get; }
/// <summary>
/// Gets the timestamp when the message was enqueued.
/// </summary>
DateTimeOffset EnqueuedAt { get; }
/// <summary>
/// Gets the timestamp when the lease expires.
/// </summary>
DateTimeOffset LeaseExpiresAt { get; }
/// <summary>
/// Gets the consumer name that owns this lease.
/// </summary>
string Consumer { get; }
/// <summary>
/// Gets the tenant identifier, if present.
/// </summary>
string? TenantId { get; }
/// <summary>
/// Gets the correlation identifier for tracing, if present.
/// </summary>
string? CorrelationId { get; }
/// <summary>
/// Acknowledges successful processing of the message.
/// The message is removed from the queue.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask AcknowledgeAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Extends the lease duration.
/// </summary>
/// <param name="extension">The time to extend the lease by.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask RenewAsync(TimeSpan extension, CancellationToken cancellationToken = default);
/// <summary>
/// Releases the lease with the specified disposition.
/// </summary>
/// <param name="disposition">How to handle the message after release.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask ReleaseAsync(ReleaseDisposition disposition, CancellationToken cancellationToken = default);
/// <summary>
/// Moves the message to the dead-letter queue.
/// </summary>
/// <param name="reason">The reason for dead-lettering.</param>
/// <param name="cancellationToken">Cancellation token.</param>
ValueTask DeadLetterAsync(string reason, CancellationToken cancellationToken = default);
}
/// <summary>
/// Specifies how to handle a message when releasing a lease.
/// </summary>
public enum ReleaseDisposition
{
/// <summary>
/// Retry the message (make it available for redelivery).
/// </summary>
Retry,
/// <summary>
/// Delay the message before making it available again.
/// </summary>
Delay,
/// <summary>
/// Abandon the message (do not retry, but don't dead-letter either).
/// Implementation may vary by transport.
/// </summary>
Abandon
}

View File

@@ -0,0 +1,59 @@
namespace StellaOps.Messaging.Abstractions;
/// <summary>
/// Transport-agnostic message queue interface.
/// Consumers depend only on this abstraction without knowing which transport is used.
/// </summary>
/// <typeparam name="TMessage">The message type.</typeparam>
public interface IMessageQueue<TMessage> where TMessage : class
{
/// <summary>
/// Gets the provider name for diagnostics (e.g., "valkey", "nats", "postgres").
/// </summary>
string ProviderName { get; }
/// <summary>
/// Gets the queue/stream name.
/// </summary>
string QueueName { get; }
/// <summary>
/// Enqueues a message to the queue.
/// </summary>
/// <param name="message">The message to enqueue.</param>
/// <param name="options">Optional enqueue options.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The result of the enqueue operation.</returns>
ValueTask<EnqueueResult> EnqueueAsync(
TMessage message,
EnqueueOptions? options = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Leases messages from the queue for processing.
/// Messages remain invisible to other consumers until acknowledged or lease expires.
/// </summary>
/// <param name="request">The lease request parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of message leases.</returns>
ValueTask<IReadOnlyList<IMessageLease<TMessage>>> LeaseAsync(
LeaseRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Claims expired leases from other consumers (pending entry list recovery).
/// </summary>
/// <param name="request">The claim request parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of claimed message leases.</returns>
ValueTask<IReadOnlyList<IMessageLease<TMessage>>> ClaimExpiredAsync(
ClaimRequest request,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets the approximate number of pending messages in the queue.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The approximate pending message count.</returns>
ValueTask<long> GetPendingCountAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,48 @@
namespace StellaOps.Messaging.Abstractions;
/// <summary>
/// Factory for creating message queue instances.
/// </summary>
public interface IMessageQueueFactory
{
/// <summary>
/// Gets the provider name for this factory.
/// </summary>
string ProviderName { get; }
/// <summary>
/// Creates a message queue for the specified message type and options.
/// </summary>
/// <typeparam name="TMessage">The message type.</typeparam>
/// <param name="options">The queue options.</param>
/// <returns>A configured message queue instance.</returns>
IMessageQueue<TMessage> Create<TMessage>(MessageQueueOptions options) where TMessage : class;
}
/// <summary>
/// Factory for creating distributed cache instances.
/// </summary>
public interface IDistributedCacheFactory
{
/// <summary>
/// Gets the provider name for this factory.
/// </summary>
string ProviderName { get; }
/// <summary>
/// Creates a distributed cache for the specified key and value types.
/// </summary>
/// <typeparam name="TKey">The key type.</typeparam>
/// <typeparam name="TValue">The value type.</typeparam>
/// <param name="options">The cache options.</param>
/// <returns>A configured distributed cache instance.</returns>
IDistributedCache<TKey, TValue> Create<TKey, TValue>(CacheOptions options);
/// <summary>
/// Creates a string-keyed distributed cache.
/// </summary>
/// <typeparam name="TValue">The value type.</typeparam>
/// <param name="options">The cache options.</param>
/// <returns>A configured distributed cache instance.</returns>
IDistributedCache<TValue> Create<TValue>(CacheOptions options);
}

View File

@@ -0,0 +1,125 @@
using System.Reflection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Messaging.Abstractions;
using StellaOps.Messaging.Plugins;
namespace StellaOps.Messaging.DependencyInjection;
/// <summary>
/// Extension methods for registering messaging services.
/// </summary>
public static class MessagingServiceCollectionExtensions
{
/// <summary>
/// Adds messaging services with plugin-based transport discovery.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration.</param>
/// <param name="configure">Optional configuration callback.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddMessagingPlugins(
this IServiceCollection services,
IConfiguration configuration,
Action<MessagingPluginOptions>? configure = null)
{
var options = new MessagingPluginOptions();
configure?.Invoke(options);
services.AddSingleton<MessagingPluginLoader>();
var loader = new MessagingPluginLoader();
var plugins = loader.LoadFromDirectory(options.PluginDirectory, options.SearchPattern);
// Also load from assemblies in the current domain that might contain plugins
var domainAssemblies = AppDomain.CurrentDomain.GetAssemblies()
.Where(a => a.GetName().Name?.StartsWith("StellaOps.Messaging.Transport.") == true);
var domainPlugins = loader.LoadFromAssemblies(domainAssemblies);
var allPlugins = plugins.Concat(domainPlugins)
.GroupBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
.Select(g => g.First())
.ToList();
var registered = loader.RegisterConfiguredTransport(
allPlugins,
services,
configuration,
options.ConfigurationSection);
if (!registered && options.RequireTransport)
{
throw new InvalidOperationException(
$"No messaging transport configured. Set '{options.ConfigurationSection}:transport' to one of: {string.Join(", ", allPlugins.Select(p => p.Name))}");
}
return services;
}
/// <summary>
/// Adds messaging services with a specific transport plugin.
/// </summary>
/// <typeparam name="TPlugin">The transport plugin type.</typeparam>
/// <param name="services">The service collection.</param>
/// <param name="configuration">The configuration.</param>
/// <param name="configSection">The configuration section for the transport.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddMessagingTransport<TPlugin>(
this IServiceCollection services,
IConfiguration configuration,
string configSection = "messaging")
where TPlugin : IMessagingTransportPlugin, new()
{
var plugin = new TPlugin();
var context = new MessagingTransportRegistrationContext(
services,
configuration,
$"{configSection}:{plugin.Name}");
plugin.Register(context);
return services;
}
/// <summary>
/// Adds a message queue for a specific message type.
/// </summary>
/// <typeparam name="TMessage">The message type.</typeparam>
/// <param name="services">The service collection.</param>
/// <param name="options">The queue options.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddMessageQueue<TMessage>(
this IServiceCollection services,
MessageQueueOptions options)
where TMessage : class
{
services.AddSingleton(sp =>
{
var factory = sp.GetRequiredService<IMessageQueueFactory>();
return factory.Create<TMessage>(options);
});
return services;
}
/// <summary>
/// Adds a distributed cache for a specific value type.
/// </summary>
/// <typeparam name="TValue">The value type.</typeparam>
/// <param name="services">The service collection.</param>
/// <param name="options">The cache options.</param>
/// <returns>The service collection.</returns>
public static IServiceCollection AddDistributedCache<TValue>(
this IServiceCollection services,
CacheOptions options)
{
services.AddSingleton(sp =>
{
var factory = sp.GetRequiredService<IDistributedCacheFactory>();
return factory.Create<TValue>(options);
});
return services;
}
}

View File

@@ -0,0 +1,59 @@
namespace StellaOps.Messaging;
/// <summary>
/// Configuration options for a distributed cache.
/// </summary>
public class CacheOptions
{
/// <summary>
/// Gets or sets the key prefix for all cache entries.
/// </summary>
public string? KeyPrefix { get; set; }
/// <summary>
/// Gets or sets the default time-to-live for cache entries.
/// </summary>
public TimeSpan? DefaultTtl { get; set; }
/// <summary>
/// Gets or sets whether to use sliding expiration.
/// If true, TTL is reset on each access.
/// </summary>
public bool SlidingExpiration { get; set; }
}
/// <summary>
/// Options for individual cache entries.
/// </summary>
public class CacheEntryOptions
{
/// <summary>
/// Gets or sets the absolute expiration time.
/// </summary>
public DateTimeOffset? AbsoluteExpiration { get; set; }
/// <summary>
/// Gets or sets the time-to-live relative to now.
/// </summary>
public TimeSpan? TimeToLive { get; set; }
/// <summary>
/// Gets or sets whether to use sliding expiration for this entry.
/// </summary>
public bool? SlidingExpiration { get; set; }
/// <summary>
/// Creates options with a specific TTL.
/// </summary>
public static CacheEntryOptions WithTtl(TimeSpan ttl) => new() { TimeToLive = ttl };
/// <summary>
/// Creates options with absolute expiration.
/// </summary>
public static CacheEntryOptions ExpiresAt(DateTimeOffset expiration) => new() { AbsoluteExpiration = expiration };
/// <summary>
/// Creates options with sliding expiration.
/// </summary>
public static CacheEntryOptions Sliding(TimeSpan slidingWindow) => new() { TimeToLive = slidingWindow, SlidingExpiration = true };
}

View File

@@ -0,0 +1,69 @@
using System.ComponentModel.DataAnnotations;
namespace StellaOps.Messaging;
/// <summary>
/// Configuration options for a message queue.
/// </summary>
public class MessageQueueOptions
{
/// <summary>
/// Gets or sets the queue/stream name.
/// </summary>
[Required]
public string QueueName { get; set; } = null!;
/// <summary>
/// Gets or sets the consumer group name.
/// </summary>
[Required]
public string ConsumerGroup { get; set; } = null!;
/// <summary>
/// Gets or sets the consumer name within the group.
/// Defaults to machine name + process ID.
/// </summary>
public string? ConsumerName { get; set; }
/// <summary>
/// Gets or sets the dead-letter queue name.
/// If null, dead-lettering may not be supported.
/// </summary>
public string? DeadLetterQueue { get; set; }
/// <summary>
/// Gets or sets the default lease duration for messages.
/// </summary>
public TimeSpan DefaultLeaseDuration { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets the maximum number of delivery attempts before dead-lettering.
/// </summary>
public int MaxDeliveryAttempts { get; set; } = 5;
/// <summary>
/// Gets or sets the idempotency window for duplicate detection.
/// </summary>
public TimeSpan IdempotencyWindow { get; set; } = TimeSpan.FromHours(24);
/// <summary>
/// Gets or sets the approximate maximum queue length (stream trimming).
/// Null means no limit.
/// </summary>
public int? ApproximateMaxLength { get; set; }
/// <summary>
/// Gets or sets the initial backoff for retry delays.
/// </summary>
public TimeSpan RetryInitialBackoff { get; set; } = TimeSpan.FromSeconds(1);
/// <summary>
/// Gets or sets the maximum backoff for retry delays.
/// </summary>
public TimeSpan RetryMaxBackoff { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets the backoff multiplier for exponential backoff.
/// </summary>
public double RetryBackoffMultiplier { get; set; } = 2.0;
}

View File

@@ -0,0 +1,27 @@
namespace StellaOps.Messaging;
/// <summary>
/// Options for configuring messaging plugin discovery and loading.
/// </summary>
public class MessagingPluginOptions
{
/// <summary>
/// Gets or sets the directory to search for transport plugins.
/// </summary>
public string PluginDirectory { get; set; } = "plugins/messaging";
/// <summary>
/// Gets or sets the search pattern for plugin assemblies.
/// </summary>
public string SearchPattern { get; set; } = "StellaOps.Messaging.Transport.*.dll";
/// <summary>
/// Gets or sets the configuration section path for messaging options.
/// </summary>
public string ConfigurationSection { get; set; } = "messaging";
/// <summary>
/// Gets or sets whether to throw if no transport is configured.
/// </summary>
public bool RequireTransport { get; set; } = true;
}

View File

@@ -0,0 +1,23 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Plugin;
namespace StellaOps.Messaging.Plugins;
/// <summary>
/// Plugin contract for messaging transports.
/// Transport plugins implement this interface to provide IMessageQueue and IDistributedCache implementations.
/// </summary>
public interface IMessagingTransportPlugin : IAvailabilityPlugin
{
/// <summary>
/// Gets the unique transport name (e.g., "valkey", "nats", "postgres", "inmemory").
/// </summary>
new string Name { get; }
/// <summary>
/// Registers transport services into the DI container.
/// </summary>
/// <param name="context">The registration context.</param>
void Register(MessagingTransportRegistrationContext context);
}

View File

@@ -0,0 +1,113 @@
using System.Reflection;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.Plugin;
using StellaOps.Plugin.Hosting;
namespace StellaOps.Messaging.Plugins;
/// <summary>
/// Loads and registers messaging transport plugins.
/// </summary>
public sealed class MessagingPluginLoader
{
private readonly ILogger<MessagingPluginLoader>? _logger;
public MessagingPluginLoader(ILogger<MessagingPluginLoader>? logger = null)
{
_logger = logger;
}
/// <summary>
/// Discovers and loads messaging transport plugins from the specified directory.
/// </summary>
public IReadOnlyList<IMessagingTransportPlugin> LoadFromDirectory(
string pluginDirectory,
string searchPattern = "StellaOps.Messaging.Transport.*.dll")
{
if (!Directory.Exists(pluginDirectory))
{
_logger?.LogWarning("Plugin directory does not exist: {Directory}", pluginDirectory);
return [];
}
var options = new PluginHostOptions
{
PluginsDirectory = pluginDirectory,
EnsureDirectoryExists = false,
RecursiveSearch = false
};
options.SearchPatterns.Add(searchPattern);
var result = PluginHost.LoadPlugins(options);
var plugins = new List<IMessagingTransportPlugin>();
foreach (var pluginAssembly in result.Plugins)
{
var transportPlugins = PluginLoader.LoadPlugins<IMessagingTransportPlugin>(new[] { pluginAssembly.Assembly });
plugins.AddRange(transportPlugins);
foreach (var plugin in transportPlugins)
{
_logger?.LogDebug("Loaded messaging transport plugin: {Name} from {Assembly}",
plugin.Name, pluginAssembly.Assembly.GetName().Name);
}
}
return plugins;
}
/// <summary>
/// Loads messaging transport plugins from the specified assemblies.
/// </summary>
public IReadOnlyList<IMessagingTransportPlugin> LoadFromAssemblies(IEnumerable<Assembly> assemblies)
{
return PluginLoader.LoadPlugins<IMessagingTransportPlugin>(assemblies);
}
/// <summary>
/// Finds and registers the configured transport plugin.
/// </summary>
/// <param name="plugins">Available plugins.</param>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration.</param>
/// <param name="configSectionPath">Configuration section path (default: "messaging").</param>
/// <returns>True if a plugin was registered.</returns>
public bool RegisterConfiguredTransport(
IReadOnlyList<IMessagingTransportPlugin> plugins,
IServiceCollection services,
IConfiguration configuration,
string configSectionPath = "messaging")
{
var messagingSection = configuration.GetSection(configSectionPath);
var transportName = messagingSection.GetValue<string>("transport");
if (string.IsNullOrWhiteSpace(transportName))
{
_logger?.LogWarning("No messaging transport configured at {Path}:transport", configSectionPath);
return false;
}
var plugin = plugins.FirstOrDefault(p =>
string.Equals(p.Name, transportName, StringComparison.OrdinalIgnoreCase));
if (plugin is null)
{
_logger?.LogError("Messaging transport plugin '{Transport}' not found. Available: {Available}",
transportName, string.Join(", ", plugins.Select(p => p.Name)));
return false;
}
var transportConfigSection = $"{configSectionPath}:{transportName}";
var context = new MessagingTransportRegistrationContext(
services,
configuration,
transportConfigSection);
plugin.Register(context);
_logger?.LogInformation("Registered messaging transport: {Transport}", transportName);
return true;
}
}

View File

@@ -0,0 +1,52 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace StellaOps.Messaging.Plugins;
/// <summary>
/// Context provided to transport plugins during registration.
/// </summary>
public sealed class MessagingTransportRegistrationContext
{
/// <summary>
/// Creates a new registration context.
/// </summary>
public MessagingTransportRegistrationContext(
IServiceCollection services,
IConfiguration configuration,
string configurationSection,
ILoggerFactory? loggerFactory = null)
{
Services = services;
Configuration = configuration;
ConfigurationSection = configurationSection;
LoggerFactory = loggerFactory;
}
/// <summary>
/// Gets the service collection for registering services.
/// </summary>
public IServiceCollection Services { get; }
/// <summary>
/// Gets the configuration root.
/// </summary>
public IConfiguration Configuration { get; }
/// <summary>
/// Gets the configuration section path for this transport (e.g., "messaging:valkey").
/// </summary>
public string ConfigurationSection { get; }
/// <summary>
/// Gets the logger factory for creating loggers during registration.
/// </summary>
public ILoggerFactory? LoggerFactory { get; }
/// <summary>
/// Gets the configuration section for this transport.
/// </summary>
public IConfigurationSection GetTransportConfiguration() =>
Configuration.GetSection(ConfigurationSection);
}

View File

@@ -0,0 +1,63 @@
namespace StellaOps.Messaging;
/// <summary>
/// Result of a cache get operation.
/// </summary>
/// <typeparam name="TValue">The value type.</typeparam>
public readonly struct CacheResult<TValue>
{
private readonly TValue? _value;
private CacheResult(TValue? value, bool hasValue)
{
_value = value;
HasValue = hasValue;
}
/// <summary>
/// Gets whether a value was found in the cache.
/// </summary>
public bool HasValue { get; }
/// <summary>
/// Gets the cached value.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when no value is present.</exception>
public TValue Value => HasValue
? _value!
: throw new InvalidOperationException("No value present in cache result.");
/// <summary>
/// Gets the value or a default.
/// </summary>
/// <param name="defaultValue">The default value to return if not cached.</param>
/// <returns>The cached value or the default.</returns>
public TValue GetValueOrDefault(TValue defaultValue = default!) =>
HasValue ? _value! : defaultValue;
/// <summary>
/// Attempts to get the value.
/// </summary>
/// <param name="value">The cached value, if present.</param>
/// <returns>True if a value was present.</returns>
public bool TryGetValue(out TValue? value)
{
value = _value;
return HasValue;
}
/// <summary>
/// Creates a result with a value.
/// </summary>
public static CacheResult<TValue> Found(TValue value) => new(value, true);
/// <summary>
/// Creates a result indicating cache miss.
/// </summary>
public static CacheResult<TValue> Miss() => new(default, false);
/// <summary>
/// Implicitly converts a value to a found result.
/// </summary>
public static implicit operator CacheResult<TValue>(TValue value) => Found(value);
}

View File

@@ -0,0 +1,54 @@
namespace StellaOps.Messaging;
/// <summary>
/// Options for enqueue operations.
/// </summary>
public class EnqueueOptions
{
/// <summary>
/// Gets or sets the idempotency key for duplicate detection.
/// If null, no duplicate detection is performed.
/// </summary>
public string? IdempotencyKey { get; set; }
/// <summary>
/// Gets or sets the correlation ID for tracing.
/// </summary>
public string? CorrelationId { get; set; }
/// <summary>
/// Gets or sets the tenant ID for multi-tenant scenarios.
/// </summary>
public string? TenantId { get; set; }
/// <summary>
/// Gets or sets the message priority (if supported by transport).
/// Higher values indicate higher priority.
/// </summary>
public int Priority { get; set; }
/// <summary>
/// Gets or sets when the message should become visible (delayed delivery).
/// </summary>
public DateTimeOffset? VisibleAt { get; set; }
/// <summary>
/// Gets or sets custom headers/metadata for the message.
/// </summary>
public IReadOnlyDictionary<string, string>? Headers { get; set; }
/// <summary>
/// Creates options with an idempotency key.
/// </summary>
public static EnqueueOptions WithIdempotencyKey(string key) => new() { IdempotencyKey = key };
/// <summary>
/// Creates options for delayed delivery.
/// </summary>
public static EnqueueOptions DelayedUntil(DateTimeOffset visibleAt) => new() { VisibleAt = visibleAt };
/// <summary>
/// Creates options with correlation ID.
/// </summary>
public static EnqueueOptions WithCorrelation(string correlationId) => new() { CorrelationId = correlationId };
}

View File

@@ -0,0 +1,45 @@
namespace StellaOps.Messaging;
/// <summary>
/// Result of an enqueue operation.
/// </summary>
public readonly struct EnqueueResult
{
/// <summary>
/// Gets the message ID assigned by the queue.
/// </summary>
public string MessageId { get; init; }
/// <summary>
/// Gets whether the message was enqueued successfully.
/// </summary>
public bool Success { get; init; }
/// <summary>
/// Gets whether this was a duplicate message (idempotency).
/// </summary>
public bool WasDuplicate { get; init; }
/// <summary>
/// Gets the error message if the operation failed.
/// </summary>
public string? Error { get; init; }
/// <summary>
/// Creates a successful result.
/// </summary>
public static EnqueueResult Succeeded(string messageId, bool wasDuplicate = false) =>
new() { MessageId = messageId, Success = true, WasDuplicate = wasDuplicate };
/// <summary>
/// Creates a failed result.
/// </summary>
public static EnqueueResult Failed(string error) =>
new() { Success = false, Error = error, MessageId = string.Empty };
/// <summary>
/// Creates a duplicate result.
/// </summary>
public static EnqueueResult Duplicate(string messageId) =>
new() { MessageId = messageId, Success = true, WasDuplicate = true };
}

View File

@@ -0,0 +1,58 @@
namespace StellaOps.Messaging;
/// <summary>
/// Request parameters for leasing messages.
/// </summary>
public class LeaseRequest
{
/// <summary>
/// Gets or sets the maximum number of messages to lease.
/// </summary>
public int BatchSize { get; set; } = 1;
/// <summary>
/// Gets or sets the lease duration for the messages.
/// If null, uses the queue's default lease duration.
/// </summary>
public TimeSpan? LeaseDuration { get; set; }
/// <summary>
/// Gets or sets the maximum time to wait for messages if none are available.
/// Zero means don't wait (poll). Null means use transport default.
/// </summary>
public TimeSpan? WaitTimeout { get; set; }
/// <summary>
/// Gets or sets whether to only return messages from the pending entry list (redeliveries).
/// </summary>
public bool PendingOnly { get; set; }
}
/// <summary>
/// Request parameters for claiming expired leases.
/// </summary>
public class ClaimRequest
{
/// <summary>
/// Gets or sets the maximum number of messages to claim.
/// </summary>
public int BatchSize { get; set; } = 10;
/// <summary>
/// Gets or sets the minimum idle time for a message to be claimed.
/// Messages must have been idle (not processed) for at least this duration.
/// </summary>
public TimeSpan MinIdleTime { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets or sets the new lease duration for claimed messages.
/// If null, uses the queue's default lease duration.
/// </summary>
public TimeSpan? LeaseDuration { get; set; }
/// <summary>
/// Gets or sets the minimum number of delivery attempts for messages to claim.
/// This helps avoid claiming messages that are still being processed for the first time.
/// </summary>
public int MinDeliveryAttempts { get; set; } = 1;
}

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<RootNamespace>StellaOps.Messaging</RootNamespace>
<AssemblyName>StellaOps.Messaging</AssemblyName>
<Description>Transport-agnostic messaging abstractions for StellaOps (queues, caching, pub/sub)</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../StellaOps.Plugin/StellaOps.Plugin.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,38 +1,38 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.DependencyInjection;
using StellaOps.Plugin.Hosting;
using StellaOps.Plugin.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Plugin.DependencyInjection;
public static class PluginDependencyInjectionExtensions
{
public static IServiceCollection RegisterPluginRoutines(
this IServiceCollection services,
IConfiguration configuration,
PluginHostOptions options,
ILogger? logger = null)
{
if (services == null)
{
throw new ArgumentNullException(nameof(services));
}
if (configuration == null)
{
throw new ArgumentNullException(nameof(configuration));
}
if (options == null)
{
throw new ArgumentNullException(nameof(options));
}
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.DependencyInjection;
using StellaOps.Plugin.Hosting;
using StellaOps.Plugin.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Plugin.DependencyInjection;
public static class PluginDependencyInjectionExtensions
{
public static IServiceCollection RegisterPluginRoutines(
this IServiceCollection services,
IConfiguration configuration,
PluginHostOptions options,
ILogger? logger = null)
{
if (services == null)
{
throw new ArgumentNullException(nameof(services));
}
if (configuration == null)
{
throw new ArgumentNullException(nameof(configuration));
}
if (options == null)
{
throw new ArgumentNullException(nameof(options));
}
var loadResult = PluginHost.LoadPlugins(options, logger);
foreach (var plugin in loadResult.Plugins)
@@ -44,50 +44,50 @@ public static class PluginDependencyInjectionExtensions
logger?.LogDebug(
"Registering DI routine '{RoutineType}' from plugin '{PluginAssembly}'.",
routine.GetType().FullName,
plugin.Assembly.FullName);
routine.Register(services, configuration);
}
}
if (loadResult.MissingOrderedPlugins.Count > 0)
{
logger?.LogWarning(
"Some ordered plugins were not found: {Missing}",
string.Join(", ", loadResult.MissingOrderedPlugins));
}
return services;
}
private static IEnumerable<IDependencyInjectionRoutine> CreateRoutines(System.Reflection.Assembly assembly)
{
foreach (var type in assembly.GetLoadableTypes())
{
if (type is null || type.IsAbstract || type.IsInterface)
{
continue;
}
if (!typeof(IDependencyInjectionRoutine).IsAssignableFrom(type))
{
continue;
}
object? instance;
try
{
instance = Activator.CreateInstance(type);
}
catch
{
continue;
}
if (instance is IDependencyInjectionRoutine routine)
{
yield return routine;
}
}
}
plugin.Assembly.FullName);
routine.Register(services, configuration);
}
}
if (loadResult.MissingOrderedPlugins.Count > 0)
{
logger?.LogWarning(
"Some ordered plugins were not found: {Missing}",
string.Join(", ", loadResult.MissingOrderedPlugins));
}
return services;
}
private static IEnumerable<IDependencyInjectionRoutine> CreateRoutines(System.Reflection.Assembly assembly)
{
foreach (var type in assembly.GetLoadableTypes())
{
if (type is null || type.IsAbstract || type.IsInterface)
{
continue;
}
if (!typeof(IDependencyInjectionRoutine).IsAssignableFrom(type))
{
continue;
}
object? instance;
try
{
instance = Activator.CreateInstance(type);
}
catch
{
continue;
}
if (instance is IDependencyInjectionRoutine routine)
{
yield return routine;
}
}
}
}

View File

@@ -1,169 +1,169 @@
using System;
using System.Linq;
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.DependencyInjection;
using StellaOps.Plugin.Internal;
namespace StellaOps.Plugin.DependencyInjection;
public static class PluginServiceRegistration
{
public static void RegisterAssemblyMetadata(IServiceCollection services, Assembly assembly, ILogger? logger)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(assembly);
foreach (var implementationType in assembly.GetLoadableTypes())
{
if (implementationType is null || !implementationType.IsClass || implementationType.IsAbstract)
{
continue;
}
var attributes = implementationType.GetCustomAttributes<ServiceBindingAttribute>(inherit: false);
if (!attributes.Any())
{
continue;
}
foreach (var attribute in attributes)
{
try
{
ApplyBinding(services, implementationType, attribute, logger);
}
catch (Exception ex)
{
logger?.LogWarning(
ex,
"Failed to register service binding for implementation '{Implementation}' declared in assembly '{Assembly}'.",
implementationType.FullName ?? implementationType.Name,
assembly.FullName ?? assembly.GetName().Name);
}
}
}
}
private static void ApplyBinding(
IServiceCollection services,
Type implementationType,
ServiceBindingAttribute attribute,
ILogger? logger)
{
var serviceType = attribute.ServiceType ?? implementationType;
if (!IsValidBinding(serviceType, implementationType))
{
logger?.LogWarning(
"Service binding metadata ignored: implementation '{Implementation}' is not assignable to service '{Service}'.",
implementationType.FullName ?? implementationType.Name,
serviceType.FullName ?? serviceType.Name);
return;
}
if (attribute.ReplaceExisting)
{
RemoveExistingDescriptors(services, serviceType);
}
AddDescriptorIfMissing(services, serviceType, implementationType, attribute.Lifetime, logger);
if (attribute.RegisterAsSelf && serviceType != implementationType)
{
AddDescriptorIfMissing(services, implementationType, implementationType, attribute.Lifetime, logger);
}
}
private static bool IsValidBinding(Type serviceType, Type implementationType)
{
if (serviceType.IsGenericTypeDefinition)
{
return implementationType.IsGenericTypeDefinition
&& implementationType.IsClass
&& implementationType.IsAssignableToGenericTypeDefinition(serviceType);
}
return serviceType.IsAssignableFrom(implementationType);
}
private static void AddDescriptorIfMissing(
IServiceCollection services,
Type serviceType,
Type implementationType,
ServiceLifetime lifetime,
ILogger? logger)
{
if (services.Any(descriptor =>
descriptor.ServiceType == serviceType &&
descriptor.ImplementationType == implementationType))
{
logger?.LogDebug(
"Skipping duplicate service binding for {ServiceType} -> {ImplementationType}.",
serviceType.FullName ?? serviceType.Name,
implementationType.FullName ?? implementationType.Name);
return;
}
ServiceDescriptor descriptor;
if (serviceType.IsGenericTypeDefinition || implementationType.IsGenericTypeDefinition)
{
descriptor = ServiceDescriptor.Describe(serviceType, implementationType, lifetime);
}
else
{
descriptor = new ServiceDescriptor(serviceType, implementationType, lifetime);
}
services.Add(descriptor);
logger?.LogDebug(
"Registered service binding {ServiceType} -> {ImplementationType} with {Lifetime} lifetime.",
serviceType.FullName ?? serviceType.Name,
implementationType.FullName ?? implementationType.Name,
lifetime);
}
private static void RemoveExistingDescriptors(IServiceCollection services, Type serviceType)
{
for (var i = services.Count - 1; i >= 0; i--)
{
if (services[i].ServiceType == serviceType)
{
services.RemoveAt(i);
}
}
}
private static bool IsAssignableToGenericTypeDefinition(this Type implementationType, Type serviceTypeDefinition)
{
if (!serviceTypeDefinition.IsGenericTypeDefinition)
{
return false;
}
if (implementationType == serviceTypeDefinition)
{
return true;
}
if (implementationType.IsGenericType && implementationType.GetGenericTypeDefinition() == serviceTypeDefinition)
{
return true;
}
var interfaces = implementationType.GetInterfaces();
foreach (var iface in interfaces)
{
if (iface.IsGenericType && iface.GetGenericTypeDefinition() == serviceTypeDefinition)
{
return true;
}
}
var baseType = implementationType.BaseType;
return baseType is not null && baseType.IsGenericTypeDefinition
? baseType.GetGenericTypeDefinition() == serviceTypeDefinition
: baseType is not null && baseType.IsAssignableToGenericTypeDefinition(serviceTypeDefinition);
}
}
using System;
using System.Linq;
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StellaOps.DependencyInjection;
using StellaOps.Plugin.Internal;
namespace StellaOps.Plugin.DependencyInjection;
public static class PluginServiceRegistration
{
public static void RegisterAssemblyMetadata(IServiceCollection services, Assembly assembly, ILogger? logger)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(assembly);
foreach (var implementationType in assembly.GetLoadableTypes())
{
if (implementationType is null || !implementationType.IsClass || implementationType.IsAbstract)
{
continue;
}
var attributes = implementationType.GetCustomAttributes<ServiceBindingAttribute>(inherit: false);
if (!attributes.Any())
{
continue;
}
foreach (var attribute in attributes)
{
try
{
ApplyBinding(services, implementationType, attribute, logger);
}
catch (Exception ex)
{
logger?.LogWarning(
ex,
"Failed to register service binding for implementation '{Implementation}' declared in assembly '{Assembly}'.",
implementationType.FullName ?? implementationType.Name,
assembly.FullName ?? assembly.GetName().Name);
}
}
}
}
private static void ApplyBinding(
IServiceCollection services,
Type implementationType,
ServiceBindingAttribute attribute,
ILogger? logger)
{
var serviceType = attribute.ServiceType ?? implementationType;
if (!IsValidBinding(serviceType, implementationType))
{
logger?.LogWarning(
"Service binding metadata ignored: implementation '{Implementation}' is not assignable to service '{Service}'.",
implementationType.FullName ?? implementationType.Name,
serviceType.FullName ?? serviceType.Name);
return;
}
if (attribute.ReplaceExisting)
{
RemoveExistingDescriptors(services, serviceType);
}
AddDescriptorIfMissing(services, serviceType, implementationType, attribute.Lifetime, logger);
if (attribute.RegisterAsSelf && serviceType != implementationType)
{
AddDescriptorIfMissing(services, implementationType, implementationType, attribute.Lifetime, logger);
}
}
private static bool IsValidBinding(Type serviceType, Type implementationType)
{
if (serviceType.IsGenericTypeDefinition)
{
return implementationType.IsGenericTypeDefinition
&& implementationType.IsClass
&& implementationType.IsAssignableToGenericTypeDefinition(serviceType);
}
return serviceType.IsAssignableFrom(implementationType);
}
private static void AddDescriptorIfMissing(
IServiceCollection services,
Type serviceType,
Type implementationType,
ServiceLifetime lifetime,
ILogger? logger)
{
if (services.Any(descriptor =>
descriptor.ServiceType == serviceType &&
descriptor.ImplementationType == implementationType))
{
logger?.LogDebug(
"Skipping duplicate service binding for {ServiceType} -> {ImplementationType}.",
serviceType.FullName ?? serviceType.Name,
implementationType.FullName ?? implementationType.Name);
return;
}
ServiceDescriptor descriptor;
if (serviceType.IsGenericTypeDefinition || implementationType.IsGenericTypeDefinition)
{
descriptor = ServiceDescriptor.Describe(serviceType, implementationType, lifetime);
}
else
{
descriptor = new ServiceDescriptor(serviceType, implementationType, lifetime);
}
services.Add(descriptor);
logger?.LogDebug(
"Registered service binding {ServiceType} -> {ImplementationType} with {Lifetime} lifetime.",
serviceType.FullName ?? serviceType.Name,
implementationType.FullName ?? implementationType.Name,
lifetime);
}
private static void RemoveExistingDescriptors(IServiceCollection services, Type serviceType)
{
for (var i = services.Count - 1; i >= 0; i--)
{
if (services[i].ServiceType == serviceType)
{
services.RemoveAt(i);
}
}
}
private static bool IsAssignableToGenericTypeDefinition(this Type implementationType, Type serviceTypeDefinition)
{
if (!serviceTypeDefinition.IsGenericTypeDefinition)
{
return false;
}
if (implementationType == serviceTypeDefinition)
{
return true;
}
if (implementationType.IsGenericType && implementationType.GetGenericTypeDefinition() == serviceTypeDefinition)
{
return true;
}
var interfaces = implementationType.GetInterfaces();
foreach (var iface in interfaces)
{
if (iface.IsGenericType && iface.GetGenericTypeDefinition() == serviceTypeDefinition)
{
return true;
}
}
var baseType = implementationType.BaseType;
return baseType is not null && baseType.IsGenericTypeDefinition
? baseType.GetGenericTypeDefinition() == serviceTypeDefinition
: baseType is not null && baseType.IsAssignableToGenericTypeDefinition(serviceTypeDefinition);
}
}

View File

@@ -1,26 +1,26 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
namespace StellaOps.Plugin.DependencyInjection;
public static class StellaOpsPluginRegistration
{
public static IServiceCollection RegisterStellaOpsPlugin(
this IServiceCollection services,
IConfiguration configuration)
{
// No-op today but reserved for future plugin infrastructure services.
return services;
}
}
public sealed class DependencyInjectionRoutine : IDependencyInjectionRoutine
{
public IServiceCollection Register(
IServiceCollection services,
IConfiguration configuration)
{
return services.RegisterStellaOpsPlugin(configuration);
}
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.DependencyInjection;
namespace StellaOps.Plugin.DependencyInjection;
public static class StellaOpsPluginRegistration
{
public static IServiceCollection RegisterStellaOpsPlugin(
this IServiceCollection services,
IConfiguration configuration)
{
// No-op today but reserved for future plugin infrastructure services.
return services;
}
}
public sealed class DependencyInjectionRoutine : IDependencyInjectionRoutine
{
public IServiceCollection Register(
IServiceCollection services,
IConfiguration configuration)
{
return services.RegisterStellaOpsPlugin(configuration);
}
}

View File

@@ -1,21 +1,21 @@
using System.Reflection;
namespace StellaOps.Plugin.Hosting;
public sealed class PluginAssembly
{
internal PluginAssembly(string assemblyPath, Assembly assembly, PluginLoadContext loadContext)
{
AssemblyPath = assemblyPath;
Assembly = assembly;
LoadContext = loadContext;
}
public string AssemblyPath { get; }
public Assembly Assembly { get; }
internal PluginLoadContext LoadContext { get; }
public override string ToString() => Assembly.FullName ?? AssemblyPath;
using System.Reflection;
namespace StellaOps.Plugin.Hosting;
public sealed class PluginAssembly
{
internal PluginAssembly(string assemblyPath, Assembly assembly, PluginLoadContext loadContext)
{
AssemblyPath = assemblyPath;
Assembly = assembly;
LoadContext = loadContext;
}
public string AssemblyPath { get; }
public Assembly Assembly { get; }
internal PluginLoadContext LoadContext { get; }
public override string ToString() => Assembly.FullName ?? AssemblyPath;
}

View File

@@ -1,76 +1,76 @@
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
namespace StellaOps.Plugin.Hosting;
public static class PluginHost
{
private static readonly object Sync = new();
private static readonly Dictionary<string, PluginAssembly> LoadedPlugins = new(StringComparer.OrdinalIgnoreCase);
public static PluginHostResult LoadPlugins(PluginHostOptions options, ILogger? logger = null)
{
if (options == null)
{
throw new ArgumentNullException(nameof(options));
}
var baseDirectory = options.ResolveBaseDirectory();
var pluginDirectory = ResolvePluginDirectory(options, baseDirectory);
if (options.EnsureDirectoryExists && !Directory.Exists(pluginDirectory))
{
Directory.CreateDirectory(pluginDirectory);
}
if (!Directory.Exists(pluginDirectory))
{
logger?.LogWarning("Plugin directory '{PluginDirectory}' does not exist; no plugins will be loaded.", pluginDirectory);
return new PluginHostResult(pluginDirectory, Array.Empty<string>(), Array.Empty<PluginAssembly>(), Array.Empty<string>());
}
var searchPatterns = BuildSearchPatterns(options, pluginDirectory);
var discovered = DiscoverPluginFiles(pluginDirectory, searchPatterns, options.RecursiveSearch, logger);
var orderedFiles = ApplyExplicitOrdering(discovered, options.PluginOrder, out var missingOrderedNames);
var loaded = new List<PluginAssembly>(orderedFiles.Count);
lock (Sync)
{
foreach (var file in orderedFiles)
{
if (LoadedPlugins.TryGetValue(file, out var existing))
{
loaded.Add(existing);
continue;
}
try
{
var loadContext = new PluginLoadContext(file);
var assembly = loadContext.LoadFromAssemblyPath(file);
var descriptor = new PluginAssembly(file, assembly, loadContext);
LoadedPlugins[file] = descriptor;
loaded.Add(descriptor);
logger?.LogInformation("Loaded plugin assembly '{Assembly}' from '{Path}'.", assembly.FullName, file);
}
catch (Exception ex)
{
logger?.LogError(ex, "Failed to load plugin assembly from '{Path}'.", file);
}
}
}
var missingOrdered = new ReadOnlyCollection<string>(missingOrderedNames);
return new PluginHostResult(pluginDirectory, searchPatterns, new ReadOnlyCollection<PluginAssembly>(loaded), missingOrdered);
}
private static string ResolvePluginDirectory(PluginHostOptions options, string baseDirectory)
{
using Microsoft.Extensions.Logging;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
namespace StellaOps.Plugin.Hosting;
public static class PluginHost
{
private static readonly object Sync = new();
private static readonly Dictionary<string, PluginAssembly> LoadedPlugins = new(StringComparer.OrdinalIgnoreCase);
public static PluginHostResult LoadPlugins(PluginHostOptions options, ILogger? logger = null)
{
if (options == null)
{
throw new ArgumentNullException(nameof(options));
}
var baseDirectory = options.ResolveBaseDirectory();
var pluginDirectory = ResolvePluginDirectory(options, baseDirectory);
if (options.EnsureDirectoryExists && !Directory.Exists(pluginDirectory))
{
Directory.CreateDirectory(pluginDirectory);
}
if (!Directory.Exists(pluginDirectory))
{
logger?.LogWarning("Plugin directory '{PluginDirectory}' does not exist; no plugins will be loaded.", pluginDirectory);
return new PluginHostResult(pluginDirectory, Array.Empty<string>(), Array.Empty<PluginAssembly>(), Array.Empty<string>());
}
var searchPatterns = BuildSearchPatterns(options, pluginDirectory);
var discovered = DiscoverPluginFiles(pluginDirectory, searchPatterns, options.RecursiveSearch, logger);
var orderedFiles = ApplyExplicitOrdering(discovered, options.PluginOrder, out var missingOrderedNames);
var loaded = new List<PluginAssembly>(orderedFiles.Count);
lock (Sync)
{
foreach (var file in orderedFiles)
{
if (LoadedPlugins.TryGetValue(file, out var existing))
{
loaded.Add(existing);
continue;
}
try
{
var loadContext = new PluginLoadContext(file);
var assembly = loadContext.LoadFromAssemblyPath(file);
var descriptor = new PluginAssembly(file, assembly, loadContext);
LoadedPlugins[file] = descriptor;
loaded.Add(descriptor);
logger?.LogInformation("Loaded plugin assembly '{Assembly}' from '{Path}'.", assembly.FullName, file);
}
catch (Exception ex)
{
logger?.LogError(ex, "Failed to load plugin assembly from '{Path}'.", file);
}
}
}
var missingOrdered = new ReadOnlyCollection<string>(missingOrderedNames);
return new PluginHostResult(pluginDirectory, searchPatterns, new ReadOnlyCollection<PluginAssembly>(loaded), missingOrdered);
}
private static string ResolvePluginDirectory(PluginHostOptions options, string baseDirectory)
{
if (string.IsNullOrWhiteSpace(options.PluginsDirectory))
{
var defaultDirectory = !string.IsNullOrWhiteSpace(options.PrimaryPrefix)
@@ -78,142 +78,142 @@ public static class PluginHost
: "PluginBinaries";
return Path.Combine(baseDirectory, defaultDirectory);
}
if (Path.IsPathRooted(options.PluginsDirectory))
{
return options.PluginsDirectory;
}
return Path.Combine(baseDirectory, options.PluginsDirectory);
}
private static IReadOnlyList<string> BuildSearchPatterns(PluginHostOptions options, string pluginDirectory)
{
var patterns = new List<string>();
if (options.SearchPatterns.Count > 0)
{
patterns.AddRange(options.SearchPatterns);
}
else
{
var prefixes = new List<string>();
if (!string.IsNullOrWhiteSpace(options.PrimaryPrefix))
{
prefixes.Add(options.PrimaryPrefix);
}
else if (System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name is { } entryName)
{
prefixes.Add(entryName);
}
prefixes.AddRange(options.AdditionalPrefixes);
if (prefixes.Count == 0)
{
// Fallback to directory name
prefixes.Add(Path.GetFileName(pluginDirectory));
}
foreach (var prefix in prefixes.Where(p => !string.IsNullOrWhiteSpace(p)))
{
patterns.Add($"{prefix}.Plugin.*.dll");
}
}
return new ReadOnlyCollection<string>(patterns.Distinct(StringComparer.OrdinalIgnoreCase).ToList());
}
private static List<string> DiscoverPluginFiles(
string pluginDirectory,
IReadOnlyList<string> searchPatterns,
bool recurse,
ILogger? logger)
{
var files = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var searchOption = recurse ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
foreach (var pattern in searchPatterns)
{
try
{
foreach (var file in Directory.EnumerateFiles(pluginDirectory, pattern, searchOption))
{
if (IsHiddenPath(file))
{
continue;
}
if (seen.Add(file))
{
files.Add(file);
}
}
}
catch (DirectoryNotFoundException)
{
// Directory could be removed between the existence check and enumeration.
logger?.LogDebug("Plugin directory '{PluginDirectory}' disappeared before enumeration.", pluginDirectory);
}
}
return files;
}
private static List<string> ApplyExplicitOrdering(
List<string> discoveredFiles,
IList<string> pluginOrder,
out List<string> missingNames)
{
if (pluginOrder.Count == 0 || discoveredFiles.Count == 0)
{
missingNames = new List<string>();
discoveredFiles.Sort(StringComparer.OrdinalIgnoreCase);
return discoveredFiles;
}
var configuredSet = new HashSet<string>(pluginOrder, StringComparer.OrdinalIgnoreCase);
var fileLookup = discoveredFiles.ToDictionary(
k => Path.GetFileNameWithoutExtension(k),
StringComparer.OrdinalIgnoreCase);
var specified = new List<string>();
foreach (var name in pluginOrder)
{
if (fileLookup.TryGetValue(name, out var file))
{
specified.Add(file);
}
}
var unspecified = discoveredFiles
.Where(f => !configuredSet.Contains(Path.GetFileNameWithoutExtension(f)))
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
.ToList();
missingNames = pluginOrder
.Where(name => !fileLookup.ContainsKey(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
specified.AddRange(unspecified);
return specified;
}
private static bool IsHiddenPath(string filePath)
{
var directory = Path.GetDirectoryName(filePath);
while (!string.IsNullOrEmpty(directory))
{
var name = Path.GetFileName(directory);
if (name.StartsWith(".", StringComparison.Ordinal))
{
return true;
}
directory = Path.GetDirectoryName(directory);
}
return false;
}
if (Path.IsPathRooted(options.PluginsDirectory))
{
return options.PluginsDirectory;
}
return Path.Combine(baseDirectory, options.PluginsDirectory);
}
private static IReadOnlyList<string> BuildSearchPatterns(PluginHostOptions options, string pluginDirectory)
{
var patterns = new List<string>();
if (options.SearchPatterns.Count > 0)
{
patterns.AddRange(options.SearchPatterns);
}
else
{
var prefixes = new List<string>();
if (!string.IsNullOrWhiteSpace(options.PrimaryPrefix))
{
prefixes.Add(options.PrimaryPrefix);
}
else if (System.Reflection.Assembly.GetEntryAssembly()?.GetName().Name is { } entryName)
{
prefixes.Add(entryName);
}
prefixes.AddRange(options.AdditionalPrefixes);
if (prefixes.Count == 0)
{
// Fallback to directory name
prefixes.Add(Path.GetFileName(pluginDirectory));
}
foreach (var prefix in prefixes.Where(p => !string.IsNullOrWhiteSpace(p)))
{
patterns.Add($"{prefix}.Plugin.*.dll");
}
}
return new ReadOnlyCollection<string>(patterns.Distinct(StringComparer.OrdinalIgnoreCase).ToList());
}
private static List<string> DiscoverPluginFiles(
string pluginDirectory,
IReadOnlyList<string> searchPatterns,
bool recurse,
ILogger? logger)
{
var files = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var searchOption = recurse ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
foreach (var pattern in searchPatterns)
{
try
{
foreach (var file in Directory.EnumerateFiles(pluginDirectory, pattern, searchOption))
{
if (IsHiddenPath(file))
{
continue;
}
if (seen.Add(file))
{
files.Add(file);
}
}
}
catch (DirectoryNotFoundException)
{
// Directory could be removed between the existence check and enumeration.
logger?.LogDebug("Plugin directory '{PluginDirectory}' disappeared before enumeration.", pluginDirectory);
}
}
return files;
}
private static List<string> ApplyExplicitOrdering(
List<string> discoveredFiles,
IList<string> pluginOrder,
out List<string> missingNames)
{
if (pluginOrder.Count == 0 || discoveredFiles.Count == 0)
{
missingNames = new List<string>();
discoveredFiles.Sort(StringComparer.OrdinalIgnoreCase);
return discoveredFiles;
}
var configuredSet = new HashSet<string>(pluginOrder, StringComparer.OrdinalIgnoreCase);
var fileLookup = discoveredFiles.ToDictionary(
k => Path.GetFileNameWithoutExtension(k),
StringComparer.OrdinalIgnoreCase);
var specified = new List<string>();
foreach (var name in pluginOrder)
{
if (fileLookup.TryGetValue(name, out var file))
{
specified.Add(file);
}
}
var unspecified = discoveredFiles
.Where(f => !configuredSet.Contains(Path.GetFileNameWithoutExtension(f)))
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
.ToList();
missingNames = pluginOrder
.Where(name => !fileLookup.ContainsKey(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
specified.AddRange(unspecified);
return specified;
}
private static bool IsHiddenPath(string filePath)
{
var directory = Path.GetDirectoryName(filePath);
while (!string.IsNullOrEmpty(directory))
{
var name = Path.GetFileName(directory);
if (name.StartsWith(".", StringComparison.Ordinal))
{
return true;
}
directory = Path.GetDirectoryName(directory);
}
return false;
}
}

View File

@@ -1,59 +1,59 @@
using System;
using System.Collections.Generic;
using System.IO;
namespace StellaOps.Plugin.Hosting;
public sealed class PluginHostOptions
{
private readonly List<string> additionalPrefixes = new();
private readonly List<string> pluginOrder = new();
private readonly List<string> searchPatterns = new();
/// <summary>
/// Optional base directory used for resolving relative plugin paths. Defaults to <see cref="AppContext.BaseDirectory" />.
/// </summary>
public string? BaseDirectory { get; set; }
/// <summary>
using System;
using System.Collections.Generic;
using System.IO;
namespace StellaOps.Plugin.Hosting;
public sealed class PluginHostOptions
{
private readonly List<string> additionalPrefixes = new();
private readonly List<string> pluginOrder = new();
private readonly List<string> searchPatterns = new();
/// <summary>
/// Optional base directory used for resolving relative plugin paths. Defaults to <see cref="AppContext.BaseDirectory" />.
/// </summary>
public string? BaseDirectory { get; set; }
/// <summary>
/// Directory that contains plugin assemblies. Relative values are resolved against <see cref="BaseDirectory" />.
/// Defaults to <c>{PrimaryPrefix}.PluginBinaries</c> when a primary prefix is provided, otherwise <c>PluginBinaries</c>.
/// </summary>
public string? PluginsDirectory { get; set; }
/// <summary>
/// Primary prefix used to discover plugin assemblies. If not supplied, the entry assembly name is used.
/// </summary>
public string? PrimaryPrefix { get; set; }
/// <summary>
/// Additional prefixes that should be considered when building search patterns.
/// </summary>
public IList<string> AdditionalPrefixes => additionalPrefixes;
/// <summary>
/// Explicit plugin ordering expressed as assembly names without extension.
/// Entries that are not discovered will be reported in <see cref="PluginHostResult.MissingOrderedPlugins" />.
/// </summary>
public IList<string> PluginOrder => pluginOrder;
/// <summary>
/// Optional explicit search patterns. When empty, they are derived from prefix settings.
/// </summary>
public IList<string> SearchPatterns => searchPatterns;
/// <summary>
/// When true (default) the plugin directory will be created if it does not exist.
/// </summary>
public bool EnsureDirectoryExists { get; set; } = true;
/// <summary>
/// Controls whether sub-directories should be scanned. Defaults to true.
/// </summary>
public bool RecursiveSearch { get; set; } = true;
internal string ResolveBaseDirectory()
=> string.IsNullOrWhiteSpace(BaseDirectory)
? AppContext.BaseDirectory
: Path.GetFullPath(BaseDirectory);
/// </summary>
public string? PluginsDirectory { get; set; }
/// <summary>
/// Primary prefix used to discover plugin assemblies. If not supplied, the entry assembly name is used.
/// </summary>
public string? PrimaryPrefix { get; set; }
/// <summary>
/// Additional prefixes that should be considered when building search patterns.
/// </summary>
public IList<string> AdditionalPrefixes => additionalPrefixes;
/// <summary>
/// Explicit plugin ordering expressed as assembly names without extension.
/// Entries that are not discovered will be reported in <see cref="PluginHostResult.MissingOrderedPlugins" />.
/// </summary>
public IList<string> PluginOrder => pluginOrder;
/// <summary>
/// Optional explicit search patterns. When empty, they are derived from prefix settings.
/// </summary>
public IList<string> SearchPatterns => searchPatterns;
/// <summary>
/// When true (default) the plugin directory will be created if it does not exist.
/// </summary>
public bool EnsureDirectoryExists { get; set; } = true;
/// <summary>
/// Controls whether sub-directories should be scanned. Defaults to true.
/// </summary>
public bool RecursiveSearch { get; set; } = true;
internal string ResolveBaseDirectory()
=> string.IsNullOrWhiteSpace(BaseDirectory)
? AppContext.BaseDirectory
: Path.GetFullPath(BaseDirectory);
}

View File

@@ -1,26 +1,26 @@
using System.Collections.Generic;
namespace StellaOps.Plugin.Hosting;
public sealed class PluginHostResult
{
internal PluginHostResult(
string pluginDirectory,
IReadOnlyList<string> searchPatterns,
IReadOnlyList<PluginAssembly> plugins,
IReadOnlyList<string> missingOrderedPlugins)
{
PluginDirectory = pluginDirectory;
SearchPatterns = searchPatterns;
Plugins = plugins;
MissingOrderedPlugins = missingOrderedPlugins;
}
public string PluginDirectory { get; }
public IReadOnlyList<string> SearchPatterns { get; }
public IReadOnlyList<PluginAssembly> Plugins { get; }
public IReadOnlyList<string> MissingOrderedPlugins { get; }
using System.Collections.Generic;
namespace StellaOps.Plugin.Hosting;
public sealed class PluginHostResult
{
internal PluginHostResult(
string pluginDirectory,
IReadOnlyList<string> searchPatterns,
IReadOnlyList<PluginAssembly> plugins,
IReadOnlyList<string> missingOrderedPlugins)
{
PluginDirectory = pluginDirectory;
SearchPatterns = searchPatterns;
Plugins = plugins;
MissingOrderedPlugins = missingOrderedPlugins;
}
public string PluginDirectory { get; }
public IReadOnlyList<string> SearchPatterns { get; }
public IReadOnlyList<PluginAssembly> Plugins { get; }
public IReadOnlyList<string> MissingOrderedPlugins { get; }
}

View File

@@ -1,79 +1,79 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
namespace StellaOps.Plugin.Hosting;
internal sealed class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver resolver;
private readonly IEnumerable<Assembly> hostAssemblies;
public PluginLoadContext(string pluginPath)
: base(isCollectible: false)
{
resolver = new AssemblyDependencyResolver(pluginPath);
hostAssemblies = AssemblyLoadContext.Default.Assemblies;
}
protected override Assembly? Load(AssemblyName assemblyName)
{
// Attempt to reuse assemblies that already exist in the default context when versions are compatible.
var existing = hostAssemblies.FirstOrDefault(a => string.Equals(
a.GetName().Name,
assemblyName.Name,
StringComparison.OrdinalIgnoreCase));
if (existing != null && IsCompatible(existing.GetName(), assemblyName))
{
return existing;
}
var assemblyPath = resolver.ResolveAssemblyToPath(assemblyName);
if (!string.IsNullOrEmpty(assemblyPath))
{
return LoadFromAssemblyPath(assemblyPath);
}
return null;
}
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
var libraryPath = resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
if (!string.IsNullOrEmpty(libraryPath))
{
return LoadUnmanagedDllFromPath(libraryPath);
}
return IntPtr.Zero;
}
private static bool IsCompatible(AssemblyName hostAssembly, AssemblyName pluginAssembly)
{
if (hostAssembly.Version == pluginAssembly.Version)
{
return true;
}
if (hostAssembly.Version is null || pluginAssembly.Version is null)
{
return false;
}
if (hostAssembly.Version.Major == pluginAssembly.Version.Major &&
hostAssembly.Version.Minor >= pluginAssembly.Version.Minor)
{
return true;
}
if (hostAssembly.Version.Major >= pluginAssembly.Version.Major)
{
return true;
}
return false;
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
namespace StellaOps.Plugin.Hosting;
internal sealed class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver resolver;
private readonly IEnumerable<Assembly> hostAssemblies;
public PluginLoadContext(string pluginPath)
: base(isCollectible: false)
{
resolver = new AssemblyDependencyResolver(pluginPath);
hostAssemblies = AssemblyLoadContext.Default.Assemblies;
}
protected override Assembly? Load(AssemblyName assemblyName)
{
// Attempt to reuse assemblies that already exist in the default context when versions are compatible.
var existing = hostAssemblies.FirstOrDefault(a => string.Equals(
a.GetName().Name,
assemblyName.Name,
StringComparison.OrdinalIgnoreCase));
if (existing != null && IsCompatible(existing.GetName(), assemblyName))
{
return existing;
}
var assemblyPath = resolver.ResolveAssemblyToPath(assemblyName);
if (!string.IsNullOrEmpty(assemblyPath))
{
return LoadFromAssemblyPath(assemblyPath);
}
return null;
}
protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
{
var libraryPath = resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
if (!string.IsNullOrEmpty(libraryPath))
{
return LoadUnmanagedDllFromPath(libraryPath);
}
return IntPtr.Zero;
}
private static bool IsCompatible(AssemblyName hostAssembly, AssemblyName pluginAssembly)
{
if (hostAssembly.Version == pluginAssembly.Version)
{
return true;
}
if (hostAssembly.Version is null || pluginAssembly.Version is null)
{
return false;
}
if (hostAssembly.Version.Major == pluginAssembly.Version.Major &&
hostAssembly.Version.Minor >= pluginAssembly.Version.Minor)
{
return true;
}
if (hostAssembly.Version.Major >= pluginAssembly.Version.Major)
{
return true;
}
return false;
}
}

View File

@@ -1,21 +1,21 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace StellaOps.Plugin.Internal;
internal static class ReflectionExtensions
{
public static IEnumerable<Type> GetLoadableTypes(this Assembly assembly)
{
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
return ex.Types.Where(static t => t is not null)!;
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace StellaOps.Plugin.Internal;
internal static class ReflectionExtensions
{
public static IEnumerable<Type> GetLoadableTypes(this Assembly assembly)
{
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
return ex.Types.Where(static t => t is not null)!;
}
}
}

View File

@@ -1,172 +1,172 @@
using StellaOps.Plugin.Hosting;
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Threading;
using System.Linq;
using System.Threading.Tasks;
namespace StellaOps.Plugin;
public interface IAvailabilityPlugin
{
string Name { get; }
bool IsAvailable(IServiceProvider services);
}
public interface IFeedConnector
{
string SourceName { get; }
Task FetchAsync(IServiceProvider services, CancellationToken cancellationToken);
Task ParseAsync(IServiceProvider services, CancellationToken cancellationToken);
Task MapAsync(IServiceProvider services, CancellationToken cancellationToken);
}
public interface IFeedExporter
{
string Name { get; }
Task ExportAsync(IServiceProvider services, CancellationToken cancellationToken);
}
public interface IConnectorPlugin : IAvailabilityPlugin
{
IFeedConnector Create(IServiceProvider services);
}
public interface IExporterPlugin : IAvailabilityPlugin
{
IFeedExporter Create(IServiceProvider services);
}
public sealed class PluginCatalog
{
private readonly List<Assembly> _assemblies = new();
private readonly HashSet<string> _assemblyLocations = new(StringComparer.OrdinalIgnoreCase);
public PluginCatalog AddAssembly(Assembly assembly)
{
if (assembly == null) throw new ArgumentNullException(nameof(assembly));
if (_assemblies.Contains(assembly))
{
return this;
}
_assemblies.Add(assembly);
if (!string.IsNullOrWhiteSpace(assembly.Location))
{
_assemblyLocations.Add(Path.GetFullPath(assembly.Location));
}
return this;
}
public PluginCatalog AddFromDirectory(string directory, string searchPattern = "StellaOps.Concelier.*.dll")
{
if (string.IsNullOrWhiteSpace(directory)) throw new ArgumentException("Directory is required", nameof(directory));
var fullDirectory = Path.GetFullPath(directory);
var options = new PluginHostOptions
{
PluginsDirectory = fullDirectory,
EnsureDirectoryExists = false,
RecursiveSearch = false,
};
options.SearchPatterns.Add(searchPattern);
var result = PluginHost.LoadPlugins(options);
foreach (var plugin in result.Plugins)
{
AddAssembly(plugin.Assembly);
}
return this;
}
public IReadOnlyList<IConnectorPlugin> GetConnectorPlugins() => PluginLoader.LoadPlugins<IConnectorPlugin>(_assemblies);
public IReadOnlyList<IExporterPlugin> GetExporterPlugins() => PluginLoader.LoadPlugins<IExporterPlugin>(_assemblies);
public IReadOnlyList<IConnectorPlugin> GetAvailableConnectorPlugins(IServiceProvider services)
=> FilterAvailable(GetConnectorPlugins(), services);
public IReadOnlyList<IExporterPlugin> GetAvailableExporterPlugins(IServiceProvider services)
=> FilterAvailable(GetExporterPlugins(), services);
private static IReadOnlyList<TPlugin> FilterAvailable<TPlugin>(IEnumerable<TPlugin> plugins, IServiceProvider services)
where TPlugin : IAvailabilityPlugin
{
var list = new List<TPlugin>();
foreach (var plugin in plugins)
{
try
{
if (plugin.IsAvailable(services))
{
list.Add(plugin);
}
}
catch
{
// Treat exceptions as plugin not available.
}
}
return list;
}
}
public static class PluginLoader
{
public static IReadOnlyList<TPlugin> LoadPlugins<TPlugin>(IEnumerable<Assembly> assemblies)
where TPlugin : class
{
if (assemblies == null) throw new ArgumentNullException(nameof(assemblies));
var plugins = new List<TPlugin>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var assembly in assemblies)
{
foreach (var candidate in SafeGetTypes(assembly))
{
if (candidate.IsAbstract || candidate.IsInterface)
{
continue;
}
if (!typeof(TPlugin).IsAssignableFrom(candidate))
{
continue;
}
if (Activator.CreateInstance(candidate) is not TPlugin plugin)
{
continue;
}
var key = candidate.FullName ?? candidate.Name;
if (key is null || !seen.Add(key))
{
continue;
}
plugins.Add(plugin);
}
}
return plugins;
}
private static IEnumerable<Type> SafeGetTypes(Assembly assembly)
{
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
return ex.Types.Where(t => t is not null)!;
}
}
}
using StellaOps.Plugin.Hosting;
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Threading;
using System.Linq;
using System.Threading.Tasks;
namespace StellaOps.Plugin;
public interface IAvailabilityPlugin
{
string Name { get; }
bool IsAvailable(IServiceProvider services);
}
public interface IFeedConnector
{
string SourceName { get; }
Task FetchAsync(IServiceProvider services, CancellationToken cancellationToken);
Task ParseAsync(IServiceProvider services, CancellationToken cancellationToken);
Task MapAsync(IServiceProvider services, CancellationToken cancellationToken);
}
public interface IFeedExporter
{
string Name { get; }
Task ExportAsync(IServiceProvider services, CancellationToken cancellationToken);
}
public interface IConnectorPlugin : IAvailabilityPlugin
{
IFeedConnector Create(IServiceProvider services);
}
public interface IExporterPlugin : IAvailabilityPlugin
{
IFeedExporter Create(IServiceProvider services);
}
public sealed class PluginCatalog
{
private readonly List<Assembly> _assemblies = new();
private readonly HashSet<string> _assemblyLocations = new(StringComparer.OrdinalIgnoreCase);
public PluginCatalog AddAssembly(Assembly assembly)
{
if (assembly == null) throw new ArgumentNullException(nameof(assembly));
if (_assemblies.Contains(assembly))
{
return this;
}
_assemblies.Add(assembly);
if (!string.IsNullOrWhiteSpace(assembly.Location))
{
_assemblyLocations.Add(Path.GetFullPath(assembly.Location));
}
return this;
}
public PluginCatalog AddFromDirectory(string directory, string searchPattern = "StellaOps.Concelier.*.dll")
{
if (string.IsNullOrWhiteSpace(directory)) throw new ArgumentException("Directory is required", nameof(directory));
var fullDirectory = Path.GetFullPath(directory);
var options = new PluginHostOptions
{
PluginsDirectory = fullDirectory,
EnsureDirectoryExists = false,
RecursiveSearch = false,
};
options.SearchPatterns.Add(searchPattern);
var result = PluginHost.LoadPlugins(options);
foreach (var plugin in result.Plugins)
{
AddAssembly(plugin.Assembly);
}
return this;
}
public IReadOnlyList<IConnectorPlugin> GetConnectorPlugins() => PluginLoader.LoadPlugins<IConnectorPlugin>(_assemblies);
public IReadOnlyList<IExporterPlugin> GetExporterPlugins() => PluginLoader.LoadPlugins<IExporterPlugin>(_assemblies);
public IReadOnlyList<IConnectorPlugin> GetAvailableConnectorPlugins(IServiceProvider services)
=> FilterAvailable(GetConnectorPlugins(), services);
public IReadOnlyList<IExporterPlugin> GetAvailableExporterPlugins(IServiceProvider services)
=> FilterAvailable(GetExporterPlugins(), services);
private static IReadOnlyList<TPlugin> FilterAvailable<TPlugin>(IEnumerable<TPlugin> plugins, IServiceProvider services)
where TPlugin : IAvailabilityPlugin
{
var list = new List<TPlugin>();
foreach (var plugin in plugins)
{
try
{
if (plugin.IsAvailable(services))
{
list.Add(plugin);
}
}
catch
{
// Treat exceptions as plugin not available.
}
}
return list;
}
}
public static class PluginLoader
{
public static IReadOnlyList<TPlugin> LoadPlugins<TPlugin>(IEnumerable<Assembly> assemblies)
where TPlugin : class
{
if (assemblies == null) throw new ArgumentNullException(nameof(assemblies));
var plugins = new List<TPlugin>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var assembly in assemblies)
{
foreach (var candidate in SafeGetTypes(assembly))
{
if (candidate.IsAbstract || candidate.IsInterface)
{
continue;
}
if (!typeof(TPlugin).IsAssignableFrom(candidate))
{
continue;
}
if (Activator.CreateInstance(candidate) is not TPlugin plugin)
{
continue;
}
var key = candidate.FullName ?? candidate.Name;
if (key is null || !seen.Add(key))
{
continue;
}
plugins.Add(plugin);
}
}
return plugins;
}
private static IEnumerable<Type> SafeGetTypes(Assembly assembly)
{
try
{
return assembly.GetTypes();
}
catch (ReflectionTypeLoadException ex)
{
return ex.Types.Where(t => t is not null)!;
}
}
}

View File

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

View File

@@ -1,173 +0,0 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Provenance.Mongo;
// Minimal stubs to avoid MongoDB.Bson dependency while keeping callers unchanged.
public abstract class BsonValue
{
public virtual object? Value => null;
public virtual BsonDocument AsBsonDocument =>
this as BsonDocument ?? throw new InvalidCastException("Value is not a BsonDocument.");
public virtual BsonArray AsBsonArray =>
this as BsonArray ?? throw new InvalidCastException("Value is not a BsonArray.");
public virtual string AsString => Value?.ToString() ?? string.Empty;
public virtual int AsInt32 => Convert.ToInt32(Value);
public virtual long AsInt64 => Convert.ToInt64(Value);
public virtual double AsDouble => Convert.ToDouble(Value);
public virtual bool AsBoolean => Convert.ToBoolean(Value);
internal static BsonValue Wrap(object? value) =>
value switch
{
null => BsonNull.Value,
BsonValue bson => bson,
string s => new BsonString(s),
bool b => new BsonBoolean(b),
int i => new BsonInt32(i),
long l => new BsonInt64(l),
double d => new BsonDouble(d),
IEnumerable<BsonValue> bsonEnumerable => new BsonArray(bsonEnumerable),
IEnumerable enumerable => new BsonArray(enumerable.Cast<object?>()),
_ => new BsonRaw(value)
};
}
public sealed class BsonNull : BsonValue
{
public static readonly BsonNull ValueInstance = new();
public new static BsonNull Value => ValueInstance;
}
public sealed class BsonString : BsonValue
{
public BsonString(string value) => Value = value;
public override object? Value { get; }
public override string ToString() => Value?.ToString() ?? string.Empty;
}
public sealed class BsonBoolean : BsonValue
{
public BsonBoolean(bool value) => Value = value;
public override object? Value { get; }
}
public sealed class BsonInt32 : BsonValue
{
public BsonInt32(int value) => Value = value;
public override object? Value { get; }
}
public sealed class BsonInt64 : BsonValue
{
public BsonInt64(long value) => Value = value;
public override object? Value { get; }
}
public sealed class BsonDouble : BsonValue
{
public BsonDouble(double value) => Value = value;
public override object? Value { get; }
}
public sealed class BsonRaw : BsonValue
{
public BsonRaw(object value) => Value = value;
public override object? Value { get; }
}
public record struct BsonElement(string Name, BsonValue Value);
public class BsonDocument : BsonValue, IEnumerable<KeyValuePair<string, BsonValue>>
{
private readonly Dictionary<string, object?> _values = new(StringComparer.Ordinal);
public BsonDocument()
{
}
public BsonDocument(string key, object? value)
{
_values[key] = value;
}
public BsonValue this[string key]
{
get => BsonValue.Wrap(_values[key]);
set => _values[key] = value;
}
public void Add(string key, object? value) => _values.Add(key, value);
public IEnumerable<BsonElement> Elements => _values.Select(kvp => new BsonElement(kvp.Key, BsonValue.Wrap(kvp.Value)));
public bool Contains(string key) => ContainsKey(key);
public bool ContainsKey(string key) => _values.ContainsKey(key);
public override object? Value => this;
public override BsonDocument AsBsonDocument => this;
public IEnumerator<KeyValuePair<string, BsonValue>> GetEnumerator() =>
_values.Select(kvp => new KeyValuePair<string, BsonValue>(kvp.Key, BsonValue.Wrap(kvp.Value))).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public class BsonArray : BsonValue, IEnumerable<BsonValue>
{
private readonly List<object?> _items = new();
public BsonArray()
{
}
public BsonArray(IEnumerable items)
{
foreach (var item in items)
{
Add(item);
}
}
public BsonArray(IEnumerable<object?> items)
: this()
{
foreach (var item in items)
{
Add(item);
}
}
public BsonValue this[int index] => BsonValue.Wrap(_items[index]);
public void Add(BsonDocument doc) => _items.Add(doc);
public void Add(object? value) => _items.Add(value);
public int Count => _items.Count;
public override object? Value => this;
public override BsonArray AsBsonArray => this;
public IEnumerator<BsonValue> GetEnumerator() => _items.Select(BsonValue.Wrap).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public static class BsonValueExtensions
{
public static BsonDocument AsBsonDocument(this object? value) => BsonValue.Wrap(value).AsBsonDocument;
public static BsonArray AsBsonArray(this object? value) => BsonValue.Wrap(value).AsBsonArray;
public static string AsString(this object? value) => BsonValue.Wrap(value).AsString;
public static int AsInt32(this object? value) => BsonValue.Wrap(value).AsInt32;
public static long AsInt64(this object? value) => BsonValue.Wrap(value).AsInt64;
public static double AsDouble(this object? value) => BsonValue.Wrap(value).AsDouble;
public static bool AsBoolean(this object? value) => BsonValue.Wrap(value).AsBoolean;
}

View File

@@ -0,0 +1,173 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
namespace StellaOps.Provenance;
// Minimal stubs for document serialization without external dependencies.
public abstract class DocumentValue
{
public virtual object? Value => null;
public virtual DocumentObject AsDocumentObject =>
this as DocumentObject ?? throw new InvalidCastException("Value is not a DocumentObject.");
public virtual DocumentArray AsDocumentArray =>
this as DocumentArray ?? throw new InvalidCastException("Value is not a DocumentArray.");
public virtual string AsString => Value?.ToString() ?? string.Empty;
public virtual int AsInt32 => Convert.ToInt32(Value);
public virtual long AsInt64 => Convert.ToInt64(Value);
public virtual double AsDouble => Convert.ToDouble(Value);
public virtual bool AsBoolean => Convert.ToBoolean(Value);
internal static DocumentValue Wrap(object? value) =>
value switch
{
null => DocumentNull.Value,
DocumentValue doc => doc,
string s => new DocumentString(s),
bool b => new DocumentBoolean(b),
int i => new DocumentInt32(i),
long l => new DocumentInt64(l),
double d => new DocumentDouble(d),
IEnumerable<DocumentValue> docEnumerable => new DocumentArray(docEnumerable),
IEnumerable enumerable => new DocumentArray(enumerable.Cast<object?>()),
_ => new DocumentRaw(value)
};
}
public sealed class DocumentNull : DocumentValue
{
public static readonly DocumentNull ValueInstance = new();
public new static DocumentNull Value => ValueInstance;
}
public sealed class DocumentString : DocumentValue
{
public DocumentString(string value) => Value = value;
public override object? Value { get; }
public override string ToString() => Value?.ToString() ?? string.Empty;
}
public sealed class DocumentBoolean : DocumentValue
{
public DocumentBoolean(bool value) => Value = value;
public override object? Value { get; }
}
public sealed class DocumentInt32 : DocumentValue
{
public DocumentInt32(int value) => Value = value;
public override object? Value { get; }
}
public sealed class DocumentInt64 : DocumentValue
{
public DocumentInt64(long value) => Value = value;
public override object? Value { get; }
}
public sealed class DocumentDouble : DocumentValue
{
public DocumentDouble(double value) => Value = value;
public override object? Value { get; }
}
public sealed class DocumentRaw : DocumentValue
{
public DocumentRaw(object value) => Value = value;
public override object? Value { get; }
}
public record struct DocumentElement(string Name, DocumentValue Value);
public class DocumentObject : DocumentValue, IEnumerable<KeyValuePair<string, DocumentValue>>
{
private readonly Dictionary<string, object?> _values = new(StringComparer.Ordinal);
public DocumentObject()
{
}
public DocumentObject(string key, object? value)
{
_values[key] = value;
}
public DocumentValue this[string key]
{
get => DocumentValue.Wrap(_values[key]);
set => _values[key] = value;
}
public void Add(string key, object? value) => _values.Add(key, value);
public IEnumerable<DocumentElement> Elements => _values.Select(kvp => new DocumentElement(kvp.Key, DocumentValue.Wrap(kvp.Value)));
public bool Contains(string key) => ContainsKey(key);
public bool ContainsKey(string key) => _values.ContainsKey(key);
public override object? Value => this;
public override DocumentObject AsDocumentObject => this;
public IEnumerator<KeyValuePair<string, DocumentValue>> GetEnumerator() =>
_values.Select(kvp => new KeyValuePair<string, DocumentValue>(kvp.Key, DocumentValue.Wrap(kvp.Value))).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public class DocumentArray : DocumentValue, IEnumerable<DocumentValue>
{
private readonly List<object?> _items = new();
public DocumentArray()
{
}
public DocumentArray(IEnumerable items)
{
foreach (var item in items)
{
Add(item);
}
}
public DocumentArray(IEnumerable<object?> items)
: this()
{
foreach (var item in items)
{
Add(item);
}
}
public DocumentValue this[int index] => DocumentValue.Wrap(_items[index]);
public void Add(DocumentObject doc) => _items.Add(doc);
public void Add(object? value) => _items.Add(value);
public int Count => _items.Count;
public override object? Value => this;
public override DocumentArray AsDocumentArray => this;
public IEnumerator<DocumentValue> GetEnumerator() => _items.Select(DocumentValue.Wrap).GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public static class DocumentValueExtensions
{
public static DocumentObject AsDocumentObject(this object? value) => DocumentValue.Wrap(value).AsDocumentObject;
public static DocumentArray AsDocumentArray(this object? value) => DocumentValue.Wrap(value).AsDocumentArray;
public static string AsString(this object? value) => DocumentValue.Wrap(value).AsString;
public static int AsInt32(this object? value) => DocumentValue.Wrap(value).AsInt32;
public static long AsInt64(this object? value) => DocumentValue.Wrap(value).AsInt64;
public static double AsDouble(this object? value) => DocumentValue.Wrap(value).AsDouble;
public static bool AsBoolean(this object? value) => DocumentValue.Wrap(value).AsBoolean;
}

View File

@@ -1,6 +1,6 @@
using System.Collections.Generic;
namespace StellaOps.Provenance.Mongo;
namespace StellaOps.Provenance;
public sealed class DsseKeyInfo
{

View File

@@ -1,20 +1,20 @@
namespace StellaOps.Provenance.Mongo;
namespace StellaOps.Provenance;
public static class ProvenanceMongoExtensions
public static class ProvenanceExtensions
{
private const string ProvenanceFieldName = "provenance";
private const string DsseFieldName = "dsse";
private const string TrustFieldName = "trust";
private const string ChainFieldName = "chain";
private static BsonValue StringOrNull(string? value) =>
value is null ? BsonNull.Value : new BsonString(value);
private static DocumentValue StringOrNull(string? value) =>
value is null ? DocumentNull.Value : new DocumentString(value);
/// <summary>
/// Attach DSSE provenance + trust info to an event document in-place.
/// Designed for generic BsonDocument-based event envelopes.
/// Designed for generic DocumentObject-based event envelopes.
/// </summary>
public static BsonDocument AttachDsseProvenance(
this BsonDocument eventDoc,
public static DocumentObject AttachDsseProvenance(
this DocumentObject eventDoc,
DsseProvenance dsse,
TrustInfo trust)
{
@@ -22,11 +22,11 @@ public static class ProvenanceMongoExtensions
if (dsse is null) throw new ArgumentNullException(nameof(dsse));
if (trust is null) throw new ArgumentNullException(nameof(trust));
var dsseDoc = new BsonDocument
var dsseDoc = new DocumentObject
{
{ "envelopeDigest", dsse.EnvelopeDigest },
{ "payloadType", dsse.PayloadType },
{ "key", new BsonDocument
{ "key", new DocumentObject
{
{ "keyId", dsse.Key.KeyId },
{ "issuer", StringOrNull(dsse.Key.Issuer) },
@@ -37,7 +37,7 @@ public static class ProvenanceMongoExtensions
if (dsse.Rekor is not null)
{
var rekorDoc = new BsonDocument
var rekorDoc = new DocumentObject
{
{ "logIndex", dsse.Rekor.LogIndex },
{ "uuid", dsse.Rekor.Uuid }
@@ -54,10 +54,10 @@ public static class ProvenanceMongoExtensions
if (dsse.Chain is not null && dsse.Chain.Count > 0)
{
var chainArray = new BsonArray();
var chainArray = new DocumentArray();
foreach (var link in dsse.Chain)
{
chainArray.Add(new BsonDocument
chainArray.Add(new DocumentObject
{
{ "type", link.Type },
{ "id", link.Id },
@@ -68,7 +68,7 @@ public static class ProvenanceMongoExtensions
dsseDoc.Add(ChainFieldName, chainArray);
}
var trustDoc = new BsonDocument
var trustDoc = new DocumentObject
{
{ "verified", trust.Verified },
{ "verifier", StringOrNull(trust.Verifier) }
@@ -80,7 +80,7 @@ public static class ProvenanceMongoExtensions
if (trust.PolicyScore is not null)
trustDoc.Add("policyScore", trust.PolicyScore);
var provenanceDoc = new BsonDocument
var provenanceDoc = new DocumentObject
{
{ DsseFieldName, dsseDoc }
};
@@ -95,15 +95,15 @@ public static class ProvenanceMongoExtensions
/// Helper to query for "cryptographically proven" events:
/// kind + subject.digest.sha256 + presence of Rekor logIndex + trust.verified = true.
/// </summary>
public static BsonDocument BuildProvenVexFilter(
public static DocumentObject BuildProvenVexFilter(
string kind,
string subjectDigestSha256)
{
return new BsonDocument
return new DocumentObject
{
{ "kind", kind },
{ "subject.digest.sha256", subjectDigestSha256 },
{ $"{ProvenanceFieldName}.{DsseFieldName}.rekor.logIndex", new BsonDocument("$exists", true) },
{ $"{ProvenanceFieldName}.{DsseFieldName}.rekor.logIndex", new DocumentObject("$exists", true) },
{ $"{TrustFieldName}.verified", true }
};
}
@@ -111,27 +111,27 @@ public static class ProvenanceMongoExtensions
/// <summary>
/// Helper to query for events influencing policy without solid provenance.
/// </summary>
public static BsonDocument BuildUnprovenEvidenceFilter(
public static DocumentObject BuildUnprovenEvidenceFilter(
IEnumerable<string> kinds)
{
var kindsArray = new BsonArray(kinds);
var kindsArray = new DocumentArray(kinds);
return new BsonDocument
return new DocumentObject
{
{
"kind", new BsonDocument("$in", kindsArray)
"kind", new DocumentObject("$in", kindsArray)
},
{
"$or", new BsonArray
"$or", new DocumentArray
{
new BsonDocument
new DocumentObject
{
{ $"{TrustFieldName}.verified", new BsonDocument("$ne", true) }
{ $"{TrustFieldName}.verified", new DocumentObject("$ne", true) }
},
new BsonDocument
new DocumentObject
{
{ $"{ProvenanceFieldName}.{DsseFieldName}.rekor.logIndex",
new BsonDocument("$exists", false) }
new DocumentObject("$exists", false) }
}
}
}

View File

@@ -5,7 +5,7 @@ using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Provenance.Mongo;
namespace StellaOps.Provenance;
public static class ProvenanceJsonParser
{

View File

@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using MongoDB.Bson;
using MongoDB.Bson.Serialization.Attributes;
using StellaOps.Replay.Serialization;
namespace StellaOps.Replay.Core;
@@ -12,10 +11,10 @@ public static class ReplayCollections
public const string Subjects = "replay_subjects";
}
[BsonIgnoreExtraElements]
[IgnoreExtraElements]
public sealed class ReplayRunRecord
{
[BsonId]
[Id]
public string Id { get; set; } = string.Empty; // scan UUID
public string ManifestHash { get; set; } = string.Empty; // sha256:...
@@ -48,10 +47,10 @@ public sealed class ReplaySignatureRecord
= false;
}
[BsonIgnoreExtraElements]
[IgnoreExtraElements]
public sealed class ReplayBundleRecord
{
[BsonId]
[Id]
public string Id { get; set; } = string.Empty; // sha256 hex
public string Type { get; set; } = string.Empty; // input|output|rootpack|reachability
@@ -64,10 +63,10 @@ public sealed class ReplayBundleRecord
public DateTime CreatedAt { get; set; } = DateTime.SpecifyKind(DateTime.UnixEpoch, DateTimeKind.Utc);
}
[BsonIgnoreExtraElements]
[IgnoreExtraElements]
public sealed class ReplaySubjectRecord
{
[BsonId]
[Id]
public string OciDigest { get; set; } = string.Empty;
public List<ReplayLayerRecord> Layers { get; set; } = new();
@@ -82,7 +81,7 @@ public sealed class ReplayLayerRecord
}
/// <summary>
/// Index names to keep mongod migrations deterministic.
/// Index names to keep database migrations deterministic.
/// </summary>
public static class ReplayIndexes
{

View File

@@ -0,0 +1,15 @@
namespace StellaOps.Replay.Serialization;
/// <summary>
/// Compatibility attribute to mark a property as the document ID.
/// This is a no-op shim for document serialization.
/// </summary>
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class IdAttribute : Attribute;
/// <summary>
/// Compatibility attribute to ignore extra elements during deserialization.
/// This is a no-op shim for document serialization.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
public sealed class IgnoreExtraElementsAttribute : Attribute;

View File

@@ -6,7 +6,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ZstdSharp.Port" Version="0.8.6" />
<PackageReference Include="MongoDB.Bson" Version="2.25.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" />

View File

@@ -4,22 +4,22 @@ using System.Globalization;
using Microsoft.Extensions.Configuration;
using StellaOps.Configuration;
using Xunit;
namespace StellaOps.Configuration.Tests;
public class StellaOpsAuthorityOptionsTests
{
[Fact]
public void Validate_Throws_When_IssuerMissing()
{
var options = new StellaOpsAuthorityOptions();
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("issuer", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
namespace StellaOps.Configuration.Tests;
public class StellaOpsAuthorityOptionsTests
{
[Fact]
public void Validate_Throws_When_IssuerMissing()
{
var options = new StellaOpsAuthorityOptions();
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("issuer", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_Normalises_Collections()
{
var options = new StellaOpsAuthorityOptions
@@ -67,35 +67,35 @@ public class StellaOpsAuthorityOptionsTests
Assert.Contains("remote inference", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
[Fact]
public void Validate_Normalises_PluginDescriptors()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
SchemaVersion = 1
};
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
var descriptor = new AuthorityPluginDescriptorOptions
{
AssemblyName = "StellaOps.Authority.Plugin.Standard",
ConfigFile = " standard.yaml ",
Enabled = true
};
descriptor.Capabilities.Add("password");
descriptor.Capabilities.Add("PASSWORD");
options.Plugins.Descriptors["standard"] = descriptor;
options.Validate();
var normalized = options.Plugins.Descriptors["standard"];
Assert.Equal("standard.yaml", normalized.ConfigFile);
Assert.Single(normalized.Capabilities);
SchemaVersion = 1
};
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
var descriptor = new AuthorityPluginDescriptorOptions
{
AssemblyName = "StellaOps.Authority.Plugin.Standard",
ConfigFile = " standard.yaml ",
Enabled = true
};
descriptor.Capabilities.Add("password");
descriptor.Capabilities.Add("PASSWORD");
options.Plugins.Descriptors["standard"] = descriptor;
options.Validate();
var normalized = options.Plugins.Descriptors["standard"];
Assert.Equal("standard.yaml", normalized.ConfigFile);
Assert.Single(normalized.Capabilities);
Assert.Equal("password", normalized.Capabilities[0]);
}
@@ -163,149 +163,149 @@ public class StellaOpsAuthorityOptionsTests
Assert.Contains("consentVersion", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_Throws_When_StorageConnectionStringMissing()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
SchemaVersion = 1
};
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Mongo connection string", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Build_Binds_From_Configuration()
{
var context = StellaOpsAuthorityConfiguration.Build(options =>
{
options.ConfigureBuilder = builder =>
{
builder.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:SchemaVersion"] = "2",
["Authority:Issuer"] = "https://authority.internal",
["Authority:AccessTokenLifetime"] = "00:30:00",
["Authority:RefreshTokenLifetime"] = "30.00:00:00",
["Authority:Storage:ConnectionString"] = "mongodb://example/stellaops",
["Authority:Storage:DatabaseName"] = "overrideDb",
["Authority:Storage:CommandTimeout"] = "00:01:30",
["Authority:PluginDirectories:0"] = "/var/lib/stellaops/plugins",
["Authority:BypassNetworks:0"] = "127.0.0.1/32",
["Authority:Security:RateLimiting:Token:PermitLimit"] = "25",
["Authority:Security:RateLimiting:Token:Window"] = "00:00:30",
["Authority:Security:RateLimiting:Authorize:Enabled"] = "true",
["Authority:Security:RateLimiting:Internal:Enabled"] = "true",
["Authority:Security:RateLimiting:Internal:PermitLimit"] = "3",
["Authority:Signing:Enabled"] = "true",
["Authority:Signing:ActiveKeyId"] = "authority-signing-dev",
["Authority:Signing:KeyPath"] = "../certificates/authority-signing-dev.pem",
["Authority:Signing:KeySource"] = "file"
});
};
});
var options = context.Options;
Assert.Equal(2, options.SchemaVersion);
Assert.Equal(new Uri("https://authority.internal"), options.Issuer);
Assert.Equal(TimeSpan.FromMinutes(30), options.AccessTokenLifetime);
Assert.Equal(TimeSpan.FromDays(30), options.RefreshTokenLifetime);
Assert.Equal(new[] { "/var/lib/stellaops/plugins" }, options.PluginDirectories);
Assert.Equal(new[] { "127.0.0.1/32" }, options.BypassNetworks);
Assert.Equal("mongodb://example/stellaops", options.Storage.ConnectionString);
Assert.Equal("overrideDb", options.Storage.DatabaseName);
Assert.Equal(TimeSpan.FromMinutes(1.5), options.Storage.CommandTimeout);
Assert.Equal(25, options.Security.RateLimiting.Token.PermitLimit);
Assert.Equal(TimeSpan.FromSeconds(30), options.Security.RateLimiting.Token.Window);
Assert.True(options.Security.RateLimiting.Authorize.Enabled);
Assert.True(options.Security.RateLimiting.Internal.Enabled);
Assert.Equal(3, options.Security.RateLimiting.Internal.PermitLimit);
Assert.True(options.Signing.Enabled);
Assert.Equal("authority-signing-dev", options.Signing.ActiveKeyId);
Assert.Equal("../certificates/authority-signing-dev.pem", options.Signing.KeyPath);
Assert.Equal("file", options.Signing.KeySource);
}
[Fact]
public void Validate_Normalises_ExceptionRoutingTemplates()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
SchemaVersion = 1
};
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
{
Id = " SecOps ",
AuthorityRouteId = " approvals/secops ",
RequireMfa = true,
Description = " Security approvals "
});
options.Validate();
Assert.True(options.Exceptions.RequiresMfaForApprovals);
var template = Assert.Single(options.Exceptions.NormalizedRoutingTemplates);
Assert.Equal("SecOps", template.Key);
Assert.Equal("SecOps", template.Value.Id);
Assert.Equal("approvals/secops", template.Value.AuthorityRouteId);
Assert.Equal("Security approvals", template.Value.Description);
Assert.True(template.Value.RequireMfa);
}
[Fact]
public void Validate_Throws_When_ExceptionRoutingTemplatesDuplicate()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
SchemaVersion = 1
};
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
{
Id = "secops",
AuthorityRouteId = "route/a"
});
options.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
{
Id = "SecOps",
AuthorityRouteId = "route/b"
});
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("secops", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_Throws_When_RateLimitingInvalid()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
SchemaVersion = 1
};
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
options.Security.RateLimiting.Token.PermitLimit = 0;
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("permitLimit", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}
[Fact]
public void Validate_Throws_When_StorageConnectionStringMissing()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
SchemaVersion = 1
};
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Mongo connection string", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Build_Binds_From_Configuration()
{
var context = StellaOpsAuthorityConfiguration.Build(options =>
{
options.ConfigureBuilder = builder =>
{
builder.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:SchemaVersion"] = "2",
["Authority:Issuer"] = "https://authority.internal",
["Authority:AccessTokenLifetime"] = "00:30:00",
["Authority:RefreshTokenLifetime"] = "30.00:00:00",
["Authority:Storage:ConnectionString"] = "mongodb://example/stellaops",
["Authority:Storage:DatabaseName"] = "overrideDb",
["Authority:Storage:CommandTimeout"] = "00:01:30",
["Authority:PluginDirectories:0"] = "/var/lib/stellaops/plugins",
["Authority:BypassNetworks:0"] = "127.0.0.1/32",
["Authority:Security:RateLimiting:Token:PermitLimit"] = "25",
["Authority:Security:RateLimiting:Token:Window"] = "00:00:30",
["Authority:Security:RateLimiting:Authorize:Enabled"] = "true",
["Authority:Security:RateLimiting:Internal:Enabled"] = "true",
["Authority:Security:RateLimiting:Internal:PermitLimit"] = "3",
["Authority:Signing:Enabled"] = "true",
["Authority:Signing:ActiveKeyId"] = "authority-signing-dev",
["Authority:Signing:KeyPath"] = "../certificates/authority-signing-dev.pem",
["Authority:Signing:KeySource"] = "file"
});
};
});
var options = context.Options;
Assert.Equal(2, options.SchemaVersion);
Assert.Equal(new Uri("https://authority.internal"), options.Issuer);
Assert.Equal(TimeSpan.FromMinutes(30), options.AccessTokenLifetime);
Assert.Equal(TimeSpan.FromDays(30), options.RefreshTokenLifetime);
Assert.Equal(new[] { "/var/lib/stellaops/plugins" }, options.PluginDirectories);
Assert.Equal(new[] { "127.0.0.1/32" }, options.BypassNetworks);
Assert.Equal("mongodb://example/stellaops", options.Storage.ConnectionString);
Assert.Equal("overrideDb", options.Storage.DatabaseName);
Assert.Equal(TimeSpan.FromMinutes(1.5), options.Storage.CommandTimeout);
Assert.Equal(25, options.Security.RateLimiting.Token.PermitLimit);
Assert.Equal(TimeSpan.FromSeconds(30), options.Security.RateLimiting.Token.Window);
Assert.True(options.Security.RateLimiting.Authorize.Enabled);
Assert.True(options.Security.RateLimiting.Internal.Enabled);
Assert.Equal(3, options.Security.RateLimiting.Internal.PermitLimit);
Assert.True(options.Signing.Enabled);
Assert.Equal("authority-signing-dev", options.Signing.ActiveKeyId);
Assert.Equal("../certificates/authority-signing-dev.pem", options.Signing.KeyPath);
Assert.Equal("file", options.Signing.KeySource);
}
[Fact]
public void Validate_Normalises_ExceptionRoutingTemplates()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
SchemaVersion = 1
};
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
{
Id = " SecOps ",
AuthorityRouteId = " approvals/secops ",
RequireMfa = true,
Description = " Security approvals "
});
options.Validate();
Assert.True(options.Exceptions.RequiresMfaForApprovals);
var template = Assert.Single(options.Exceptions.NormalizedRoutingTemplates);
Assert.Equal("SecOps", template.Key);
Assert.Equal("SecOps", template.Value.Id);
Assert.Equal("approvals/secops", template.Value.AuthorityRouteId);
Assert.Equal("Security approvals", template.Value.Description);
Assert.True(template.Value.RequireMfa);
}
[Fact]
public void Validate_Throws_When_ExceptionRoutingTemplatesDuplicate()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
SchemaVersion = 1
};
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
options.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
{
Id = "secops",
AuthorityRouteId = "route/a"
});
options.Exceptions.RoutingTemplates.Add(new AuthorityExceptionRoutingTemplateOptions
{
Id = "SecOps",
AuthorityRouteId = "route/b"
});
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("secops", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_Throws_When_RateLimitingInvalid()
{
var options = new StellaOpsAuthorityOptions
{
Issuer = new Uri("https://authority.stella-ops.test"),
SchemaVersion = 1
};
options.Storage.ConnectionString = "mongodb://localhost:27017/authority";
options.Security.RateLimiting.Token.PermitLimit = 0;
options.Signing.ActiveKeyId = "test-key";
options.Signing.KeyPath = "/tmp/test-key.pem";
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("permitLimit", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -1,57 +1,57 @@
using System;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Cryptography.Tests.Audit;
public class AuthEventRecordTests
{
[Fact]
public void AuthEventRecord_InitializesCollections()
{
var record = new AuthEventRecord
{
EventType = "authority.test",
Outcome = AuthEventOutcome.Success
};
Assert.NotNull(record.Scopes);
Assert.Empty(record.Scopes);
Assert.NotNull(record.Properties);
Assert.Empty(record.Properties);
Assert.False(record.Tenant.HasValue);
Assert.False(record.Project.HasValue);
}
[Fact]
public void ClassifiedString_NormalizesWhitespace()
{
var value = ClassifiedString.Personal(" ");
Assert.Null(value.Value);
Assert.False(value.HasValue);
Assert.Equal(AuthEventDataClassification.Personal, value.Classification);
}
[Fact]
public void Subject_DefaultsToEmptyCollections()
{
var subject = new AuthEventSubject();
Assert.NotNull(subject.Attributes);
Assert.Empty(subject.Attributes);
}
[Fact]
public void Record_AssignsTimestamp_WhenNotProvided()
{
var record = new AuthEventRecord
{
EventType = "authority.test",
Outcome = AuthEventOutcome.Success
};
Assert.NotEqual(default, record.OccurredAt);
Assert.InRange(
record.OccurredAt,
DateTimeOffset.UtcNow.AddSeconds(-5),
DateTimeOffset.UtcNow.AddSeconds(5));
}
}
using System;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Cryptography.Tests.Audit;
public class AuthEventRecordTests
{
[Fact]
public void AuthEventRecord_InitializesCollections()
{
var record = new AuthEventRecord
{
EventType = "authority.test",
Outcome = AuthEventOutcome.Success
};
Assert.NotNull(record.Scopes);
Assert.Empty(record.Scopes);
Assert.NotNull(record.Properties);
Assert.Empty(record.Properties);
Assert.False(record.Tenant.HasValue);
Assert.False(record.Project.HasValue);
}
[Fact]
public void ClassifiedString_NormalizesWhitespace()
{
var value = ClassifiedString.Personal(" ");
Assert.Null(value.Value);
Assert.False(value.HasValue);
Assert.Equal(AuthEventDataClassification.Personal, value.Classification);
}
[Fact]
public void Subject_DefaultsToEmptyCollections()
{
var subject = new AuthEventSubject();
Assert.NotNull(subject.Attributes);
Assert.Empty(subject.Attributes);
}
[Fact]
public void Record_AssignsTimestamp_WhenNotProvided()
{
var record = new AuthEventRecord
{
EventType = "authority.test",
Outcome = AuthEventOutcome.Success
};
Assert.NotEqual(default, record.OccurredAt);
Assert.InRange(
record.OccurredAt,
DateTimeOffset.UtcNow.AddSeconds(-5),
DateTimeOffset.UtcNow.AddSeconds(5));
}
}

View File

@@ -1,52 +1,52 @@
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Cryptography.Plugin.BouncyCastle;
using Xunit;
namespace StellaOps.Cryptography.Tests;
public sealed class BouncyCastleEd25519CryptoProviderTests
{
[Fact]
public async Task SignAndVerify_WithBouncyCastleProvider_Succeeds()
{
var services = new ServiceCollection();
services.AddStellaOpsCrypto();
services.AddBouncyCastleEd25519Provider();
using var provider = services.BuildServiceProvider();
var registry = provider.GetRequiredService<ICryptoProviderRegistry>();
var bcProvider = provider.GetServices<ICryptoProvider>()
.OfType<BouncyCastleEd25519CryptoProvider>()
.Single();
var keyId = "ed25519-unit-test";
var privateKeyBytes = Enumerable.Range(0, 32).Select(i => (byte)(i + 1)).ToArray();
var keyReference = new CryptoKeyReference(keyId, bcProvider.Name);
var signingKey = new CryptoSigningKey(
keyReference,
SignatureAlgorithms.Ed25519,
privateKeyBytes,
createdAt: DateTimeOffset.UtcNow);
bcProvider.UpsertSigningKey(signingKey);
var resolution = registry.ResolveSigner(
CryptoCapability.Signing,
SignatureAlgorithms.Ed25519,
keyReference,
bcProvider.Name);
var payload = new byte[] { 0x01, 0x02, 0x03, 0x04 };
var signature = await resolution.Signer.SignAsync(payload);
Assert.True(await resolution.Signer.VerifyAsync(payload, signature));
var jwk = resolution.Signer.ExportPublicJsonWebKey();
Assert.Equal("OKP", jwk.Kty);
Assert.Equal("Ed25519", jwk.Crv);
Assert.Equal(SignatureAlgorithms.EdDsa, jwk.Alg);
Assert.Equal(keyId, jwk.Kid);
}
}
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
using StellaOps.Cryptography.Plugin.BouncyCastle;
using Xunit;
namespace StellaOps.Cryptography.Tests;
public sealed class BouncyCastleEd25519CryptoProviderTests
{
[Fact]
public async Task SignAndVerify_WithBouncyCastleProvider_Succeeds()
{
var services = new ServiceCollection();
services.AddStellaOpsCrypto();
services.AddBouncyCastleEd25519Provider();
using var provider = services.BuildServiceProvider();
var registry = provider.GetRequiredService<ICryptoProviderRegistry>();
var bcProvider = provider.GetServices<ICryptoProvider>()
.OfType<BouncyCastleEd25519CryptoProvider>()
.Single();
var keyId = "ed25519-unit-test";
var privateKeyBytes = Enumerable.Range(0, 32).Select(i => (byte)(i + 1)).ToArray();
var keyReference = new CryptoKeyReference(keyId, bcProvider.Name);
var signingKey = new CryptoSigningKey(
keyReference,
SignatureAlgorithms.Ed25519,
privateKeyBytes,
createdAt: DateTimeOffset.UtcNow);
bcProvider.UpsertSigningKey(signingKey);
var resolution = registry.ResolveSigner(
CryptoCapability.Signing,
SignatureAlgorithms.Ed25519,
keyReference,
bcProvider.Name);
var payload = new byte[] { 0x01, 0x02, 0x03, 0x04 };
var signature = await resolution.Signer.SignAsync(payload);
Assert.True(await resolution.Signer.VerifyAsync(payload, signature));
var jwk = resolution.Signer.ExportPublicJsonWebKey();
Assert.Equal("OKP", jwk.Kty);
Assert.Equal("Ed25519", jwk.Crv);
Assert.Equal(SignatureAlgorithms.EdDsa, jwk.Alg);
Assert.Equal(keyId, jwk.Kid);
}
}

View File

@@ -1,112 +1,112 @@
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.DependencyInjection;
using StellaOps.Plugin.DependencyInjection;
using Xunit;
namespace StellaOps.Plugin.Tests.DependencyInjection;
public sealed class PluginServiceRegistrationTests
{
[Fact]
public void RegisterAssemblyMetadata_RegistersScopedDescriptor()
{
var services = new ServiceCollection();
PluginServiceRegistration.RegisterAssemblyMetadata(
services,
typeof(ScopedTestService).Assembly,
NullLogger.Instance);
var descriptor = Assert.Single(services, static d => d.ServiceType == typeof(IScopedService));
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
Assert.Equal(typeof(ScopedTestService), descriptor.ImplementationType);
}
[Fact]
public void RegisterAssemblyMetadata_HonoursRegisterAsSelf()
{
var services = new ServiceCollection();
PluginServiceRegistration.RegisterAssemblyMetadata(
services,
typeof(SelfRegisteringService).Assembly,
NullLogger.Instance);
Assert.Contains(services, static d =>
d.ServiceType == typeof(SelfRegisteringService) &&
d.ImplementationType == typeof(SelfRegisteringService));
}
[Fact]
public void RegisterAssemblyMetadata_ReplacesExistingDescriptorsWhenRequested()
{
var services = new ServiceCollection();
services.AddSingleton<IReplacementService, ExistingReplacementService>();
PluginServiceRegistration.RegisterAssemblyMetadata(
services,
typeof(ReplacementService).Assembly,
NullLogger.Instance);
var descriptor = Assert.Single(
services,
static d => d.ServiceType == typeof(IReplacementService) &&
d.ImplementationType == typeof(ReplacementService));
Assert.Equal(ServiceLifetime.Transient, descriptor.Lifetime);
}
[Fact]
public void RegisterAssemblyMetadata_SkipsInvalidAssignments()
{
var services = new ServiceCollection();
PluginServiceRegistration.RegisterAssemblyMetadata(
services,
typeof(InvalidServiceBinding).Assembly,
NullLogger.Instance);
Assert.DoesNotContain(services, static d => d.ServiceType == typeof(IAnotherService));
}
private interface IScopedService
{
}
private interface ISelfContract
{
}
private interface IReplacementService
{
}
private interface IAnotherService
{
}
private sealed class ExistingReplacementService : IReplacementService
{
}
[ServiceBinding(typeof(IScopedService), ServiceLifetime.Scoped)]
private sealed class ScopedTestService : IScopedService
{
}
[ServiceBinding(typeof(ISelfContract), ServiceLifetime.Singleton, RegisterAsSelf = true)]
private sealed class SelfRegisteringService : ISelfContract
{
}
[ServiceBinding(typeof(IReplacementService), ServiceLifetime.Transient, ReplaceExisting = true)]
private sealed class ReplacementService : IReplacementService
{
}
[ServiceBinding(typeof(IAnotherService), ServiceLifetime.Singleton)]
private sealed class InvalidServiceBinding
{
}
}
using System.Linq;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.DependencyInjection;
using StellaOps.Plugin.DependencyInjection;
using Xunit;
namespace StellaOps.Plugin.Tests.DependencyInjection;
public sealed class PluginServiceRegistrationTests
{
[Fact]
public void RegisterAssemblyMetadata_RegistersScopedDescriptor()
{
var services = new ServiceCollection();
PluginServiceRegistration.RegisterAssemblyMetadata(
services,
typeof(ScopedTestService).Assembly,
NullLogger.Instance);
var descriptor = Assert.Single(services, static d => d.ServiceType == typeof(IScopedService));
Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime);
Assert.Equal(typeof(ScopedTestService), descriptor.ImplementationType);
}
[Fact]
public void RegisterAssemblyMetadata_HonoursRegisterAsSelf()
{
var services = new ServiceCollection();
PluginServiceRegistration.RegisterAssemblyMetadata(
services,
typeof(SelfRegisteringService).Assembly,
NullLogger.Instance);
Assert.Contains(services, static d =>
d.ServiceType == typeof(SelfRegisteringService) &&
d.ImplementationType == typeof(SelfRegisteringService));
}
[Fact]
public void RegisterAssemblyMetadata_ReplacesExistingDescriptorsWhenRequested()
{
var services = new ServiceCollection();
services.AddSingleton<IReplacementService, ExistingReplacementService>();
PluginServiceRegistration.RegisterAssemblyMetadata(
services,
typeof(ReplacementService).Assembly,
NullLogger.Instance);
var descriptor = Assert.Single(
services,
static d => d.ServiceType == typeof(IReplacementService) &&
d.ImplementationType == typeof(ReplacementService));
Assert.Equal(ServiceLifetime.Transient, descriptor.Lifetime);
}
[Fact]
public void RegisterAssemblyMetadata_SkipsInvalidAssignments()
{
var services = new ServiceCollection();
PluginServiceRegistration.RegisterAssemblyMetadata(
services,
typeof(InvalidServiceBinding).Assembly,
NullLogger.Instance);
Assert.DoesNotContain(services, static d => d.ServiceType == typeof(IAnotherService));
}
private interface IScopedService
{
}
private interface ISelfContract
{
}
private interface IReplacementService
{
}
private interface IAnotherService
{
}
private sealed class ExistingReplacementService : IReplacementService
{
}
[ServiceBinding(typeof(IScopedService), ServiceLifetime.Scoped)]
private sealed class ScopedTestService : IScopedService
{
}
[ServiceBinding(typeof(ISelfContract), ServiceLifetime.Singleton, RegisterAsSelf = true)]
private sealed class SelfRegisteringService : ISelfContract
{
}
[ServiceBinding(typeof(IReplacementService), ServiceLifetime.Transient, ReplaceExisting = true)]
private sealed class ReplacementService : IReplacementService
{
}
[ServiceBinding(typeof(IAnotherService), ServiceLifetime.Singleton)]
private sealed class InvalidServiceBinding
{
}
}