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,28 +1,28 @@
using System.Diagnostics.Metrics;
namespace StellaOps.AdvisoryAI.Hosting;
public sealed class AdvisoryAiMetrics
{
private static readonly Meter Meter = new("StellaOps.AdvisoryAI", "1.0.0");
private readonly Counter<long> _requests;
private readonly Counter<long> _queuePublished;
private readonly Counter<long> _queueProcessed;
public AdvisoryAiMetrics()
{
_requests = Meter.CreateCounter<long>("advisory_ai_pipeline_requests_total");
_queuePublished = Meter.CreateCounter<long>("advisory_ai_pipeline_messages_enqueued_total");
_queueProcessed = Meter.CreateCounter<long>("advisory_ai_pipeline_messages_processed_total");
}
public void RecordRequest(string taskType)
=> _requests.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType));
public void RecordEnqueued(string taskType)
=> _queuePublished.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType));
public void RecordProcessed(string taskType)
=> _queueProcessed.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType));
}
using System.Diagnostics.Metrics;
namespace StellaOps.AdvisoryAI.Hosting;
public sealed class AdvisoryAiMetrics
{
private static readonly Meter Meter = new("StellaOps.AdvisoryAI", "1.0.0");
private readonly Counter<long> _requests;
private readonly Counter<long> _queuePublished;
private readonly Counter<long> _queueProcessed;
public AdvisoryAiMetrics()
{
_requests = Meter.CreateCounter<long>("advisory_ai_pipeline_requests_total");
_queuePublished = Meter.CreateCounter<long>("advisory_ai_pipeline_messages_enqueued_total");
_queueProcessed = Meter.CreateCounter<long>("advisory_ai_pipeline_messages_processed_total");
}
public void RecordRequest(string taskType)
=> _requests.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType));
public void RecordEnqueued(string taskType)
=> _queuePublished.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType));
public void RecordProcessed(string taskType)
=> _queueProcessed.Add(1, KeyValuePair.Create<string, object?>("task_type", taskType));
}

View File

@@ -1,41 +1,41 @@
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Providers;
using StellaOps.AdvisoryAI.Retrievers;
namespace StellaOps.AdvisoryAI.DependencyInjection;
public static class SbomContextServiceCollectionExtensions
{
public static IServiceCollection AddSbomContext(this IServiceCollection services, Action<SbomContextClientOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
var optionsBuilder = services.AddOptions<SbomContextClientOptions>();
if (configure is not null)
{
optionsBuilder.Configure(configure);
}
services.AddHttpClient<ISbomContextClient, SbomContextHttpClient>((serviceProvider, client) =>
{
var options = serviceProvider.GetRequiredService<IOptions<SbomContextClientOptions>>().Value;
if (options.BaseAddress is not null)
{
client.BaseAddress = options.BaseAddress;
}
if (!string.IsNullOrWhiteSpace(options.Tenant) && !string.IsNullOrWhiteSpace(options.TenantHeaderName))
{
client.DefaultRequestHeaders.Remove(options.TenantHeaderName);
client.DefaultRequestHeaders.Add(options.TenantHeaderName, options.Tenant);
}
});
services.TryAddSingleton<ISbomContextRetriever, SbomContextRetriever>();
return services;
}
}
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Providers;
using StellaOps.AdvisoryAI.Retrievers;
namespace StellaOps.AdvisoryAI.DependencyInjection;
public static class SbomContextServiceCollectionExtensions
{
public static IServiceCollection AddSbomContext(this IServiceCollection services, Action<SbomContextClientOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
var optionsBuilder = services.AddOptions<SbomContextClientOptions>();
if (configure is not null)
{
optionsBuilder.Configure(configure);
}
services.AddHttpClient<ISbomContextClient, SbomContextHttpClient>((serviceProvider, client) =>
{
var options = serviceProvider.GetRequiredService<IOptions<SbomContextClientOptions>>().Value;
if (options.BaseAddress is not null)
{
client.BaseAddress = options.BaseAddress;
}
if (!string.IsNullOrWhiteSpace(options.Tenant) && !string.IsNullOrWhiteSpace(options.TenantHeaderName))
{
client.DefaultRequestHeaders.Remove(options.TenantHeaderName);
client.DefaultRequestHeaders.Add(options.TenantHeaderName, options.Tenant);
}
});
services.TryAddSingleton<ISbomContextRetriever, SbomContextRetriever>();
return services;
}
}

View File

@@ -4,52 +4,52 @@ using System.Globalization;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Context;
using StellaOps.AdvisoryAI.Documents;
using StellaOps.AdvisoryAI.Tools;
namespace StellaOps.AdvisoryAI.Orchestration;
internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrator
{
private readonly IAdvisoryStructuredRetriever _structuredRetriever;
private readonly IAdvisoryVectorRetriever _vectorRetriever;
private readonly ISbomContextRetriever _sbomContextRetriever;
private readonly IDeterministicToolset _toolset;
private readonly AdvisoryPipelineOptions _options;
private readonly ILogger<AdvisoryPipelineOrchestrator>? _logger;
public AdvisoryPipelineOrchestrator(
IAdvisoryStructuredRetriever structuredRetriever,
IAdvisoryVectorRetriever vectorRetriever,
ISbomContextRetriever sbomContextRetriever,
IDeterministicToolset toolset,
IOptions<AdvisoryPipelineOptions> options,
ILogger<AdvisoryPipelineOrchestrator>? logger = null)
{
_structuredRetriever = structuredRetriever ?? throw new ArgumentNullException(nameof(structuredRetriever));
_vectorRetriever = vectorRetriever ?? throw new ArgumentNullException(nameof(vectorRetriever));
_sbomContextRetriever = sbomContextRetriever ?? throw new ArgumentNullException(nameof(sbomContextRetriever));
_toolset = toolset ?? throw new ArgumentNullException(nameof(toolset));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_options.ApplyDefaults();
_logger = logger;
}
public async Task<AdvisoryTaskPlan> CreatePlanAsync(AdvisoryTaskRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var config = _options.GetConfiguration(request.TaskType);
var structuredRequest = new AdvisoryRetrievalRequest(
request.AdvisoryKey,
request.PreferredSections,
config.StructuredMaxChunks);
namespace StellaOps.AdvisoryAI.Orchestration;
internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrator
{
private readonly IAdvisoryStructuredRetriever _structuredRetriever;
private readonly IAdvisoryVectorRetriever _vectorRetriever;
private readonly ISbomContextRetriever _sbomContextRetriever;
private readonly IDeterministicToolset _toolset;
private readonly AdvisoryPipelineOptions _options;
private readonly ILogger<AdvisoryPipelineOrchestrator>? _logger;
public AdvisoryPipelineOrchestrator(
IAdvisoryStructuredRetriever structuredRetriever,
IAdvisoryVectorRetriever vectorRetriever,
ISbomContextRetriever sbomContextRetriever,
IDeterministicToolset toolset,
IOptions<AdvisoryPipelineOptions> options,
ILogger<AdvisoryPipelineOrchestrator>? logger = null)
{
_structuredRetriever = structuredRetriever ?? throw new ArgumentNullException(nameof(structuredRetriever));
_vectorRetriever = vectorRetriever ?? throw new ArgumentNullException(nameof(vectorRetriever));
_sbomContextRetriever = sbomContextRetriever ?? throw new ArgumentNullException(nameof(sbomContextRetriever));
_toolset = toolset ?? throw new ArgumentNullException(nameof(toolset));
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
_options.ApplyDefaults();
_logger = logger;
}
public async Task<AdvisoryTaskPlan> CreatePlanAsync(AdvisoryTaskRequest request, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
var config = _options.GetConfiguration(request.TaskType);
var structuredRequest = new AdvisoryRetrievalRequest(
request.AdvisoryKey,
request.PreferredSections,
config.StructuredMaxChunks);
var structured = await _structuredRetriever
.RetrieveAsync(structuredRequest, cancellationToken)
.ConfigureAwait(false);
@@ -57,10 +57,10 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
var structuredChunks = NormalizeStructuredChunks(structured);
var vectorResults = await RetrieveVectorMatchesAsync(request, structuredRequest, config, cancellationToken).ConfigureAwait(false);
var (sbomContext, dependencyAnalysis) = await RetrieveSbomContextAsync(request, config, cancellationToken).ConfigureAwait(false);
var metadata = BuildMetadata(request, structured, vectorResults, sbomContext, dependencyAnalysis);
var cacheKey = ComputeCacheKey(request, structured, vectorResults, sbomContext, dependencyAnalysis);
var metadata = BuildMetadata(request, structured, vectorResults, sbomContext, dependencyAnalysis);
var cacheKey = ComputeCacheKey(request, structured, vectorResults, sbomContext, dependencyAnalysis);
var plan = new AdvisoryTaskPlan(
request,
cacheKey,
@@ -69,27 +69,27 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
vectorResults,
sbomContext,
dependencyAnalysis,
config.Budget,
metadata);
return plan;
}
private async Task<ImmutableArray<AdvisoryVectorResult>> RetrieveVectorMatchesAsync(
AdvisoryTaskRequest request,
AdvisoryRetrievalRequest structuredRequest,
AdvisoryTaskConfiguration configuration,
CancellationToken cancellationToken)
{
if (configuration.VectorQueries.Count == 0)
{
return ImmutableArray<AdvisoryVectorResult>.Empty;
}
var builder = ImmutableArray.CreateBuilder<AdvisoryVectorResult>(configuration.VectorQueries.Count);
foreach (var query in configuration.GetVectorQueries())
{
var vectorRequest = new VectorRetrievalRequest(structuredRequest, query, configuration.VectorTopK);
config.Budget,
metadata);
return plan;
}
private async Task<ImmutableArray<AdvisoryVectorResult>> RetrieveVectorMatchesAsync(
AdvisoryTaskRequest request,
AdvisoryRetrievalRequest structuredRequest,
AdvisoryTaskConfiguration configuration,
CancellationToken cancellationToken)
{
if (configuration.VectorQueries.Count == 0)
{
return ImmutableArray<AdvisoryVectorResult>.Empty;
}
var builder = ImmutableArray.CreateBuilder<AdvisoryVectorResult>(configuration.VectorQueries.Count);
foreach (var query in configuration.GetVectorQueries())
{
var vectorRequest = new VectorRetrievalRequest(structuredRequest, query, configuration.VectorTopK);
var matches = await _vectorRetriever
.SearchAsync(vectorRequest, cancellationToken)
.ConfigureAwait(false);
@@ -102,27 +102,27 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
builder.Add(new AdvisoryVectorResult(query, orderedMatches));
}
return builder.MoveToImmutable();
}
private async Task<(SbomContextResult? Context, DependencyAnalysisResult? Analysis)> RetrieveSbomContextAsync(
AdvisoryTaskRequest request,
AdvisoryTaskConfiguration configuration,
CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(request.ArtifactId))
{
return (null, null);
}
var sbomRequest = new SbomContextRequest(
artifactId: request.ArtifactId!,
purl: request.ArtifactPurl,
maxTimelineEntries: configuration.SbomMaxTimelineEntries,
maxDependencyPaths: configuration.SbomMaxDependencyPaths,
includeEnvironmentFlags: configuration.IncludeEnvironmentFlags,
includeBlastRadius: configuration.IncludeBlastRadius);
return builder.MoveToImmutable();
}
private async Task<(SbomContextResult? Context, DependencyAnalysisResult? Analysis)> RetrieveSbomContextAsync(
AdvisoryTaskRequest request,
AdvisoryTaskConfiguration configuration,
CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(request.ArtifactId))
{
return (null, null);
}
var sbomRequest = new SbomContextRequest(
artifactId: request.ArtifactId!,
purl: request.ArtifactPurl,
maxTimelineEntries: configuration.SbomMaxTimelineEntries,
maxDependencyPaths: configuration.SbomMaxDependencyPaths,
includeEnvironmentFlags: configuration.IncludeEnvironmentFlags,
includeBlastRadius: configuration.IncludeBlastRadius);
var context = await _sbomContextRetriever
.RetrieveAsync(sbomRequest, cancellationToken)
.ConfigureAwait(false);
@@ -135,73 +135,73 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
private static ImmutableDictionary<string, string> BuildMetadata(
AdvisoryTaskRequest request,
AdvisoryRetrievalResult structured,
ImmutableArray<AdvisoryVectorResult> vectors,
SbomContextResult? sbom,
DependencyAnalysisResult? dependency)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
builder["task_type"] = request.TaskType.ToString();
builder["advisory_key"] = request.AdvisoryKey;
builder["profile"] = request.Profile;
ImmutableArray<AdvisoryVectorResult> vectors,
SbomContextResult? sbom,
DependencyAnalysisResult? dependency)
{
var builder = ImmutableDictionary.CreateBuilder<string, string>(StringComparer.Ordinal);
builder["task_type"] = request.TaskType.ToString();
builder["advisory_key"] = request.AdvisoryKey;
builder["profile"] = request.Profile;
builder["structured_chunk_count"] = structured.Chunks.Count().ToString(CultureInfo.InvariantCulture);
builder["vector_query_count"] = vectors.Length.ToString(CultureInfo.InvariantCulture);
builder["vector_match_count"] = vectors.Sum(result => result.Matches.Length).ToString(CultureInfo.InvariantCulture);
builder["includes_sbom"] = (sbom is not null).ToString();
builder["dependency_node_count"] = (dependency?.Nodes.Length ?? 0).ToString(CultureInfo.InvariantCulture);
builder["force_refresh"] = request.ForceRefresh.ToString();
if (!string.IsNullOrEmpty(request.PolicyVersion))
{
builder["policy_version"] = request.PolicyVersion!;
}
if (sbom is not null)
{
builder["vector_query_count"] = vectors.Length.ToString(CultureInfo.InvariantCulture);
builder["vector_match_count"] = vectors.Sum(result => result.Matches.Length).ToString(CultureInfo.InvariantCulture);
builder["includes_sbom"] = (sbom is not null).ToString();
builder["dependency_node_count"] = (dependency?.Nodes.Length ?? 0).ToString(CultureInfo.InvariantCulture);
builder["force_refresh"] = request.ForceRefresh.ToString();
if (!string.IsNullOrEmpty(request.PolicyVersion))
{
builder["policy_version"] = request.PolicyVersion!;
}
if (sbom is not null)
{
builder["sbom_version_count"] = sbom.VersionTimeline.Length.ToString(CultureInfo.InvariantCulture);
builder["sbom_dependency_path_count"] = sbom.DependencyPaths.Length.ToString(CultureInfo.InvariantCulture);
if (!sbom.EnvironmentFlags.IsEmpty)
{
foreach (var flag in sbom.EnvironmentFlags.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
builder[$"sbom_env_{flag.Key}"] = flag.Value;
}
}
if (sbom.BlastRadius is not null)
{
builder["sbom_blast_impacted_assets"] = sbom.BlastRadius.ImpactedAssets.ToString(CultureInfo.InvariantCulture);
builder["sbom_blast_impacted_workloads"] = sbom.BlastRadius.ImpactedWorkloads.ToString(CultureInfo.InvariantCulture);
builder["sbom_blast_impacted_namespaces"] = sbom.BlastRadius.ImpactedNamespaces.ToString(CultureInfo.InvariantCulture);
if (sbom.BlastRadius.ImpactedPercentage is not null)
{
builder["sbom_blast_impacted_percentage"] = sbom.BlastRadius.ImpactedPercentage.Value.ToString("G", CultureInfo.InvariantCulture);
}
if (!sbom.BlastRadius.Metadata.IsEmpty)
{
foreach (var kvp in sbom.BlastRadius.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
builder[$"sbom_blast_meta_{kvp.Key}"] = kvp.Value;
}
}
}
if (!sbom.Metadata.IsEmpty)
{
foreach (var kvp in sbom.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
builder[$"sbom_meta_{kvp.Key}"] = kvp.Value;
}
}
}
if (dependency is not null)
{
foreach (var kvp in dependency.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
builder[$"dependency_{kvp.Key}"] = kvp.Value;
}
if (!sbom.EnvironmentFlags.IsEmpty)
{
foreach (var flag in sbom.EnvironmentFlags.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
builder[$"sbom_env_{flag.Key}"] = flag.Value;
}
}
if (sbom.BlastRadius is not null)
{
builder["sbom_blast_impacted_assets"] = sbom.BlastRadius.ImpactedAssets.ToString(CultureInfo.InvariantCulture);
builder["sbom_blast_impacted_workloads"] = sbom.BlastRadius.ImpactedWorkloads.ToString(CultureInfo.InvariantCulture);
builder["sbom_blast_impacted_namespaces"] = sbom.BlastRadius.ImpactedNamespaces.ToString(CultureInfo.InvariantCulture);
if (sbom.BlastRadius.ImpactedPercentage is not null)
{
builder["sbom_blast_impacted_percentage"] = sbom.BlastRadius.ImpactedPercentage.Value.ToString("G", CultureInfo.InvariantCulture);
}
if (!sbom.BlastRadius.Metadata.IsEmpty)
{
foreach (var kvp in sbom.BlastRadius.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
builder[$"sbom_blast_meta_{kvp.Key}"] = kvp.Value;
}
}
}
if (!sbom.Metadata.IsEmpty)
{
foreach (var kvp in sbom.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
builder[$"sbom_meta_{kvp.Key}"] = kvp.Value;
}
}
}
if (dependency is not null)
{
foreach (var kvp in dependency.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
builder[$"dependency_{kvp.Key}"] = kvp.Value;
}
}
return builder.ToImmutable();
@@ -249,177 +249,177 @@ internal sealed class AdvisoryPipelineOrchestrator : IAdvisoryPipelineOrchestrat
}
private static string ComputeCacheKey(
AdvisoryTaskRequest request,
AdvisoryRetrievalResult structured,
ImmutableArray<AdvisoryVectorResult> vectors,
SbomContextResult? sbom,
DependencyAnalysisResult? dependency)
{
var builder = new StringBuilder();
builder.Append(request.TaskType)
.Append('|').Append(request.AdvisoryKey)
.Append('|').Append(request.ArtifactId ?? string.Empty)
.Append('|').Append(request.PolicyVersion ?? string.Empty)
.Append('|').Append(request.Profile);
if (request.PreferredSections is not null)
{
foreach (var section in request.PreferredSections.OrderBy(s => s, StringComparer.OrdinalIgnoreCase))
{
builder.Append('|').Append(section);
}
}
foreach (var chunkId in structured.Chunks
.Select(chunk => chunk.ChunkId)
.OrderBy(id => id, StringComparer.Ordinal))
{
builder.Append("|chunk:").Append(chunkId);
}
foreach (var vector in vectors)
{
builder.Append("|query:").Append(vector.Query);
foreach (var match in vector.Matches
.OrderBy(m => m.ChunkId, StringComparer.Ordinal)
.ThenBy(m => m.Score))
{
builder.Append("|match:")
.Append(match.ChunkId)
.Append('@')
.Append(match.Score.ToString("G", CultureInfo.InvariantCulture));
}
}
if (sbom is not null)
{
AdvisoryTaskRequest request,
AdvisoryRetrievalResult structured,
ImmutableArray<AdvisoryVectorResult> vectors,
SbomContextResult? sbom,
DependencyAnalysisResult? dependency)
{
var builder = new StringBuilder();
builder.Append(request.TaskType)
.Append('|').Append(request.AdvisoryKey)
.Append('|').Append(request.ArtifactId ?? string.Empty)
.Append('|').Append(request.PolicyVersion ?? string.Empty)
.Append('|').Append(request.Profile);
if (request.PreferredSections is not null)
{
foreach (var section in request.PreferredSections.OrderBy(s => s, StringComparer.OrdinalIgnoreCase))
{
builder.Append('|').Append(section);
}
}
foreach (var chunkId in structured.Chunks
.Select(chunk => chunk.ChunkId)
.OrderBy(id => id, StringComparer.Ordinal))
{
builder.Append("|chunk:").Append(chunkId);
}
foreach (var vector in vectors)
{
builder.Append("|query:").Append(vector.Query);
foreach (var match in vector.Matches
.OrderBy(m => m.ChunkId, StringComparer.Ordinal)
.ThenBy(m => m.Score))
{
builder.Append("|match:")
.Append(match.ChunkId)
.Append('@')
.Append(match.Score.ToString("G", CultureInfo.InvariantCulture));
}
}
if (sbom is not null)
{
builder.Append("|sbom:timeline=").Append(sbom.VersionTimeline.Length);
builder.Append("|sbom:paths=").Append(sbom.DependencyPaths.Length);
foreach (var entry in sbom.VersionTimeline
.OrderBy(e => e.Version, StringComparer.Ordinal)
.ThenBy(e => e.FirstObserved.ToUnixTimeMilliseconds())
.ThenBy(e => e.LastObserved?.ToUnixTimeMilliseconds() ?? long.MinValue)
.ThenBy(e => e.Status, StringComparer.Ordinal)
.ThenBy(e => e.Source, StringComparer.Ordinal))
{
builder.Append("|timeline:")
.Append(entry.Version)
.Append('@')
.Append(entry.FirstObserved.ToUnixTimeMilliseconds())
.Append('@')
.Append(entry.LastObserved?.ToUnixTimeMilliseconds() ?? -1)
.Append('@')
.Append(entry.Status)
.Append('@')
.Append(entry.Source);
}
foreach (var path in sbom.DependencyPaths
.OrderBy(path => path.IsRuntime)
.ThenBy(path => string.Join(">", path.Nodes.Select(node => node.Identifier)), StringComparer.Ordinal))
{
builder.Append("|path:")
.Append(path.IsRuntime ? 'R' : 'D');
foreach (var node in path.Nodes)
{
builder.Append(":")
.Append(node.Identifier)
.Append('@')
.Append(node.Version ?? string.Empty);
}
if (!string.IsNullOrWhiteSpace(path.Source))
{
builder.Append("|pathsrc:").Append(path.Source);
}
if (!path.Metadata.IsEmpty)
{
foreach (var kvp in path.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
builder.Append("|pathmeta:")
.Append(kvp.Key)
.Append('=')
.Append(kvp.Value);
}
}
}
if (!sbom.EnvironmentFlags.IsEmpty)
{
foreach (var flag in sbom.EnvironmentFlags.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
builder.Append("|env:")
.Append(flag.Key)
.Append('=')
.Append(flag.Value);
}
}
if (sbom.BlastRadius is not null)
{
builder.Append("|blast:")
.Append(sbom.BlastRadius.ImpactedAssets)
.Append(',')
.Append(sbom.BlastRadius.ImpactedWorkloads)
.Append(',')
.Append(sbom.BlastRadius.ImpactedNamespaces)
.Append(',')
.Append(sbom.BlastRadius.ImpactedPercentage?.ToString("G", CultureInfo.InvariantCulture) ?? string.Empty);
if (!sbom.BlastRadius.Metadata.IsEmpty)
{
foreach (var kvp in sbom.BlastRadius.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
builder.Append("|blastmeta:")
.Append(kvp.Key)
.Append('=')
.Append(kvp.Value);
}
}
}
if (!sbom.Metadata.IsEmpty)
{
foreach (var kvp in sbom.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
builder.Append("|sbommeta:")
.Append(kvp.Key)
.Append('=')
.Append(kvp.Value);
}
}
}
if (dependency is not null)
{
foreach (var node in dependency.Nodes
.OrderBy(n => n.Identifier, StringComparer.Ordinal))
{
builder.Append("|dep:")
.Append(node.Identifier)
.Append(':')
.Append(node.RuntimeOccurrences)
.Append(':')
.Append(node.DevelopmentOccurrences)
.Append(':')
.Append(string.Join(',', node.Versions));
}
if (!dependency.Metadata.IsEmpty)
{
foreach (var kvp in dependency.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
builder.Append("|depmeta:")
.Append(kvp.Key)
.Append('=')
.Append(kvp.Value);
}
}
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
return Convert.ToHexString(hash);
}
}
foreach (var entry in sbom.VersionTimeline
.OrderBy(e => e.Version, StringComparer.Ordinal)
.ThenBy(e => e.FirstObserved.ToUnixTimeMilliseconds())
.ThenBy(e => e.LastObserved?.ToUnixTimeMilliseconds() ?? long.MinValue)
.ThenBy(e => e.Status, StringComparer.Ordinal)
.ThenBy(e => e.Source, StringComparer.Ordinal))
{
builder.Append("|timeline:")
.Append(entry.Version)
.Append('@')
.Append(entry.FirstObserved.ToUnixTimeMilliseconds())
.Append('@')
.Append(entry.LastObserved?.ToUnixTimeMilliseconds() ?? -1)
.Append('@')
.Append(entry.Status)
.Append('@')
.Append(entry.Source);
}
foreach (var path in sbom.DependencyPaths
.OrderBy(path => path.IsRuntime)
.ThenBy(path => string.Join(">", path.Nodes.Select(node => node.Identifier)), StringComparer.Ordinal))
{
builder.Append("|path:")
.Append(path.IsRuntime ? 'R' : 'D');
foreach (var node in path.Nodes)
{
builder.Append(":")
.Append(node.Identifier)
.Append('@')
.Append(node.Version ?? string.Empty);
}
if (!string.IsNullOrWhiteSpace(path.Source))
{
builder.Append("|pathsrc:").Append(path.Source);
}
if (!path.Metadata.IsEmpty)
{
foreach (var kvp in path.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
builder.Append("|pathmeta:")
.Append(kvp.Key)
.Append('=')
.Append(kvp.Value);
}
}
}
if (!sbom.EnvironmentFlags.IsEmpty)
{
foreach (var flag in sbom.EnvironmentFlags.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
builder.Append("|env:")
.Append(flag.Key)
.Append('=')
.Append(flag.Value);
}
}
if (sbom.BlastRadius is not null)
{
builder.Append("|blast:")
.Append(sbom.BlastRadius.ImpactedAssets)
.Append(',')
.Append(sbom.BlastRadius.ImpactedWorkloads)
.Append(',')
.Append(sbom.BlastRadius.ImpactedNamespaces)
.Append(',')
.Append(sbom.BlastRadius.ImpactedPercentage?.ToString("G", CultureInfo.InvariantCulture) ?? string.Empty);
if (!sbom.BlastRadius.Metadata.IsEmpty)
{
foreach (var kvp in sbom.BlastRadius.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
builder.Append("|blastmeta:")
.Append(kvp.Key)
.Append('=')
.Append(kvp.Value);
}
}
}
if (!sbom.Metadata.IsEmpty)
{
foreach (var kvp in sbom.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
builder.Append("|sbommeta:")
.Append(kvp.Key)
.Append('=')
.Append(kvp.Value);
}
}
}
if (dependency is not null)
{
foreach (var node in dependency.Nodes
.OrderBy(n => n.Identifier, StringComparer.Ordinal))
{
builder.Append("|dep:")
.Append(node.Identifier)
.Append(':')
.Append(node.RuntimeOccurrences)
.Append(':')
.Append(node.DevelopmentOccurrences)
.Append(':')
.Append(string.Join(',', node.Versions));
}
if (!dependency.Metadata.IsEmpty)
{
foreach (var kvp in dependency.Metadata.OrderBy(pair => pair.Key, StringComparer.Ordinal))
{
builder.Append("|depmeta:")
.Append(kvp.Key)
.Append('=')
.Append(kvp.Value);
}
}
}
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
return Convert.ToHexString(hash);
}
}

View File

@@ -1,70 +1,70 @@
using System.Collections.Immutable;
using System.Collections.Immutable;
using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Context;
using StellaOps.AdvisoryAI.Documents;
using StellaOps.AdvisoryAI.Tools;
namespace StellaOps.AdvisoryAI.Orchestration;
public sealed class AdvisoryTaskPlan
{
public AdvisoryTaskPlan(
AdvisoryTaskRequest request,
string cacheKey,
string promptTemplate,
ImmutableArray<AdvisoryChunk> structuredChunks,
ImmutableArray<AdvisoryVectorResult> vectorResults,
SbomContextResult? sbomContext,
DependencyAnalysisResult? dependencyAnalysis,
namespace StellaOps.AdvisoryAI.Orchestration;
public sealed class AdvisoryTaskPlan
{
public AdvisoryTaskPlan(
AdvisoryTaskRequest request,
string cacheKey,
string promptTemplate,
ImmutableArray<AdvisoryChunk> structuredChunks,
ImmutableArray<AdvisoryVectorResult> vectorResults,
SbomContextResult? sbomContext,
DependencyAnalysisResult? dependencyAnalysis,
AdvisoryTaskBudget budget,
ImmutableDictionary<string, string> metadata)
{
Request = request ?? throw new ArgumentNullException(nameof(request));
CacheKey = cacheKey ?? throw new ArgumentNullException(nameof(cacheKey));
PromptTemplate = promptTemplate ?? throw new ArgumentNullException(nameof(promptTemplate));
StructuredChunks = structuredChunks;
VectorResults = vectorResults;
SbomContext = sbomContext;
DependencyAnalysis = dependencyAnalysis;
Budget = budget ?? throw new ArgumentNullException(nameof(budget));
Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
}
public AdvisoryTaskRequest Request { get; }
public string CacheKey { get; }
public string PromptTemplate { get; }
public ImmutableArray<AdvisoryChunk> StructuredChunks { get; }
public ImmutableArray<AdvisoryVectorResult> VectorResults { get; }
public SbomContextResult? SbomContext { get; }
public DependencyAnalysisResult? DependencyAnalysis { get; }
public AdvisoryTaskBudget Budget { get; }
{
Request = request ?? throw new ArgumentNullException(nameof(request));
CacheKey = cacheKey ?? throw new ArgumentNullException(nameof(cacheKey));
PromptTemplate = promptTemplate ?? throw new ArgumentNullException(nameof(promptTemplate));
StructuredChunks = structuredChunks;
VectorResults = vectorResults;
SbomContext = sbomContext;
DependencyAnalysis = dependencyAnalysis;
Budget = budget ?? throw new ArgumentNullException(nameof(budget));
Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
}
public AdvisoryTaskRequest Request { get; }
public string CacheKey { get; }
public string PromptTemplate { get; }
public ImmutableArray<AdvisoryChunk> StructuredChunks { get; }
public ImmutableArray<AdvisoryVectorResult> VectorResults { get; }
public SbomContextResult? SbomContext { get; }
public DependencyAnalysisResult? DependencyAnalysis { get; }
public AdvisoryTaskBudget Budget { get; }
public ImmutableDictionary<string, string> Metadata { get; }
}
public sealed class AdvisoryVectorResult
{
public AdvisoryVectorResult(string query, ImmutableArray<VectorRetrievalMatch> matches)
{
Query = string.IsNullOrWhiteSpace(query) ? throw new ArgumentException(nameof(query)) : query;
Matches = matches;
}
public string Query { get; }
public ImmutableArray<VectorRetrievalMatch> Matches { get; }
}
public sealed class AdvisoryTaskBudget
{
public int PromptTokens { get; init; } = 2048;
public int CompletionTokens { get; init; } = 512;
}
}
public sealed class AdvisoryVectorResult
{
public AdvisoryVectorResult(string query, ImmutableArray<VectorRetrievalMatch> matches)
{
Query = string.IsNullOrWhiteSpace(query) ? throw new ArgumentException(nameof(query)) : query;
Matches = matches;
}
public string Query { get; }
public ImmutableArray<VectorRetrievalMatch> Matches { get; }
}
public sealed class AdvisoryTaskBudget
{
public int PromptTokens { get; init; } = 2048;
public int CompletionTokens { get; init; } = 512;
}

View File

@@ -1,30 +1,30 @@
using System;
namespace StellaOps.AdvisoryAI.Providers;
/// <summary>
/// Configuration for the SBOM context HTTP client.
/// </summary>
public sealed class SbomContextClientOptions
{
/// <summary>
/// Base address for the SBOM service. Required.
/// </summary>
public Uri? BaseAddress { get; set; }
/// <summary>
/// Relative endpoint that returns SBOM context payloads.
/// Defaults to <c>api/sbom/context</c>.
/// </summary>
public string ContextEndpoint { get; set; } = "api/sbom/context";
/// <summary>
/// Optional tenant identifier that should be forwarded to the SBOM service.
/// </summary>
public string? Tenant { get; set; }
/// <summary>
/// Header name used when forwarding the tenant. Defaults to <c>X-StellaOps-Tenant</c>.
/// </summary>
public string TenantHeaderName { get; set; } = "X-StellaOps-Tenant";
}
using System;
namespace StellaOps.AdvisoryAI.Providers;
/// <summary>
/// Configuration for the SBOM context HTTP client.
/// </summary>
public sealed class SbomContextClientOptions
{
/// <summary>
/// Base address for the SBOM service. Required.
/// </summary>
public Uri? BaseAddress { get; set; }
/// <summary>
/// Relative endpoint that returns SBOM context payloads.
/// Defaults to <c>api/sbom/context</c>.
/// </summary>
public string ContextEndpoint { get; set; } = "api/sbom/context";
/// <summary>
/// Optional tenant identifier that should be forwarded to the SBOM service.
/// </summary>
public string? Tenant { get; set; }
/// <summary>
/// Header name used when forwarding the tenant. Defaults to <c>X-StellaOps-Tenant</c>.
/// </summary>
public string TenantHeaderName { get; set; } = "X-StellaOps-Tenant";
}

View File

@@ -1,234 +1,234 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.AdvisoryAI.Providers;
internal sealed class SbomContextHttpClient : ISbomContextClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
private readonly HttpClient httpClient;
private readonly SbomContextClientOptions options;
private readonly ILogger<SbomContextHttpClient>? logger;
public SbomContextHttpClient(
HttpClient httpClient,
IOptions<SbomContextClientOptions> options,
ILogger<SbomContextHttpClient>? logger = null)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
this.options = options.Value ?? throw new ArgumentNullException(nameof(options));
if (this.options.BaseAddress is not null && this.httpClient.BaseAddress is null)
{
this.httpClient.BaseAddress = this.options.BaseAddress;
}
if (this.httpClient.BaseAddress is null)
{
throw new InvalidOperationException("SBOM context client requires a BaseAddress to be configured.");
}
this.httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json");
this.logger = logger;
}
public async Task<SbomContextDocument?> GetContextAsync(SbomContextQuery query, CancellationToken cancellationToken)
{
if (query is null)
{
throw new ArgumentNullException(nameof(query));
}
var endpoint = options.ContextEndpoint?.Trim() ?? string.Empty;
if (endpoint.Length == 0)
{
throw new InvalidOperationException("SBOM context endpoint must be configured.");
}
var requestUri = BuildRequestUri(endpoint, query);
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
ApplyTenantHeader(request);
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound || response.StatusCode == HttpStatusCode.NoContent)
{
logger?.LogDebug("Received {StatusCode} for SBOM context request {Uri}; returning null.", (int)response.StatusCode, requestUri);
return null;
}
if (!response.IsSuccessStatusCode)
{
var content = response.Content is null
? string.Empty
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger?.LogWarning(
"SBOM context request {Uri} failed with status {StatusCode}. Payload: {Payload}",
requestUri,
(int)response.StatusCode,
content);
response.EnsureSuccessStatusCode();
}
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace StellaOps.AdvisoryAI.Providers;
internal sealed class SbomContextHttpClient : ISbomContextClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true
};
private readonly HttpClient httpClient;
private readonly SbomContextClientOptions options;
private readonly ILogger<SbomContextHttpClient>? logger;
public SbomContextHttpClient(
HttpClient httpClient,
IOptions<SbomContextClientOptions> options,
ILogger<SbomContextHttpClient>? logger = null)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
this.options = options.Value ?? throw new ArgumentNullException(nameof(options));
if (this.options.BaseAddress is not null && this.httpClient.BaseAddress is null)
{
this.httpClient.BaseAddress = this.options.BaseAddress;
}
if (this.httpClient.BaseAddress is null)
{
throw new InvalidOperationException("SBOM context client requires a BaseAddress to be configured.");
}
this.httpClient.DefaultRequestHeaders.Accept.ParseAdd("application/json");
this.logger = logger;
}
public async Task<SbomContextDocument?> GetContextAsync(SbomContextQuery query, CancellationToken cancellationToken)
{
if (query is null)
{
throw new ArgumentNullException(nameof(query));
}
var endpoint = options.ContextEndpoint?.Trim() ?? string.Empty;
if (endpoint.Length == 0)
{
throw new InvalidOperationException("SBOM context endpoint must be configured.");
}
var requestUri = BuildRequestUri(endpoint, query);
using var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
ApplyTenantHeader(request);
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound || response.StatusCode == HttpStatusCode.NoContent)
{
logger?.LogDebug("Received {StatusCode} for SBOM context request {Uri}; returning null.", (int)response.StatusCode, requestUri);
return null;
}
if (!response.IsSuccessStatusCode)
{
var content = response.Content is null
? string.Empty
: await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger?.LogWarning(
"SBOM context request {Uri} failed with status {StatusCode}. Payload: {Payload}",
requestUri,
(int)response.StatusCode,
content);
response.EnsureSuccessStatusCode();
}
var httpContent = response.Content ?? throw new InvalidOperationException("SBOM context response did not include content.");
var payload = await httpContent.ReadFromJsonAsync<SbomContextPayload>(SerializerOptions, cancellationToken: cancellationToken)
.ConfigureAwait(false);
if (payload is null)
{
logger?.LogWarning("SBOM context response for {Uri} was empty.", requestUri);
return null;
}
return payload.ToDocument();
}
private Uri BuildRequestUri(string endpoint, SbomContextQuery query)
{
var relative = endpoint.StartsWith("/", StringComparison.Ordinal)
? endpoint[1..]
: endpoint;
var queryBuilder = new StringBuilder();
AppendQuery(queryBuilder, "artifactId", query.ArtifactId);
AppendQuery(queryBuilder, "maxTimelineEntries", query.MaxTimelineEntries.ToString(CultureInfo.InvariantCulture));
AppendQuery(queryBuilder, "maxDependencyPaths", query.MaxDependencyPaths.ToString(CultureInfo.InvariantCulture));
AppendQuery(queryBuilder, "includeEnvironmentFlags", query.IncludeEnvironmentFlags ? "true" : "false");
AppendQuery(queryBuilder, "includeBlastRadius", query.IncludeBlastRadius ? "true" : "false");
if (!string.IsNullOrWhiteSpace(query.Purl))
{
AppendQuery(queryBuilder, "purl", query.Purl!);
}
var uriString = queryBuilder.Length > 0 ? $"{relative}?{queryBuilder}" : relative;
return new Uri(httpClient.BaseAddress!, uriString);
static void AppendQuery(StringBuilder builder, string name, string value)
{
if (builder.Length > 0)
{
builder.Append('&');
}
builder.Append(Uri.EscapeDataString(name));
builder.Append('=');
builder.Append(Uri.EscapeDataString(value));
}
}
private void ApplyTenantHeader(HttpRequestMessage request)
{
if (string.IsNullOrWhiteSpace(options.Tenant) || string.IsNullOrWhiteSpace(options.TenantHeaderName))
{
return;
}
if (!request.Headers.Contains(options.TenantHeaderName))
{
request.Headers.Add(options.TenantHeaderName, options.Tenant);
}
}
private sealed record SbomContextPayload(
[property: JsonPropertyName("artifactId")] string ArtifactId,
[property: JsonPropertyName("purl")] string? Purl,
[property: JsonPropertyName("versions")] ImmutableArray<SbomVersionPayload> Versions,
[property: JsonPropertyName("dependencyPaths")] ImmutableArray<SbomDependencyPathPayload> DependencyPaths,
[property: JsonPropertyName("environmentFlags")] ImmutableDictionary<string, string> EnvironmentFlags,
[property: JsonPropertyName("blastRadius")] SbomBlastRadiusPayload? BlastRadius,
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata)
{
public SbomContextDocument ToDocument()
=> new(
ArtifactId,
Purl,
Versions.IsDefault ? ImmutableArray<SbomVersionRecord>.Empty : Versions.Select(v => v.ToRecord()).ToImmutableArray(),
DependencyPaths.IsDefault ? ImmutableArray<SbomDependencyPathRecord>.Empty : DependencyPaths.Select(p => p.ToRecord()).ToImmutableArray(),
EnvironmentFlags == default ? ImmutableDictionary<string, string>.Empty : EnvironmentFlags,
BlastRadius?.ToRecord(),
Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata);
}
private sealed record SbomVersionPayload(
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("firstObserved")] DateTimeOffset FirstObserved,
[property: JsonPropertyName("lastObserved")] DateTimeOffset? LastObserved,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("isFixAvailable")] bool IsFixAvailable,
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata)
{
public SbomVersionRecord ToRecord()
=> new(
Version,
FirstObserved,
LastObserved,
Status,
Source,
IsFixAvailable,
Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata);
}
private sealed record SbomDependencyPathPayload(
[property: JsonPropertyName("nodes")] ImmutableArray<SbomDependencyNodePayload> Nodes,
[property: JsonPropertyName("isRuntime")] bool IsRuntime,
[property: JsonPropertyName("source")] string? Source,
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata)
{
public SbomDependencyPathRecord ToRecord()
=> new(
Nodes.IsDefault ? ImmutableArray<SbomDependencyNodeRecord>.Empty : Nodes.Select(n => n.ToRecord()).ToImmutableArray(),
IsRuntime,
Source,
Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata);
}
private sealed record SbomDependencyNodePayload(
[property: JsonPropertyName("identifier")] string Identifier,
[property: JsonPropertyName("version")] string? Version)
{
public SbomDependencyNodeRecord ToRecord()
=> new(Identifier, Version);
}
private sealed record SbomBlastRadiusPayload(
[property: JsonPropertyName("impactedAssets")] int ImpactedAssets,
[property: JsonPropertyName("impactedWorkloads")] int ImpactedWorkloads,
[property: JsonPropertyName("impactedNamespaces")] int ImpactedNamespaces,
[property: JsonPropertyName("impactedPercentage")] double? ImpactedPercentage,
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata)
{
public SbomBlastRadiusRecord ToRecord()
=> new(
ImpactedAssets,
ImpactedWorkloads,
ImpactedNamespaces,
ImpactedPercentage,
Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata);
}
}
if (payload is null)
{
logger?.LogWarning("SBOM context response for {Uri} was empty.", requestUri);
return null;
}
return payload.ToDocument();
}
private Uri BuildRequestUri(string endpoint, SbomContextQuery query)
{
var relative = endpoint.StartsWith("/", StringComparison.Ordinal)
? endpoint[1..]
: endpoint;
var queryBuilder = new StringBuilder();
AppendQuery(queryBuilder, "artifactId", query.ArtifactId);
AppendQuery(queryBuilder, "maxTimelineEntries", query.MaxTimelineEntries.ToString(CultureInfo.InvariantCulture));
AppendQuery(queryBuilder, "maxDependencyPaths", query.MaxDependencyPaths.ToString(CultureInfo.InvariantCulture));
AppendQuery(queryBuilder, "includeEnvironmentFlags", query.IncludeEnvironmentFlags ? "true" : "false");
AppendQuery(queryBuilder, "includeBlastRadius", query.IncludeBlastRadius ? "true" : "false");
if (!string.IsNullOrWhiteSpace(query.Purl))
{
AppendQuery(queryBuilder, "purl", query.Purl!);
}
var uriString = queryBuilder.Length > 0 ? $"{relative}?{queryBuilder}" : relative;
return new Uri(httpClient.BaseAddress!, uriString);
static void AppendQuery(StringBuilder builder, string name, string value)
{
if (builder.Length > 0)
{
builder.Append('&');
}
builder.Append(Uri.EscapeDataString(name));
builder.Append('=');
builder.Append(Uri.EscapeDataString(value));
}
}
private void ApplyTenantHeader(HttpRequestMessage request)
{
if (string.IsNullOrWhiteSpace(options.Tenant) || string.IsNullOrWhiteSpace(options.TenantHeaderName))
{
return;
}
if (!request.Headers.Contains(options.TenantHeaderName))
{
request.Headers.Add(options.TenantHeaderName, options.Tenant);
}
}
private sealed record SbomContextPayload(
[property: JsonPropertyName("artifactId")] string ArtifactId,
[property: JsonPropertyName("purl")] string? Purl,
[property: JsonPropertyName("versions")] ImmutableArray<SbomVersionPayload> Versions,
[property: JsonPropertyName("dependencyPaths")] ImmutableArray<SbomDependencyPathPayload> DependencyPaths,
[property: JsonPropertyName("environmentFlags")] ImmutableDictionary<string, string> EnvironmentFlags,
[property: JsonPropertyName("blastRadius")] SbomBlastRadiusPayload? BlastRadius,
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata)
{
public SbomContextDocument ToDocument()
=> new(
ArtifactId,
Purl,
Versions.IsDefault ? ImmutableArray<SbomVersionRecord>.Empty : Versions.Select(v => v.ToRecord()).ToImmutableArray(),
DependencyPaths.IsDefault ? ImmutableArray<SbomDependencyPathRecord>.Empty : DependencyPaths.Select(p => p.ToRecord()).ToImmutableArray(),
EnvironmentFlags == default ? ImmutableDictionary<string, string>.Empty : EnvironmentFlags,
BlastRadius?.ToRecord(),
Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata);
}
private sealed record SbomVersionPayload(
[property: JsonPropertyName("version")] string Version,
[property: JsonPropertyName("firstObserved")] DateTimeOffset FirstObserved,
[property: JsonPropertyName("lastObserved")] DateTimeOffset? LastObserved,
[property: JsonPropertyName("status")] string Status,
[property: JsonPropertyName("source")] string Source,
[property: JsonPropertyName("isFixAvailable")] bool IsFixAvailable,
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata)
{
public SbomVersionRecord ToRecord()
=> new(
Version,
FirstObserved,
LastObserved,
Status,
Source,
IsFixAvailable,
Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata);
}
private sealed record SbomDependencyPathPayload(
[property: JsonPropertyName("nodes")] ImmutableArray<SbomDependencyNodePayload> Nodes,
[property: JsonPropertyName("isRuntime")] bool IsRuntime,
[property: JsonPropertyName("source")] string? Source,
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata)
{
public SbomDependencyPathRecord ToRecord()
=> new(
Nodes.IsDefault ? ImmutableArray<SbomDependencyNodeRecord>.Empty : Nodes.Select(n => n.ToRecord()).ToImmutableArray(),
IsRuntime,
Source,
Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata);
}
private sealed record SbomDependencyNodePayload(
[property: JsonPropertyName("identifier")] string Identifier,
[property: JsonPropertyName("version")] string? Version)
{
public SbomDependencyNodeRecord ToRecord()
=> new(Identifier, Version);
}
private sealed record SbomBlastRadiusPayload(
[property: JsonPropertyName("impactedAssets")] int ImpactedAssets,
[property: JsonPropertyName("impactedWorkloads")] int ImpactedWorkloads,
[property: JsonPropertyName("impactedNamespaces")] int ImpactedNamespaces,
[property: JsonPropertyName("impactedPercentage")] double? ImpactedPercentage,
[property: JsonPropertyName("metadata")] ImmutableDictionary<string, string> Metadata)
{
public SbomBlastRadiusRecord ToRecord()
=> new(
ImpactedAssets,
ImpactedWorkloads,
ImpactedNamespaces,
ImpactedPercentage,
Metadata == default ? ImmutableDictionary<string, string>.Empty : Metadata);
}
}

View File

@@ -1,79 +1,79 @@
using System.Collections.Immutable;
using System.Linq;
using FluentAssertions;
using StellaOps.AdvisoryAI.Context;
using StellaOps.AdvisoryAI.Tools;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests;
public sealed class DeterministicToolsetTests
{
[Fact]
public void AnalyzeDependencies_ComputesRuntimeAndDevelopmentCounts()
{
var context = SbomContextResult.Create(
"artifact-123",
purl: null,
versionTimeline: Array.Empty<SbomVersionTimelineEntry>(),
dependencyPaths: new[]
{
new SbomDependencyPath(
new[]
{
new SbomDependencyNode("root", "1.0.0"),
new SbomDependencyNode("lib-a", "2.0.0"),
},
isRuntime: true),
new SbomDependencyPath(
new[]
{
new SbomDependencyNode("root", "1.0.0"),
new SbomDependencyNode("lib-b", "3.1.4"),
},
isRuntime: false),
});
IDeterministicToolset toolset = new DeterministicToolset();
var analysis = toolset.AnalyzeDependencies(context);
analysis.ArtifactId.Should().Be("artifact-123");
analysis.Metadata["path_count"].Should().Be("2");
analysis.Metadata["runtime_path_count"].Should().Be("1");
analysis.Metadata["development_path_count"].Should().Be("1");
analysis.Nodes.Should().HaveCount(3);
var libA = analysis.Nodes.Single(node => node.Identifier == "lib-a");
libA.RuntimeOccurrences.Should().Be(1);
libA.DevelopmentOccurrences.Should().Be(0);
var libB = analysis.Nodes.Single(node => node.Identifier == "lib-b");
libB.RuntimeOccurrences.Should().Be(0);
libB.DevelopmentOccurrences.Should().Be(1);
}
[Theory]
[InlineData("semver", "1.2.3", "1.2.4", -1)]
[InlineData("semver", "1.2.3", "1.2.3", 0)]
[InlineData("semver", "1.2.4", "1.2.3", 1)]
[InlineData("evr", "1:1.0-1", "1:1.0-2", -1)]
[InlineData("evr", "0:2.0-0", "0:2.0-0", 0)]
[InlineData("evr", "0:2.1-0", "0:2.0-5", 1)]
public void TryCompare_SucceedsForSupportedSchemes(string scheme, string left, string right, int expected)
{
IDeterministicToolset toolset = new DeterministicToolset();
toolset.TryCompare(scheme, left, right, out var comparison).Should().BeTrue();
comparison.Should().Be(expected);
}
[Theory]
[InlineData("semver", "1.2.3", ">=1.0.0 <2.0.0")]
[InlineData("semver", "2.0.0", ">=2.0.0")]
[InlineData("evr", "0:1.2-3", ">=0:1.0-0 <0:2.0-0")]
[InlineData("evr", "1:3.4-1", ">=1:3.0-0")]
public void SatisfiesRange_HonoursExpressions(string scheme, string version, string range)
{
IDeterministicToolset toolset = new DeterministicToolset();
toolset.SatisfiesRange(scheme, version, range).Should().BeTrue();
}
}
using System.Collections.Immutable;
using System.Linq;
using FluentAssertions;
using StellaOps.AdvisoryAI.Context;
using StellaOps.AdvisoryAI.Tools;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests;
public sealed class DeterministicToolsetTests
{
[Fact]
public void AnalyzeDependencies_ComputesRuntimeAndDevelopmentCounts()
{
var context = SbomContextResult.Create(
"artifact-123",
purl: null,
versionTimeline: Array.Empty<SbomVersionTimelineEntry>(),
dependencyPaths: new[]
{
new SbomDependencyPath(
new[]
{
new SbomDependencyNode("root", "1.0.0"),
new SbomDependencyNode("lib-a", "2.0.0"),
},
isRuntime: true),
new SbomDependencyPath(
new[]
{
new SbomDependencyNode("root", "1.0.0"),
new SbomDependencyNode("lib-b", "3.1.4"),
},
isRuntime: false),
});
IDeterministicToolset toolset = new DeterministicToolset();
var analysis = toolset.AnalyzeDependencies(context);
analysis.ArtifactId.Should().Be("artifact-123");
analysis.Metadata["path_count"].Should().Be("2");
analysis.Metadata["runtime_path_count"].Should().Be("1");
analysis.Metadata["development_path_count"].Should().Be("1");
analysis.Nodes.Should().HaveCount(3);
var libA = analysis.Nodes.Single(node => node.Identifier == "lib-a");
libA.RuntimeOccurrences.Should().Be(1);
libA.DevelopmentOccurrences.Should().Be(0);
var libB = analysis.Nodes.Single(node => node.Identifier == "lib-b");
libB.RuntimeOccurrences.Should().Be(0);
libB.DevelopmentOccurrences.Should().Be(1);
}
[Theory]
[InlineData("semver", "1.2.3", "1.2.4", -1)]
[InlineData("semver", "1.2.3", "1.2.3", 0)]
[InlineData("semver", "1.2.4", "1.2.3", 1)]
[InlineData("evr", "1:1.0-1", "1:1.0-2", -1)]
[InlineData("evr", "0:2.0-0", "0:2.0-0", 0)]
[InlineData("evr", "0:2.1-0", "0:2.0-5", 1)]
public void TryCompare_SucceedsForSupportedSchemes(string scheme, string left, string right, int expected)
{
IDeterministicToolset toolset = new DeterministicToolset();
toolset.TryCompare(scheme, left, right, out var comparison).Should().BeTrue();
comparison.Should().Be(expected);
}
[Theory]
[InlineData("semver", "1.2.3", ">=1.0.0 <2.0.0")]
[InlineData("semver", "2.0.0", ">=2.0.0")]
[InlineData("evr", "0:1.2-3", ">=0:1.0-0 <0:2.0-0")]
[InlineData("evr", "1:3.4-1", ">=1:3.0-0")]
public void SatisfiesRange_HonoursExpressions(string scheme, string version, string range)
{
IDeterministicToolset toolset = new DeterministicToolset();
toolset.SatisfiesRange(scheme, version, range).Should().BeTrue();
}
}

View File

@@ -1,144 +1,144 @@
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Providers;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests;
public sealed class SbomContextHttpClientTests
{
[Fact]
public async Task GetContextAsync_MapsPayloadToDocument()
{
const string payload = """
{
"artifactId": "artifact-001",
"purl": "pkg:npm/react@18.3.0",
"versions": [
{
"version": "18.3.0",
"firstObserved": "2025-10-01T00:00:00Z",
"lastObserved": null,
"status": "affected",
"source": "inventory",
"isFixAvailable": false,
"metadata": { "note": "current" }
}
],
"dependencyPaths": [
{
"nodes": [
{ "identifier": "app", "version": "1.0.0" },
{ "identifier": "react", "version": "18.3.0" }
],
"isRuntime": true,
"source": "scanner",
"metadata": { "scope": "production" }
}
],
"environmentFlags": {
"environment/prod": "true"
},
"blastRadius": {
"impactedAssets": 10,
"impactedWorkloads": 4,
"impactedNamespaces": 2,
"impactedPercentage": 0.25,
"metadata": { "note": "simulated" }
},
"metadata": {
"source": "sbom-service"
}
}
""";
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json")
});
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://sbom.example/")
};
var options = Options.Create(new SbomContextClientOptions
{
ContextEndpoint = "api/sbom/context",
Tenant = "tenant-alpha",
TenantHeaderName = "X-StellaOps-Tenant"
});
var client = new SbomContextHttpClient(httpClient, options, NullLogger<SbomContextHttpClient>.Instance);
var query = new SbomContextQuery("artifact-001", "pkg:npm/react@18.3.0", 25, 10, includeEnvironmentFlags: true, includeBlastRadius: true);
var document = await client.GetContextAsync(query, CancellationToken.None);
Assert.NotNull(document);
Assert.Equal("artifact-001", document!.ArtifactId);
Assert.Equal("pkg:npm/react@18.3.0", document.Purl);
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StellaOps.AdvisoryAI.Providers;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests;
public sealed class SbomContextHttpClientTests
{
[Fact]
public async Task GetContextAsync_MapsPayloadToDocument()
{
const string payload = """
{
"artifactId": "artifact-001",
"purl": "pkg:npm/react@18.3.0",
"versions": [
{
"version": "18.3.0",
"firstObserved": "2025-10-01T00:00:00Z",
"lastObserved": null,
"status": "affected",
"source": "inventory",
"isFixAvailable": false,
"metadata": { "note": "current" }
}
],
"dependencyPaths": [
{
"nodes": [
{ "identifier": "app", "version": "1.0.0" },
{ "identifier": "react", "version": "18.3.0" }
],
"isRuntime": true,
"source": "scanner",
"metadata": { "scope": "production" }
}
],
"environmentFlags": {
"environment/prod": "true"
},
"blastRadius": {
"impactedAssets": 10,
"impactedWorkloads": 4,
"impactedNamespaces": 2,
"impactedPercentage": 0.25,
"metadata": { "note": "simulated" }
},
"metadata": {
"source": "sbom-service"
}
}
""";
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(payload, Encoding.UTF8, "application/json")
});
var httpClient = new HttpClient(handler)
{
BaseAddress = new Uri("https://sbom.example/")
};
var options = Options.Create(new SbomContextClientOptions
{
ContextEndpoint = "api/sbom/context",
Tenant = "tenant-alpha",
TenantHeaderName = "X-StellaOps-Tenant"
});
var client = new SbomContextHttpClient(httpClient, options, NullLogger<SbomContextHttpClient>.Instance);
var query = new SbomContextQuery("artifact-001", "pkg:npm/react@18.3.0", 25, 10, includeEnvironmentFlags: true, includeBlastRadius: true);
var document = await client.GetContextAsync(query, CancellationToken.None);
Assert.NotNull(document);
Assert.Equal("artifact-001", document!.ArtifactId);
Assert.Equal("pkg:npm/react@18.3.0", document.Purl);
Assert.Single(document.Versions);
Assert.Single(document.DependencyPaths);
Assert.Single(document.EnvironmentFlags);
Assert.NotNull(document.BlastRadius);
Assert.Equal("sbom-service", document.Metadata["source"]);
Assert.NotNull(handler.LastRequest);
Assert.Equal("tenant-alpha", handler.LastRequest!.Headers.GetValues("X-StellaOps-Tenant").Single());
Assert.Contains("artifactId=artifact-001", handler.LastRequest.RequestUri!.Query);
Assert.Contains("purl=pkg%3Anpm%2Freact%4018.3.0", handler.LastRequest.RequestUri!.Query);
Assert.Contains("includeEnvironmentFlags=true", handler.LastRequest.RequestUri!.Query);
Assert.Contains("includeBlastRadius=true", handler.LastRequest.RequestUri!.Query);
}
[Fact]
public async Task GetContextAsync_ReturnsNullOnNotFound()
{
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://sbom.example/") };
var options = Options.Create(new SbomContextClientOptions());
var client = new SbomContextHttpClient(httpClient, options, NullLogger<SbomContextHttpClient>.Instance);
var result = await client.GetContextAsync(new SbomContextQuery("missing", null, 10, 5, false, false), CancellationToken.None);
Assert.Null(result);
}
[Fact]
public async Task GetContextAsync_ThrowsForServerError()
{
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("{\"error\":\"boom\"}", Encoding.UTF8, "application/json")
});
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://sbom.example/") };
var options = Options.Create(new SbomContextClientOptions());
var client = new SbomContextHttpClient(httpClient, options, NullLogger<SbomContextHttpClient>.Instance);
await Assert.ThrowsAsync<HttpRequestException>(() => client.GetContextAsync(new SbomContextQuery("artifact", null, 5, 5, false, false), CancellationToken.None));
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> responder;
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
{
this.responder = responder ?? throw new ArgumentNullException(nameof(responder));
}
public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
return Task.FromResult(responder(request));
}
}
}
Assert.Single(document.DependencyPaths);
Assert.Single(document.EnvironmentFlags);
Assert.NotNull(document.BlastRadius);
Assert.Equal("sbom-service", document.Metadata["source"]);
Assert.NotNull(handler.LastRequest);
Assert.Equal("tenant-alpha", handler.LastRequest!.Headers.GetValues("X-StellaOps-Tenant").Single());
Assert.Contains("artifactId=artifact-001", handler.LastRequest.RequestUri!.Query);
Assert.Contains("purl=pkg%3Anpm%2Freact%4018.3.0", handler.LastRequest.RequestUri!.Query);
Assert.Contains("includeEnvironmentFlags=true", handler.LastRequest.RequestUri!.Query);
Assert.Contains("includeBlastRadius=true", handler.LastRequest.RequestUri!.Query);
}
[Fact]
public async Task GetContextAsync_ReturnsNullOnNotFound()
{
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound));
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://sbom.example/") };
var options = Options.Create(new SbomContextClientOptions());
var client = new SbomContextHttpClient(httpClient, options, NullLogger<SbomContextHttpClient>.Instance);
var result = await client.GetContextAsync(new SbomContextQuery("missing", null, 10, 5, false, false), CancellationToken.None);
Assert.Null(result);
}
[Fact]
public async Task GetContextAsync_ThrowsForServerError()
{
var handler = new StubHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)
{
Content = new StringContent("{\"error\":\"boom\"}", Encoding.UTF8, "application/json")
});
var httpClient = new HttpClient(handler) { BaseAddress = new Uri("https://sbom.example/") };
var options = Options.Create(new SbomContextClientOptions());
var client = new SbomContextHttpClient(httpClient, options, NullLogger<SbomContextHttpClient>.Instance);
await Assert.ThrowsAsync<HttpRequestException>(() => client.GetContextAsync(new SbomContextQuery("artifact", null, 5, 5, false, false), CancellationToken.None));
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> responder;
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responder)
{
this.responder = responder ?? throw new ArgumentNullException(nameof(responder));
}
public HttpRequestMessage? LastRequest { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
LastRequest = request;
return Task.FromResult(responder(request));
}
}
}

View File

@@ -10,30 +10,30 @@ using StellaOps.AdvisoryAI.Tools;
using StellaOps.AdvisoryAI.Abstractions;
using StellaOps.AdvisoryAI.Documents;
using Xunit;
namespace StellaOps.AdvisoryAI.Tests;
public sealed class ToolsetServiceCollectionExtensionsTests
{
[Fact]
public void AddAdvisoryDeterministicToolset_RegistersSingleton()
{
var services = new ServiceCollection();
services.AddAdvisoryDeterministicToolset();
var provider = services.BuildServiceProvider();
var toolsetA = provider.GetRequiredService<IDeterministicToolset>();
var toolsetB = provider.GetRequiredService<IDeterministicToolset>();
Assert.Same(toolsetA, toolsetB);
}
[Fact]
public void AddAdvisoryPipeline_RegistersOrchestrator()
{
var services = new ServiceCollection();
namespace StellaOps.AdvisoryAI.Tests;
public sealed class ToolsetServiceCollectionExtensionsTests
{
[Fact]
public void AddAdvisoryDeterministicToolset_RegistersSingleton()
{
var services = new ServiceCollection();
services.AddAdvisoryDeterministicToolset();
var provider = services.BuildServiceProvider();
var toolsetA = provider.GetRequiredService<IDeterministicToolset>();
var toolsetB = provider.GetRequiredService<IDeterministicToolset>();
Assert.Same(toolsetA, toolsetB);
}
[Fact]
public void AddAdvisoryPipeline_RegistersOrchestrator()
{
var services = new ServiceCollection();
services.AddSbomContext(options =>
{
options.BaseAddress = new Uri("https://sbom.example/");

View File

@@ -1,25 +1,25 @@
using System.Collections.Immutable;
namespace StellaOps.Aoc;
public static class AocForbiddenKeys
{
private static readonly ImmutableHashSet<string> ForbiddenTopLevel = new[]
{
"severity",
"cvss",
"cvss_vector",
"effective_status",
"effective_range",
"merged_from",
"consensus_provider",
"reachability",
"asset_criticality",
"risk_score",
}.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
public static bool IsForbiddenTopLevel(string propertyName) => ForbiddenTopLevel.Contains(propertyName);
public static bool IsDerivedField(string propertyName)
=> propertyName.StartsWith("effective_", StringComparison.OrdinalIgnoreCase);
}
using System.Collections.Immutable;
namespace StellaOps.Aoc;
public static class AocForbiddenKeys
{
private static readonly ImmutableHashSet<string> ForbiddenTopLevel = new[]
{
"severity",
"cvss",
"cvss_vector",
"effective_status",
"effective_range",
"merged_from",
"consensus_provider",
"reachability",
"asset_criticality",
"risk_score",
}.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase);
public static bool IsForbiddenTopLevel(string propertyName) => ForbiddenTopLevel.Contains(propertyName);
public static bool IsDerivedField(string propertyName)
=> propertyName.StartsWith("effective_", StringComparison.OrdinalIgnoreCase);
}

View File

@@ -1,17 +1,17 @@
using System;
using System.Collections.Immutable;
namespace StellaOps.Aoc;
public sealed class AocGuardException : Exception
{
public AocGuardException(AocGuardResult result)
: base("AOC guard validation failed.")
{
Result = result ?? throw new ArgumentNullException(nameof(result));
}
public AocGuardResult Result { get; }
public ImmutableArray<AocViolation> Violations => Result.Violations;
}
using System;
using System.Collections.Immutable;
namespace StellaOps.Aoc;
public sealed class AocGuardException : Exception
{
public AocGuardException(AocGuardResult result)
: base("AOC guard validation failed.")
{
Result = result ?? throw new ArgumentNullException(nameof(result));
}
public AocGuardResult Result { get; }
public ImmutableArray<AocViolation> Violations => Result.Violations;
}

View File

@@ -1,22 +1,22 @@
using System.Text.Json;
namespace StellaOps.Aoc;
public static class AocGuardExtensions
{
public static AocGuardResult ValidateOrThrow(this IAocGuard guard, JsonElement document, AocGuardOptions? options = null)
{
if (guard is null)
{
throw new ArgumentNullException(nameof(guard));
}
var result = guard.Validate(document, options);
if (!result.IsValid)
{
throw new AocGuardException(result);
}
return result;
}
}
using System.Text.Json;
namespace StellaOps.Aoc;
public static class AocGuardExtensions
{
public static AocGuardResult ValidateOrThrow(this IAocGuard guard, JsonElement document, AocGuardOptions? options = null)
{
if (guard is null)
{
throw new ArgumentNullException(nameof(guard));
}
var result = guard.Validate(document, options);
if (!result.IsValid)
{
throw new AocGuardException(result);
}
return result;
}
}

View File

@@ -1,8 +1,8 @@
using System.Collections.Immutable;
using System.Linq;
namespace StellaOps.Aoc;
namespace StellaOps.Aoc;
public sealed record AocGuardOptions
{
private static readonly ImmutableHashSet<string> DefaultRequiredTopLevel = new[]

View File

@@ -1,14 +1,14 @@
using System.Collections.Immutable;
namespace StellaOps.Aoc;
public sealed record AocGuardResult(bool IsValid, ImmutableArray<AocViolation> Violations)
{
public static AocGuardResult Success { get; } = new(true, ImmutableArray<AocViolation>.Empty);
public static AocGuardResult FromViolations(IEnumerable<AocViolation> violations)
{
var array = violations.ToImmutableArray();
return array.IsDefaultOrEmpty ? Success : new(false, array);
}
}
using System.Collections.Immutable;
namespace StellaOps.Aoc;
public sealed record AocGuardResult(bool IsValid, ImmutableArray<AocViolation> Violations)
{
public static AocGuardResult Success { get; } = new(true, ImmutableArray<AocViolation>.Empty);
public static AocGuardResult FromViolations(IEnumerable<AocViolation> violations)
{
var array = violations.ToImmutableArray();
return array.IsDefaultOrEmpty ? Success : new(false, array);
}
}

View File

@@ -1,13 +1,13 @@
using System.Text.Json.Serialization;
namespace StellaOps.Aoc;
public sealed record AocViolation(
[property: JsonPropertyName("code")] AocViolationCode Code,
[property: JsonPropertyName("errorCode")] string ErrorCode,
[property: JsonPropertyName("path")] string Path,
[property: JsonPropertyName("message")] string Message)
{
public static AocViolation Create(AocViolationCode code, string path, string message)
=> new(code, code.ToErrorCode(), path, message);
}
using System.Text.Json.Serialization;
namespace StellaOps.Aoc;
public sealed record AocViolation(
[property: JsonPropertyName("code")] AocViolationCode Code,
[property: JsonPropertyName("errorCode")] string ErrorCode,
[property: JsonPropertyName("path")] string Path,
[property: JsonPropertyName("message")] string Message)
{
public static AocViolation Create(AocViolationCode code, string path, string message)
=> new(code, code.ToErrorCode(), path, message);
}

View File

@@ -1,34 +1,34 @@
namespace StellaOps.Aoc;
public enum AocViolationCode
{
None = 0,
ForbiddenField,
MergeAttempt,
IdempotencyViolation,
MissingProvenance,
SignatureInvalid,
DerivedFindingDetected,
UnknownField,
MissingRequiredField,
InvalidTenant,
InvalidSignatureMetadata,
}
public static class AocViolationCodeExtensions
{
public static string ToErrorCode(this AocViolationCode code) => code switch
{
AocViolationCode.ForbiddenField => "ERR_AOC_001",
AocViolationCode.MergeAttempt => "ERR_AOC_002",
AocViolationCode.IdempotencyViolation => "ERR_AOC_003",
AocViolationCode.MissingProvenance => "ERR_AOC_004",
AocViolationCode.SignatureInvalid => "ERR_AOC_005",
AocViolationCode.DerivedFindingDetected => "ERR_AOC_006",
AocViolationCode.UnknownField => "ERR_AOC_007",
AocViolationCode.MissingRequiredField => "ERR_AOC_004",
AocViolationCode.InvalidTenant => "ERR_AOC_004",
AocViolationCode.InvalidSignatureMetadata => "ERR_AOC_005",
_ => "ERR_AOC_000",
};
}
namespace StellaOps.Aoc;
public enum AocViolationCode
{
None = 0,
ForbiddenField,
MergeAttempt,
IdempotencyViolation,
MissingProvenance,
SignatureInvalid,
DerivedFindingDetected,
UnknownField,
MissingRequiredField,
InvalidTenant,
InvalidSignatureMetadata,
}
public static class AocViolationCodeExtensions
{
public static string ToErrorCode(this AocViolationCode code) => code switch
{
AocViolationCode.ForbiddenField => "ERR_AOC_001",
AocViolationCode.MergeAttempt => "ERR_AOC_002",
AocViolationCode.IdempotencyViolation => "ERR_AOC_003",
AocViolationCode.MissingProvenance => "ERR_AOC_004",
AocViolationCode.SignatureInvalid => "ERR_AOC_005",
AocViolationCode.DerivedFindingDetected => "ERR_AOC_006",
AocViolationCode.UnknownField => "ERR_AOC_007",
AocViolationCode.MissingRequiredField => "ERR_AOC_004",
AocViolationCode.InvalidTenant => "ERR_AOC_004",
AocViolationCode.InvalidSignatureMetadata => "ERR_AOC_005",
_ => "ERR_AOC_000",
};
}

View File

@@ -1,16 +1,16 @@
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text.Json;
namespace StellaOps.Aoc;
public interface IAocGuard
{
AocGuardResult Validate(JsonElement document, AocGuardOptions? options = null);
}
public sealed class AocWriteGuard : IAocGuard
{
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Text.Json;
namespace StellaOps.Aoc;
public interface IAocGuard
{
AocGuardResult Validate(JsonElement document, AocGuardOptions? options = null);
}
public sealed class AocWriteGuard : IAocGuard
{
public AocGuardResult Validate(JsonElement document, AocGuardOptions? options = null)
{
options ??= AocGuardOptions.Default;
@@ -22,13 +22,13 @@ public sealed class AocWriteGuard : IAocGuard
{
presentTopLevel.Add(property.Name);
if (AocForbiddenKeys.IsForbiddenTopLevel(property.Name))
{
violations.Add(AocViolation.Create(AocViolationCode.ForbiddenField, $"/{property.Name}", $"Field '{property.Name}' is forbidden in AOC documents."));
continue;
}
if (AocForbiddenKeys.IsDerivedField(property.Name))
if (AocForbiddenKeys.IsForbiddenTopLevel(property.Name))
{
violations.Add(AocViolation.Create(AocViolationCode.ForbiddenField, $"/{property.Name}", $"Field '{property.Name}' is forbidden in AOC documents."));
continue;
}
if (AocForbiddenKeys.IsDerivedField(property.Name))
{
violations.Add(AocViolation.Create(AocViolationCode.DerivedFindingDetected, $"/{property.Name}", $"Derived field '{property.Name}' must not be written during ingestion."));
}
@@ -43,92 +43,92 @@ public sealed class AocWriteGuard : IAocGuard
foreach (var required in options.RequiredTopLevelFields)
{
if (!document.TryGetProperty(required, out var element) || element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, $"/{required}", $"Required field '{required}' is missing."));
continue;
}
if (options.RequireTenant && string.Equals(required, "tenant", StringComparison.OrdinalIgnoreCase))
{
if (element.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(element.GetString()))
{
violations.Add(AocViolation.Create(AocViolationCode.InvalidTenant, "/tenant", "Tenant must be a non-empty string."));
}
}
}
if (document.TryGetProperty("upstream", out var upstream) && upstream.ValueKind == JsonValueKind.Object)
{
if (!upstream.TryGetProperty("content_hash", out var contentHash) || contentHash.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(contentHash.GetString()))
{
violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/upstream/content_hash", "Upstream content hash is required."));
}
if (!upstream.TryGetProperty("signature", out var signature) || signature.ValueKind != JsonValueKind.Object)
{
if (options.RequireSignatureMetadata)
{
violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/upstream/signature", "Signature metadata is required."));
}
}
else if (options.RequireSignatureMetadata)
{
ValidateSignature(signature, violations);
}
}
else
{
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/upstream", "Upstream metadata is required."));
}
if (document.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Object)
{
if (!content.TryGetProperty("raw", out var raw) || raw.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/content/raw", "Raw upstream payload must be preserved."));
}
}
else
{
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/content", "Content metadata is required."));
}
if (!document.TryGetProperty("linkset", out var linkset) || linkset.ValueKind != JsonValueKind.Object)
{
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/linkset", "Linkset metadata is required."));
}
return AocGuardResult.FromViolations(violations);
}
private static void ValidateSignature(JsonElement signature, ImmutableArray<AocViolation>.Builder violations)
{
if (!signature.TryGetProperty("present", out var presentElement) || presentElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
{
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/present", "Signature metadata must include 'present' boolean."));
return;
}
var signaturePresent = presentElement.GetBoolean();
if (!signaturePresent)
{
return;
}
if (!signature.TryGetProperty("format", out var formatElement) || formatElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(formatElement.GetString()))
{
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/format", "Signature format is required when signature is present."));
}
if (!signature.TryGetProperty("sig", out var sigElement) || sigElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(sigElement.GetString()))
{
violations.Add(AocViolation.Create(AocViolationCode.SignatureInvalid, "/upstream/signature/sig", "Signature payload is required when signature is present."));
}
if (!signature.TryGetProperty("key_id", out var keyIdElement) || keyIdElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(keyIdElement.GetString()))
{
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/key_id", "Signature key identifier is required when signature is present."));
}
}
}
{
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, $"/{required}", $"Required field '{required}' is missing."));
continue;
}
if (options.RequireTenant && string.Equals(required, "tenant", StringComparison.OrdinalIgnoreCase))
{
if (element.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(element.GetString()))
{
violations.Add(AocViolation.Create(AocViolationCode.InvalidTenant, "/tenant", "Tenant must be a non-empty string."));
}
}
}
if (document.TryGetProperty("upstream", out var upstream) && upstream.ValueKind == JsonValueKind.Object)
{
if (!upstream.TryGetProperty("content_hash", out var contentHash) || contentHash.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(contentHash.GetString()))
{
violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/upstream/content_hash", "Upstream content hash is required."));
}
if (!upstream.TryGetProperty("signature", out var signature) || signature.ValueKind != JsonValueKind.Object)
{
if (options.RequireSignatureMetadata)
{
violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/upstream/signature", "Signature metadata is required."));
}
}
else if (options.RequireSignatureMetadata)
{
ValidateSignature(signature, violations);
}
}
else
{
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/upstream", "Upstream metadata is required."));
}
if (document.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Object)
{
if (!content.TryGetProperty("raw", out var raw) || raw.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
{
violations.Add(AocViolation.Create(AocViolationCode.MissingProvenance, "/content/raw", "Raw upstream payload must be preserved."));
}
}
else
{
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/content", "Content metadata is required."));
}
if (!document.TryGetProperty("linkset", out var linkset) || linkset.ValueKind != JsonValueKind.Object)
{
violations.Add(AocViolation.Create(AocViolationCode.MissingRequiredField, "/linkset", "Linkset metadata is required."));
}
return AocGuardResult.FromViolations(violations);
}
private static void ValidateSignature(JsonElement signature, ImmutableArray<AocViolation>.Builder violations)
{
if (!signature.TryGetProperty("present", out var presentElement) || presentElement.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
{
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/present", "Signature metadata must include 'present' boolean."));
return;
}
var signaturePresent = presentElement.GetBoolean();
if (!signaturePresent)
{
return;
}
if (!signature.TryGetProperty("format", out var formatElement) || formatElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(formatElement.GetString()))
{
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/format", "Signature format is required when signature is present."));
}
if (!signature.TryGetProperty("sig", out var sigElement) || sigElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(sigElement.GetString()))
{
violations.Add(AocViolation.Create(AocViolationCode.SignatureInvalid, "/upstream/signature/sig", "Signature payload is required when signature is present."));
}
if (!signature.TryGetProperty("key_id", out var keyIdElement) || keyIdElement.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(keyIdElement.GetString()))
{
violations.Add(AocViolation.Create(AocViolationCode.InvalidSignatureMetadata, "/upstream/signature/key_id", "Signature key identifier is required when signature is present."));
}
}
}

View File

@@ -1,17 +1,17 @@
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Aoc;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAocGuard(this IServiceCollection services)
{
if (services is null)
{
throw new ArgumentNullException(nameof(services));
}
services.AddSingleton<IAocGuard, AocWriteGuard>();
return services;
}
}
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Aoc;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAocGuard(this IServiceCollection services)
{
if (services is null)
{
throw new ArgumentNullException(nameof(services));
}
services.AddSingleton<IAocGuard, AocWriteGuard>();
return services;
}
}

View File

@@ -1,32 +1,32 @@
using System.Text.Json;
using StellaOps.Aoc;
namespace StellaOps.Aoc.Tests;
public sealed class AocWriteGuardTests
{
private static readonly AocWriteGuard Guard = new();
[Fact]
public void Validate_ReturnsSuccess_ForMinimalValidDocument()
{
using var document = JsonDocument.Parse("""
{
"tenant": "default",
"source": {"vendor": "osv"},
"upstream": {
"upstream_id": "GHSA-xxxx",
"content_hash": "sha256:abc",
"signature": { "present": false }
},
"content": {
"format": "OSV",
"raw": {"id": "GHSA-xxxx"}
},
"linkset": {}
}
""");
using System.Text.Json;
using StellaOps.Aoc;
namespace StellaOps.Aoc.Tests;
public sealed class AocWriteGuardTests
{
private static readonly AocWriteGuard Guard = new();
[Fact]
public void Validate_ReturnsSuccess_ForMinimalValidDocument()
{
using var document = JsonDocument.Parse("""
{
"tenant": "default",
"source": {"vendor": "osv"},
"upstream": {
"upstream_id": "GHSA-xxxx",
"content_hash": "sha256:abc",
"signature": { "present": false }
},
"content": {
"format": "OSV",
"raw": {"id": "GHSA-xxxx"}
},
"linkset": {}
}
""");
var result = Guard.Validate(document.RootElement);
Assert.True(result.IsValid);
@@ -63,32 +63,32 @@ public sealed class AocWriteGuardTests
Assert.Empty(result.Violations);
}
[Fact]
public void Validate_FlagsMissingTenant()
{
using var document = JsonDocument.Parse("""
{
"source": {"vendor": "osv"},
"upstream": {
"upstream_id": "GHSA-xxxx",
"content_hash": "sha256:abc",
"signature": { "present": false }
},
"content": {
"format": "OSV",
"raw": {"id": "GHSA-xxxx"}
},
"linkset": {}
}
""");
var result = Guard.Validate(document.RootElement);
Assert.False(result.IsValid);
Assert.Contains(result.Violations, v => v.ErrorCode == "ERR_AOC_004" && v.Path == "/tenant");
}
[Fact]
[Fact]
public void Validate_FlagsMissingTenant()
{
using var document = JsonDocument.Parse("""
{
"source": {"vendor": "osv"},
"upstream": {
"upstream_id": "GHSA-xxxx",
"content_hash": "sha256:abc",
"signature": { "present": false }
},
"content": {
"format": "OSV",
"raw": {"id": "GHSA-xxxx"}
},
"linkset": {}
}
""");
var result = Guard.Validate(document.RootElement);
Assert.False(result.IsValid);
Assert.Contains(result.Violations, v => v.ErrorCode == "ERR_AOC_004" && v.Path == "/tenant");
}
[Fact]
public void Validate_FlagsForbiddenField()
{
using var document = JsonDocument.Parse("""
@@ -100,18 +100,18 @@ public sealed class AocWriteGuardTests
"upstream": {
"upstream_id": "GHSA-xxxx",
"content_hash": "sha256:abc",
"signature": { "present": false }
},
"content": {
"format": "OSV",
"raw": {"id": "GHSA-xxxx"}
},
"linkset": {}
}
""");
var result = Guard.Validate(document.RootElement);
"signature": { "present": false }
},
"content": {
"format": "OSV",
"raw": {"id": "GHSA-xxxx"}
},
"linkset": {}
}
""");
var result = Guard.Validate(document.RootElement);
Assert.False(result.IsValid);
Assert.Contains(result.Violations, v => v.ErrorCode == "ERR_AOC_001" && v.Path == "/severity");
}
@@ -180,23 +180,23 @@ public sealed class AocWriteGuardTests
using var document = JsonDocument.Parse("""
{
"tenant": "default",
"source": {"vendor": "osv"},
"upstream": {
"upstream_id": "GHSA-xxxx",
"content_hash": "sha256:abc",
"signature": { "present": true, "format": "dsse" }
},
"content": {
"format": "OSV",
"raw": {"id": "GHSA-xxxx"}
},
"linkset": {}
}
""");
var result = Guard.Validate(document.RootElement);
Assert.False(result.IsValid);
Assert.Contains(result.Violations, v => v.ErrorCode == "ERR_AOC_005" && v.Path.Contains("/sig"));
}
}
"source": {"vendor": "osv"},
"upstream": {
"upstream_id": "GHSA-xxxx",
"content_hash": "sha256:abc",
"signature": { "present": true, "format": "dsse" }
},
"content": {
"format": "OSV",
"raw": {"id": "GHSA-xxxx"}
},
"linkset": {}
}
""");
var result = Guard.Validate(document.RootElement);
Assert.False(result.IsValid);
Assert.Contains(result.Violations, v => v.ErrorCode == "ERR_AOC_005" && v.Path.Contains("/sig"));
}
}

View File

@@ -1,10 +1,10 @@
namespace StellaOps.Aoc.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}
namespace StellaOps.Aoc.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -1,42 +1,42 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Attestor.Core.Audit;
public sealed class AttestorAuditRecord
{
public string Action { get; init; } = string.Empty;
public string Result { get; init; } = string.Empty;
public string? RekorUuid { get; init; }
public long? Index { get; init; }
public string ArtifactSha256 { get; init; } = string.Empty;
public string BundleSha256 { get; init; } = string.Empty;
public string Backend { get; init; } = string.Empty;
public long LatencyMs { get; init; }
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
public CallerDescriptor Caller { get; init; } = new();
public IDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
public sealed class CallerDescriptor
{
public string? Subject { get; init; }
public string? Audience { get; init; }
public string? ClientId { get; init; }
public string? MtlsThumbprint { get; init; }
public string? Tenant { get; init; }
}
}
using System;
using System.Collections.Generic;
namespace StellaOps.Attestor.Core.Audit;
public sealed class AttestorAuditRecord
{
public string Action { get; init; } = string.Empty;
public string Result { get; init; } = string.Empty;
public string? RekorUuid { get; init; }
public long? Index { get; init; }
public string ArtifactSha256 { get; init; } = string.Empty;
public string BundleSha256 { get; init; } = string.Empty;
public string Backend { get; init; } = string.Empty;
public long LatencyMs { get; init; }
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
public CallerDescriptor Caller { get; init; } = new();
public IDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
public sealed class CallerDescriptor
{
public string? Subject { get; init; }
public string? Audience { get; init; }
public string? ClientId { get; init; }
public string? MtlsThumbprint { get; init; }
public string? Tenant { get; init; }
}
}

View File

@@ -1,18 +1,18 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestor.Core.Submission;
namespace StellaOps.Attestor.Core.Rekor;
public interface IRekorClient
{
Task<RekorSubmissionResponse> SubmitAsync(
AttestorSubmissionRequest request,
RekorBackend backend,
CancellationToken cancellationToken = default);
Task<RekorProofResponse?> GetProofAsync(
string rekorUuid,
RekorBackend backend,
CancellationToken cancellationToken = default);
}
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestor.Core.Submission;
namespace StellaOps.Attestor.Core.Rekor;
public interface IRekorClient
{
Task<RekorSubmissionResponse> SubmitAsync(
AttestorSubmissionRequest request,
RekorBackend backend,
CancellationToken cancellationToken = default);
Task<RekorProofResponse?> GetProofAsync(
string rekorUuid,
RekorBackend backend,
CancellationToken cancellationToken = default);
}

View File

@@ -1,16 +1,16 @@
using System;
namespace StellaOps.Attestor.Core.Rekor;
public sealed class RekorBackend
{
public required string Name { get; init; }
public required Uri Url { get; init; }
public TimeSpan ProofTimeout { get; init; } = TimeSpan.FromSeconds(15);
public TimeSpan PollInterval { get; init; } = TimeSpan.FromMilliseconds(250);
public int MaxAttempts { get; init; } = 60;
}
using System;
namespace StellaOps.Attestor.Core.Rekor;
public sealed class RekorBackend
{
public required string Name { get; init; }
public required Uri Url { get; init; }
public TimeSpan ProofTimeout { get; init; } = TimeSpan.FromSeconds(15);
public TimeSpan PollInterval { get; init; } = TimeSpan.FromMilliseconds(250);
public int MaxAttempts { get; init; } = 60;
}

View File

@@ -1,38 +1,38 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Core.Rekor;
public sealed class RekorProofResponse
{
[JsonPropertyName("checkpoint")]
public RekorCheckpoint? Checkpoint { get; set; }
[JsonPropertyName("inclusion")]
public RekorInclusionProof? Inclusion { get; set; }
public sealed class RekorCheckpoint
{
[JsonPropertyName("origin")]
public string? Origin { get; set; }
[JsonPropertyName("size")]
public long Size { get; set; }
[JsonPropertyName("rootHash")]
public string? RootHash { get; set; }
[JsonPropertyName("timestamp")]
public DateTimeOffset? Timestamp { get; set; }
}
public sealed class RekorInclusionProof
{
[JsonPropertyName("leafHash")]
public string? LeafHash { get; set; }
[JsonPropertyName("path")]
public IReadOnlyList<string> Path { get; set; } = Array.Empty<string>();
}
}
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Core.Rekor;
public sealed class RekorProofResponse
{
[JsonPropertyName("checkpoint")]
public RekorCheckpoint? Checkpoint { get; set; }
[JsonPropertyName("inclusion")]
public RekorInclusionProof? Inclusion { get; set; }
public sealed class RekorCheckpoint
{
[JsonPropertyName("origin")]
public string? Origin { get; set; }
[JsonPropertyName("size")]
public long Size { get; set; }
[JsonPropertyName("rootHash")]
public string? RootHash { get; set; }
[JsonPropertyName("timestamp")]
public DateTimeOffset? Timestamp { get; set; }
}
public sealed class RekorInclusionProof
{
[JsonPropertyName("leafHash")]
public string? LeafHash { get; set; }
[JsonPropertyName("path")]
public IReadOnlyList<string> Path { get; set; } = Array.Empty<string>();
}
}

View File

@@ -1,21 +1,21 @@
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Core.Rekor;
public sealed class RekorSubmissionResponse
{
[JsonPropertyName("uuid")]
public string Uuid { get; set; } = string.Empty;
[JsonPropertyName("index")]
public long? Index { get; set; }
[JsonPropertyName("logURL")]
public string? LogUrl { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; } = "included";
[JsonPropertyName("proof")]
public RekorProofResponse? Proof { get; set; }
}
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Core.Rekor;
public sealed class RekorSubmissionResponse
{
[JsonPropertyName("uuid")]
public string Uuid { get; set; } = string.Empty;
[JsonPropertyName("index")]
public long? Index { get; set; }
[JsonPropertyName("logURL")]
public string? LogUrl { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; } = "included";
[JsonPropertyName("proof")]
public RekorProofResponse? Proof { get; set; }
}

View File

@@ -1,19 +1,19 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Attestor.Core.Storage;
public sealed class AttestorArchiveBundle
{
public string RekorUuid { get; init; } = string.Empty;
public string ArtifactSha256 { get; init; } = string.Empty;
public string BundleSha256 { get; init; } = string.Empty;
public byte[] CanonicalBundleJson { get; init; } = Array.Empty<byte>();
public byte[] ProofJson { get; init; } = Array.Empty<byte>();
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
}
using System;
using System.Collections.Generic;
namespace StellaOps.Attestor.Core.Storage;
public sealed class AttestorArchiveBundle
{
public string RekorUuid { get; init; } = string.Empty;
public string ArtifactSha256 { get; init; } = string.Empty;
public string BundleSha256 { get; init; } = string.Empty;
public byte[] CanonicalBundleJson { get; init; } = Array.Empty<byte>();
public byte[] ProofJson { get; init; } = Array.Empty<byte>();
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
}

View File

@@ -1,8 +1,8 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Core.Storage;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Core.Storage;
public interface IAttestorArchiveStore
{
Task ArchiveBundleAsync(AttestorArchiveBundle bundle, CancellationToken cancellationToken = default);

View File

@@ -1,10 +1,10 @@
using StellaOps.Attestor.Core.Audit;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Core.Storage;
public interface IAttestorAuditSink
{
Task WriteAsync(AttestorAuditRecord record, CancellationToken cancellationToken = default);
}
using StellaOps.Attestor.Core.Audit;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Core.Storage;
public interface IAttestorAuditSink
{
Task WriteAsync(AttestorAuditRecord record, CancellationToken cancellationToken = default);
}

View File

@@ -1,12 +1,12 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Core.Storage;
public interface IAttestorDedupeStore
{
Task<string?> TryGetExistingAsync(string bundleSha256, CancellationToken cancellationToken = default);
Task SetAsync(string bundleSha256, string rekorUuid, TimeSpan ttl, CancellationToken cancellationToken = default);
}
using System;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Core.Storage;
public interface IAttestorDedupeStore
{
Task<string?> TryGetExistingAsync(string bundleSha256, CancellationToken cancellationToken = default);
Task SetAsync(string bundleSha256, string rekorUuid, TimeSpan ttl, CancellationToken cancellationToken = default);
}

View File

@@ -1,13 +1,13 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Core.Storage;
public interface IAttestorEntryRepository
{
Task<AttestorEntry?> GetByBundleShaAsync(string bundleSha256, CancellationToken cancellationToken = default);
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Core.Storage;
public interface IAttestorEntryRepository
{
Task<AttestorEntry?> GetByBundleShaAsync(string bundleSha256, CancellationToken cancellationToken = default);
Task<AttestorEntry?> GetByUuidAsync(string rekorUuid, CancellationToken cancellationToken = default);
Task<IReadOnlyList<AttestorEntry>> GetByArtifactShaAsync(string artifactSha256, CancellationToken cancellationToken = default);

View File

@@ -1,79 +1,79 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Core.Submission;
/// <summary>
/// Incoming submission payload for <c>/api/v1/rekor/entries</c>.
/// </summary>
public sealed class AttestorSubmissionRequest
{
[JsonPropertyName("bundle")]
public SubmissionBundle Bundle { get; set; } = new();
[JsonPropertyName("meta")]
public SubmissionMeta Meta { get; set; } = new();
public sealed class SubmissionBundle
{
[JsonPropertyName("dsse")]
public DsseEnvelope Dsse { get; set; } = new();
[JsonPropertyName("certificateChain")]
public IList<string> CertificateChain { get; set; } = new List<string>();
[JsonPropertyName("mode")]
public string Mode { get; set; } = "keyless";
}
public sealed class DsseEnvelope
{
[JsonPropertyName("payloadType")]
public string PayloadType { get; set; } = string.Empty;
[JsonPropertyName("payload")]
public string PayloadBase64 { get; set; } = string.Empty;
[JsonPropertyName("signatures")]
public IList<DsseSignature> Signatures { get; set; } = new List<DsseSignature>();
}
public sealed class DsseSignature
{
[JsonPropertyName("keyid")]
public string? KeyId { get; set; }
[JsonPropertyName("sig")]
public string Signature { get; set; } = string.Empty;
}
public sealed class SubmissionMeta
{
[JsonPropertyName("artifact")]
public ArtifactInfo Artifact { get; set; } = new();
[JsonPropertyName("bundleSha256")]
public string BundleSha256 { get; set; } = string.Empty;
[JsonPropertyName("logPreference")]
public string LogPreference { get; set; } = "primary";
[JsonPropertyName("archive")]
public bool Archive { get; set; } = true;
}
public sealed class ArtifactInfo
{
[JsonPropertyName("sha256")]
public string Sha256 { get; set; } = string.Empty;
[JsonPropertyName("kind")]
public string Kind { get; set; } = string.Empty;
[JsonPropertyName("imageDigest")]
public string? ImageDigest { get; set; }
[JsonPropertyName("subjectUri")]
public string? SubjectUri { get; set; }
}
}
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace StellaOps.Attestor.Core.Submission;
/// <summary>
/// Incoming submission payload for <c>/api/v1/rekor/entries</c>.
/// </summary>
public sealed class AttestorSubmissionRequest
{
[JsonPropertyName("bundle")]
public SubmissionBundle Bundle { get; set; } = new();
[JsonPropertyName("meta")]
public SubmissionMeta Meta { get; set; } = new();
public sealed class SubmissionBundle
{
[JsonPropertyName("dsse")]
public DsseEnvelope Dsse { get; set; } = new();
[JsonPropertyName("certificateChain")]
public IList<string> CertificateChain { get; set; } = new List<string>();
[JsonPropertyName("mode")]
public string Mode { get; set; } = "keyless";
}
public sealed class DsseEnvelope
{
[JsonPropertyName("payloadType")]
public string PayloadType { get; set; } = string.Empty;
[JsonPropertyName("payload")]
public string PayloadBase64 { get; set; } = string.Empty;
[JsonPropertyName("signatures")]
public IList<DsseSignature> Signatures { get; set; } = new List<DsseSignature>();
}
public sealed class DsseSignature
{
[JsonPropertyName("keyid")]
public string? KeyId { get; set; }
[JsonPropertyName("sig")]
public string Signature { get; set; } = string.Empty;
}
public sealed class SubmissionMeta
{
[JsonPropertyName("artifact")]
public ArtifactInfo Artifact { get; set; } = new();
[JsonPropertyName("bundleSha256")]
public string BundleSha256 { get; set; } = string.Empty;
[JsonPropertyName("logPreference")]
public string LogPreference { get; set; } = "primary";
[JsonPropertyName("archive")]
public bool Archive { get; set; } = true;
}
public sealed class ArtifactInfo
{
[JsonPropertyName("sha256")]
public string Sha256 { get; set; } = string.Empty;
[JsonPropertyName("kind")]
public string Kind { get; set; } = string.Empty;
[JsonPropertyName("imageDigest")]
public string? ImageDigest { get; set; }
[JsonPropertyName("subjectUri")]
public string? SubjectUri { get; set; }
}
}

View File

@@ -1,11 +1,11 @@
namespace StellaOps.Attestor.Core.Submission;
public sealed class AttestorSubmissionValidationResult
{
public AttestorSubmissionValidationResult(byte[] canonicalBundle)
{
CanonicalBundle = canonicalBundle;
}
public byte[] CanonicalBundle { get; }
}
namespace StellaOps.Attestor.Core.Submission;
public sealed class AttestorSubmissionValidationResult
{
public AttestorSubmissionValidationResult(byte[] canonicalBundle)
{
CanonicalBundle = canonicalBundle;
}
public byte[] CanonicalBundle { get; }
}

View File

@@ -1,17 +1,17 @@
using System;
using System.Buffers.Text;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Core.Submission;
public sealed class AttestorSubmissionValidator
{
private static readonly string[] AllowedKinds = ["sbom", "report", "vex-export"];
private readonly IDsseCanonicalizer _canonicalizer;
using System;
using System.Buffers.Text;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Core.Submission;
public sealed class AttestorSubmissionValidator
{
private static readonly string[] AllowedKinds = ["sbom", "report", "vex-export"];
private readonly IDsseCanonicalizer _canonicalizer;
private readonly HashSet<string> _allowedModes;
private readonly AttestorSubmissionConstraints _constraints;
@@ -30,23 +30,23 @@ public sealed class AttestorSubmissionValidator
public async Task<AttestorSubmissionValidationResult> ValidateAsync(AttestorSubmissionRequest request, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
if (request.Bundle is null)
{
throw new AttestorValidationException("bundle_missing", "Submission bundle payload is required.");
}
if (request.Bundle.Dsse is null)
{
throw new AttestorValidationException("dsse_missing", "DSSE envelope is required.");
}
if (string.IsNullOrWhiteSpace(request.Bundle.Dsse.PayloadType))
{
throw new AttestorValidationException("payload_type_missing", "DSSE payloadType is required.");
}
if (string.IsNullOrWhiteSpace(request.Bundle.Dsse.PayloadBase64))
if (request.Bundle is null)
{
throw new AttestorValidationException("bundle_missing", "Submission bundle payload is required.");
}
if (request.Bundle.Dsse is null)
{
throw new AttestorValidationException("dsse_missing", "DSSE envelope is required.");
}
if (string.IsNullOrWhiteSpace(request.Bundle.Dsse.PayloadType))
{
throw new AttestorValidationException("payload_type_missing", "DSSE payloadType is required.");
}
if (string.IsNullOrWhiteSpace(request.Bundle.Dsse.PayloadBase64))
{
throw new AttestorValidationException("payload_missing", "DSSE payload must be provided.");
}
@@ -66,36 +66,36 @@ public sealed class AttestorSubmissionValidator
throw new AttestorValidationException("mode_not_allowed", $"Submission mode '{request.Bundle.Mode}' is not permitted.");
}
if (request.Meta is null)
{
throw new AttestorValidationException("meta_missing", "Submission metadata is required.");
}
if (request.Meta.Artifact is null)
{
throw new AttestorValidationException("artifact_missing", "Artifact metadata is required.");
}
if (string.IsNullOrWhiteSpace(request.Meta.Artifact.Sha256))
{
throw new AttestorValidationException("artifact_sha_missing", "Artifact sha256 is required.");
}
if (!IsHex(request.Meta.Artifact.Sha256, expectedLength: 64))
{
throw new AttestorValidationException("artifact_sha_invalid", "Artifact sha256 must be a 64-character hex string.");
}
if (string.IsNullOrWhiteSpace(request.Meta.BundleSha256))
{
throw new AttestorValidationException("bundle_sha_missing", "bundleSha256 is required.");
}
if (!IsHex(request.Meta.BundleSha256, expectedLength: 64))
{
throw new AttestorValidationException("bundle_sha_invalid", "bundleSha256 must be a 64-character hex string.");
}
if (request.Meta is null)
{
throw new AttestorValidationException("meta_missing", "Submission metadata is required.");
}
if (request.Meta.Artifact is null)
{
throw new AttestorValidationException("artifact_missing", "Artifact metadata is required.");
}
if (string.IsNullOrWhiteSpace(request.Meta.Artifact.Sha256))
{
throw new AttestorValidationException("artifact_sha_missing", "Artifact sha256 is required.");
}
if (!IsHex(request.Meta.Artifact.Sha256, expectedLength: 64))
{
throw new AttestorValidationException("artifact_sha_invalid", "Artifact sha256 must be a 64-character hex string.");
}
if (string.IsNullOrWhiteSpace(request.Meta.BundleSha256))
{
throw new AttestorValidationException("bundle_sha_missing", "bundleSha256 is required.");
}
if (!IsHex(request.Meta.BundleSha256, expectedLength: 64))
{
throw new AttestorValidationException("bundle_sha_invalid", "bundleSha256 must be a 64-character hex string.");
}
if (Array.IndexOf(AllowedKinds, request.Meta.Artifact.Kind) < 0)
{
throw new AttestorValidationException("artifact_kind_invalid", $"Artifact kind '{request.Meta.Artifact.Kind}' is not supported.");
@@ -121,77 +121,77 @@ public sealed class AttestorSubmissionValidator
if (!SHA256.TryHashData(canonical, hash, out _))
{
throw new AttestorValidationException("bundle_sha_failure", "Failed to compute canonical bundle hash.");
}
var hashHex = Convert.ToHexString(hash).ToLowerInvariant();
if (!string.Equals(hashHex, request.Meta.BundleSha256, StringComparison.OrdinalIgnoreCase))
{
throw new AttestorValidationException("bundle_sha_mismatch", "bundleSha256 does not match canonical DSSE hash.");
}
if (!string.Equals(request.Meta.LogPreference, "primary", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(request.Meta.LogPreference, "mirror", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(request.Meta.LogPreference, "both", StringComparison.OrdinalIgnoreCase))
{
throw new AttestorValidationException("log_preference_invalid", "logPreference must be 'primary', 'mirror', or 'both'.");
}
return new AttestorSubmissionValidationResult(canonical);
}
private static bool IsHex(string value, int expectedLength)
{
if (value.Length != expectedLength)
{
return false;
}
foreach (var ch in value)
{
var isHex = ch is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F';
if (!isHex)
{
return false;
}
}
return true;
}
private static bool Base64UrlDecode(string value, out byte[] bytes)
{
try
{
bytes = Convert.FromBase64String(Normalise(value));
return true;
}
catch (FormatException)
{
bytes = Array.Empty<byte>();
return false;
}
}
private static string Normalise(string value)
{
if (value.Contains('-') || value.Contains('_'))
{
Span<char> buffer = value.ToCharArray();
for (var i = 0; i < buffer.Length; i++)
{
buffer[i] = buffer[i] switch
{
'-' => '+',
'_' => '/',
_ => buffer[i]
};
}
var padding = 4 - (buffer.Length % 4);
return padding == 4 ? new string(buffer) : new string(buffer) + new string('=', padding);
}
return value;
}
var hashHex = Convert.ToHexString(hash).ToLowerInvariant();
if (!string.Equals(hashHex, request.Meta.BundleSha256, StringComparison.OrdinalIgnoreCase))
{
throw new AttestorValidationException("bundle_sha_mismatch", "bundleSha256 does not match canonical DSSE hash.");
}
if (!string.Equals(request.Meta.LogPreference, "primary", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(request.Meta.LogPreference, "mirror", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(request.Meta.LogPreference, "both", StringComparison.OrdinalIgnoreCase))
{
throw new AttestorValidationException("log_preference_invalid", "logPreference must be 'primary', 'mirror', or 'both'.");
}
return new AttestorSubmissionValidationResult(canonical);
}
private static bool IsHex(string value, int expectedLength)
{
if (value.Length != expectedLength)
{
return false;
}
foreach (var ch in value)
{
var isHex = ch is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F';
if (!isHex)
{
return false;
}
}
return true;
}
private static bool Base64UrlDecode(string value, out byte[] bytes)
{
try
{
bytes = Convert.FromBase64String(Normalise(value));
return true;
}
catch (FormatException)
{
bytes = Array.Empty<byte>();
return false;
}
}
private static string Normalise(string value)
{
if (value.Contains('-') || value.Contains('_'))
{
Span<char> buffer = value.ToCharArray();
for (var i = 0; i < buffer.Length; i++)
{
buffer[i] = buffer[i] switch
{
'-' => '+',
'_' => '/',
_ => buffer[i]
};
}
var padding = 4 - (buffer.Length % 4);
return padding == 4 ? new string(buffer) : new string(buffer) + new string('=', padding);
}
return value;
}
}

View File

@@ -1,14 +1,14 @@
using System;
namespace StellaOps.Attestor.Core.Submission;
public sealed class AttestorValidationException : Exception
{
public AttestorValidationException(string code, string message)
: base(message)
{
Code = code;
}
public string Code { get; }
}
using System;
namespace StellaOps.Attestor.Core.Submission;
public sealed class AttestorValidationException : Exception
{
public AttestorValidationException(string code, string message)
: base(message)
{
Code = code;
}
public string Code { get; }
}

View File

@@ -1,12 +1,12 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Core.Submission;
public interface IAttestorSubmissionService
{
Task<AttestorSubmissionResult> SubmitAsync(
AttestorSubmissionRequest request,
SubmissionContext context,
CancellationToken cancellationToken = default);
}
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Core.Submission;
public interface IAttestorSubmissionService
{
Task<AttestorSubmissionResult> SubmitAsync(
AttestorSubmissionRequest request,
SubmissionContext context,
CancellationToken cancellationToken = default);
}

View File

@@ -1,9 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Core.Submission;
public interface IDsseCanonicalizer
{
Task<byte[]> CanonicalizeAsync(AttestorSubmissionRequest request, CancellationToken cancellationToken = default);
}
using System.Threading;
using System.Threading.Tasks;
namespace StellaOps.Attestor.Core.Submission;
public interface IDsseCanonicalizer
{
Task<byte[]> CanonicalizeAsync(AttestorSubmissionRequest request, CancellationToken cancellationToken = default);
}

View File

@@ -1,21 +1,21 @@
using System.Security.Cryptography.X509Certificates;
namespace StellaOps.Attestor.Core.Submission;
/// <summary>
/// Ambient information about the caller used for policy and audit decisions.
/// </summary>
public sealed class SubmissionContext
{
public required string CallerSubject { get; init; }
public required string CallerAudience { get; init; }
public required string? CallerClientId { get; init; }
public required string? CallerTenant { get; init; }
public X509Certificate2? ClientCertificate { get; init; }
public string? MtlsThumbprint { get; init; }
}
using System.Security.Cryptography.X509Certificates;
namespace StellaOps.Attestor.Core.Submission;
/// <summary>
/// Ambient information about the caller used for policy and audit decisions.
/// </summary>
public sealed class SubmissionContext
{
public required string CallerSubject { get; init; }
public required string CallerAudience { get; init; }
public required string? CallerClientId { get; init; }
public required string? CallerTenant { get; init; }
public X509Certificate2? ClientCertificate { get; init; }
public string? MtlsThumbprint { get; init; }
}

View File

@@ -1,14 +1,14 @@
using System;
namespace StellaOps.Attestor.Core.Verification;
public sealed class AttestorVerificationException : Exception
{
public AttestorVerificationException(string code, string message)
: base(message)
{
Code = code;
}
public string Code { get; }
}
using System;
namespace StellaOps.Attestor.Core.Verification;
public sealed class AttestorVerificationException : Exception
{
public AttestorVerificationException(string code, string message)
: base(message)
{
Code = code;
}
public string Code { get; }
}

View File

@@ -1,10 +1,10 @@
namespace StellaOps.Attestor.Core.Verification;
/// <summary>
/// Payload accepted by the verification service.
/// </summary>
public sealed class AttestorVerificationRequest
{
namespace StellaOps.Attestor.Core.Verification;
/// <summary>
/// Payload accepted by the verification service.
/// </summary>
public sealed class AttestorVerificationRequest
{
public string? Uuid { get; set; }
public Submission.AttestorSubmissionRequest.SubmissionBundle? Bundle { get; set; }

View File

@@ -1,16 +1,16 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Attestor.Core.Verification;
public sealed class AttestorVerificationResult
{
public bool Ok { get; init; }
public string? Uuid { get; init; }
public long? Index { get; init; }
using System;
using System.Collections.Generic;
namespace StellaOps.Attestor.Core.Verification;
public sealed class AttestorVerificationResult
{
public bool Ok { get; init; }
public string? Uuid { get; init; }
public long? Index { get; init; }
public string? LogUrl { get; init; }
public DateTimeOffset CheckedAt { get; init; } = DateTimeOffset.UtcNow;

View File

@@ -1,12 +1,12 @@
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Core.Verification;
public interface IAttestorVerificationService
{
Task<AttestorVerificationResult> VerifyAsync(AttestorVerificationRequest request, CancellationToken cancellationToken = default);
Task<AttestorEntry?> GetEntryAsync(string rekorUuid, bool refreshProof, CancellationToken cancellationToken = default);
}
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Core.Verification;
public interface IAttestorVerificationService
{
Task<AttestorVerificationResult> VerifyAsync(AttestorVerificationRequest request, CancellationToken cancellationToken = default);
Task<AttestorEntry?> GetEntryAsync(string rekorUuid, bool refreshProof, CancellationToken cancellationToken = default);
}

View File

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

View File

@@ -1,157 +1,157 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Submission;
namespace StellaOps.Attestor.Infrastructure.Rekor;
internal sealed class HttpRekorClient : IRekorClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly HttpClient _httpClient;
private readonly ILogger<HttpRekorClient> _logger;
public HttpRekorClient(HttpClient httpClient, ILogger<HttpRekorClient> logger)
{
_httpClient = httpClient;
_logger = logger;
}
public async Task<RekorSubmissionResponse> SubmitAsync(AttestorSubmissionRequest request, RekorBackend backend, CancellationToken cancellationToken = default)
{
var submissionUri = BuildUri(backend.Url, "api/v2/log/entries");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, submissionUri)
{
Content = JsonContent.Create(BuildSubmissionPayload(request), options: SerializerOptions)
};
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Conflict)
{
var message = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Rekor reported a conflict: {message}");
}
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var root = document.RootElement;
long? index = null;
if (root.TryGetProperty("index", out var indexElement) && indexElement.TryGetInt64(out var indexValue))
{
index = indexValue;
}
return new RekorSubmissionResponse
{
Uuid = root.TryGetProperty("uuid", out var uuidElement) ? uuidElement.GetString() ?? string.Empty : string.Empty,
Index = index,
LogUrl = root.TryGetProperty("logURL", out var urlElement) ? urlElement.GetString() ?? backend.Url.ToString() : backend.Url.ToString(),
Status = root.TryGetProperty("status", out var statusElement) ? statusElement.GetString() ?? "included" : "included",
Proof = TryParseProof(root.TryGetProperty("proof", out var proofElement) ? proofElement : default)
};
}
public async Task<RekorProofResponse?> GetProofAsync(string rekorUuid, RekorBackend backend, CancellationToken cancellationToken = default)
{
var proofUri = BuildUri(backend.Url, $"api/v2/log/entries/{rekorUuid}/proof");
using var request = new HttpRequestMessage(HttpMethod.Get, proofUri);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
_logger.LogDebug("Rekor proof for {Uuid} not found", rekorUuid);
return null;
}
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
return TryParseProof(document.RootElement);
}
private static object BuildSubmissionPayload(AttestorSubmissionRequest request)
{
var signatures = new List<object>();
foreach (var sig in request.Bundle.Dsse.Signatures)
{
signatures.Add(new { keyid = sig.KeyId, sig = sig.Signature });
}
return new
{
entries = new[]
{
new
{
dsseEnvelope = new
{
payload = request.Bundle.Dsse.PayloadBase64,
payloadType = request.Bundle.Dsse.PayloadType,
signatures
}
}
}
};
}
private static RekorProofResponse? TryParseProof(JsonElement proofElement)
{
if (proofElement.ValueKind == JsonValueKind.Undefined || proofElement.ValueKind == JsonValueKind.Null)
{
return null;
}
var checkpointElement = proofElement.TryGetProperty("checkpoint", out var cp) ? cp : default;
var inclusionElement = proofElement.TryGetProperty("inclusion", out var inc) ? inc : default;
return new RekorProofResponse
{
Checkpoint = checkpointElement.ValueKind == JsonValueKind.Object
? new RekorProofResponse.RekorCheckpoint
{
Origin = checkpointElement.TryGetProperty("origin", out var origin) ? origin.GetString() : null,
Size = checkpointElement.TryGetProperty("size", out var size) && size.TryGetInt64(out var sizeValue) ? sizeValue : 0,
RootHash = checkpointElement.TryGetProperty("rootHash", out var rootHash) ? rootHash.GetString() : null,
Timestamp = checkpointElement.TryGetProperty("timestamp", out var ts) && ts.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(ts.GetString(), out var dto) ? dto : null
}
: null,
Inclusion = inclusionElement.ValueKind == JsonValueKind.Object
? new RekorProofResponse.RekorInclusionProof
{
LeafHash = inclusionElement.TryGetProperty("leafHash", out var leaf) ? leaf.GetString() : null,
Path = inclusionElement.TryGetProperty("path", out var pathElement) && pathElement.ValueKind == JsonValueKind.Array
? pathElement.EnumerateArray().Select(p => p.GetString() ?? string.Empty).ToArray()
: Array.Empty<string>()
}
: null
};
}
private static Uri BuildUri(Uri baseUri, string relative)
{
if (!relative.StartsWith("/", StringComparison.Ordinal))
{
relative = "/" + relative;
}
return new Uri(baseUri, relative);
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Submission;
namespace StellaOps.Attestor.Infrastructure.Rekor;
internal sealed class HttpRekorClient : IRekorClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private readonly HttpClient _httpClient;
private readonly ILogger<HttpRekorClient> _logger;
public HttpRekorClient(HttpClient httpClient, ILogger<HttpRekorClient> logger)
{
_httpClient = httpClient;
_logger = logger;
}
public async Task<RekorSubmissionResponse> SubmitAsync(AttestorSubmissionRequest request, RekorBackend backend, CancellationToken cancellationToken = default)
{
var submissionUri = BuildUri(backend.Url, "api/v2/log/entries");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, submissionUri)
{
Content = JsonContent.Create(BuildSubmissionPayload(request), options: SerializerOptions)
};
using var response = await _httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Conflict)
{
var message = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
throw new InvalidOperationException($"Rekor reported a conflict: {message}");
}
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
var root = document.RootElement;
long? index = null;
if (root.TryGetProperty("index", out var indexElement) && indexElement.TryGetInt64(out var indexValue))
{
index = indexValue;
}
return new RekorSubmissionResponse
{
Uuid = root.TryGetProperty("uuid", out var uuidElement) ? uuidElement.GetString() ?? string.Empty : string.Empty,
Index = index,
LogUrl = root.TryGetProperty("logURL", out var urlElement) ? urlElement.GetString() ?? backend.Url.ToString() : backend.Url.ToString(),
Status = root.TryGetProperty("status", out var statusElement) ? statusElement.GetString() ?? "included" : "included",
Proof = TryParseProof(root.TryGetProperty("proof", out var proofElement) ? proofElement : default)
};
}
public async Task<RekorProofResponse?> GetProofAsync(string rekorUuid, RekorBackend backend, CancellationToken cancellationToken = default)
{
var proofUri = BuildUri(backend.Url, $"api/v2/log/entries/{rekorUuid}/proof");
using var request = new HttpRequestMessage(HttpMethod.Get, proofUri);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
_logger.LogDebug("Rekor proof for {Uuid} not found", rekorUuid);
return null;
}
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken).ConfigureAwait(false);
return TryParseProof(document.RootElement);
}
private static object BuildSubmissionPayload(AttestorSubmissionRequest request)
{
var signatures = new List<object>();
foreach (var sig in request.Bundle.Dsse.Signatures)
{
signatures.Add(new { keyid = sig.KeyId, sig = sig.Signature });
}
return new
{
entries = new[]
{
new
{
dsseEnvelope = new
{
payload = request.Bundle.Dsse.PayloadBase64,
payloadType = request.Bundle.Dsse.PayloadType,
signatures
}
}
}
};
}
private static RekorProofResponse? TryParseProof(JsonElement proofElement)
{
if (proofElement.ValueKind == JsonValueKind.Undefined || proofElement.ValueKind == JsonValueKind.Null)
{
return null;
}
var checkpointElement = proofElement.TryGetProperty("checkpoint", out var cp) ? cp : default;
var inclusionElement = proofElement.TryGetProperty("inclusion", out var inc) ? inc : default;
return new RekorProofResponse
{
Checkpoint = checkpointElement.ValueKind == JsonValueKind.Object
? new RekorProofResponse.RekorCheckpoint
{
Origin = checkpointElement.TryGetProperty("origin", out var origin) ? origin.GetString() : null,
Size = checkpointElement.TryGetProperty("size", out var size) && size.TryGetInt64(out var sizeValue) ? sizeValue : 0,
RootHash = checkpointElement.TryGetProperty("rootHash", out var rootHash) ? rootHash.GetString() : null,
Timestamp = checkpointElement.TryGetProperty("timestamp", out var ts) && ts.ValueKind == JsonValueKind.String && DateTimeOffset.TryParse(ts.GetString(), out var dto) ? dto : null
}
: null,
Inclusion = inclusionElement.ValueKind == JsonValueKind.Object
? new RekorProofResponse.RekorInclusionProof
{
LeafHash = inclusionElement.TryGetProperty("leafHash", out var leaf) ? leaf.GetString() : null,
Path = inclusionElement.TryGetProperty("path", out var pathElement) && pathElement.ValueKind == JsonValueKind.Array
? pathElement.EnumerateArray().Select(p => p.GetString() ?? string.Empty).ToArray()
: Array.Empty<string>()
}
: null
};
}
private static Uri BuildUri(Uri baseUri, string relative)
{
if (!relative.StartsWith("/", StringComparison.Ordinal))
{
relative = "/" + relative;
}
return new Uri(baseUri, relative);
}
}

View File

@@ -1,71 +1,71 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Submission;
namespace StellaOps.Attestor.Infrastructure.Rekor;
internal sealed class StubRekorClient : IRekorClient
{
private readonly ILogger<StubRekorClient> _logger;
public StubRekorClient(ILogger<StubRekorClient> logger)
{
_logger = logger;
}
public Task<RekorSubmissionResponse> SubmitAsync(AttestorSubmissionRequest request, RekorBackend backend, CancellationToken cancellationToken = default)
{
var uuid = Guid.NewGuid().ToString();
_logger.LogInformation("Stub Rekor submission for bundle {BundleSha} -> {Uuid}", request.Meta.BundleSha256, uuid);
var proof = new RekorProofResponse
{
Checkpoint = new RekorProofResponse.RekorCheckpoint
{
Origin = backend.Url.Host,
Size = 1,
RootHash = request.Meta.BundleSha256,
Timestamp = DateTimeOffset.UtcNow
},
Inclusion = new RekorProofResponse.RekorInclusionProof
{
LeafHash = request.Meta.BundleSha256,
Path = Array.Empty<string>()
}
};
var response = new RekorSubmissionResponse
{
Uuid = uuid,
Index = Random.Shared.NextInt64(1, long.MaxValue),
LogUrl = new Uri(backend.Url, $"/api/v2/log/entries/{uuid}").ToString(),
Status = "included",
Proof = proof
};
return Task.FromResult(response);
}
public Task<RekorProofResponse?> GetProofAsync(string rekorUuid, RekorBackend backend, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Stub Rekor proof fetch for {Uuid}", rekorUuid);
return Task.FromResult<RekorProofResponse?>(new RekorProofResponse
{
Checkpoint = new RekorProofResponse.RekorCheckpoint
{
Origin = backend.Url.Host,
Size = 1,
RootHash = string.Empty,
Timestamp = DateTimeOffset.UtcNow
},
Inclusion = new RekorProofResponse.RekorInclusionProof
{
LeafHash = string.Empty,
Path = Array.Empty<string>()
}
});
}
}
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Submission;
namespace StellaOps.Attestor.Infrastructure.Rekor;
internal sealed class StubRekorClient : IRekorClient
{
private readonly ILogger<StubRekorClient> _logger;
public StubRekorClient(ILogger<StubRekorClient> logger)
{
_logger = logger;
}
public Task<RekorSubmissionResponse> SubmitAsync(AttestorSubmissionRequest request, RekorBackend backend, CancellationToken cancellationToken = default)
{
var uuid = Guid.NewGuid().ToString();
_logger.LogInformation("Stub Rekor submission for bundle {BundleSha} -> {Uuid}", request.Meta.BundleSha256, uuid);
var proof = new RekorProofResponse
{
Checkpoint = new RekorProofResponse.RekorCheckpoint
{
Origin = backend.Url.Host,
Size = 1,
RootHash = request.Meta.BundleSha256,
Timestamp = DateTimeOffset.UtcNow
},
Inclusion = new RekorProofResponse.RekorInclusionProof
{
LeafHash = request.Meta.BundleSha256,
Path = Array.Empty<string>()
}
};
var response = new RekorSubmissionResponse
{
Uuid = uuid,
Index = Random.Shared.NextInt64(1, long.MaxValue),
LogUrl = new Uri(backend.Url, $"/api/v2/log/entries/{uuid}").ToString(),
Status = "included",
Proof = proof
};
return Task.FromResult(response);
}
public Task<RekorProofResponse?> GetProofAsync(string rekorUuid, RekorBackend backend, CancellationToken cancellationToken = default)
{
_logger.LogInformation("Stub Rekor proof fetch for {Uuid}", rekorUuid);
return Task.FromResult<RekorProofResponse?>(new RekorProofResponse
{
Checkpoint = new RekorProofResponse.RekorCheckpoint
{
Origin = backend.Url.Host,
Size = 1,
RootHash = string.Empty,
Timestamp = DateTimeOffset.UtcNow
},
Inclusion = new RekorProofResponse.RekorInclusionProof
{
LeafHash = string.Empty,
Path = Array.Empty<string>()
}
});
}
}

View File

@@ -1,33 +1,33 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Infrastructure.Storage;
internal sealed class InMemoryAttestorDedupeStore : IAttestorDedupeStore
{
private readonly ConcurrentDictionary<string, (string Uuid, DateTimeOffset ExpiresAt)> _store = new();
public Task<string?> TryGetExistingAsync(string bundleSha256, CancellationToken cancellationToken = default)
{
if (_store.TryGetValue(bundleSha256, out var entry))
{
if (entry.ExpiresAt > DateTimeOffset.UtcNow)
{
return Task.FromResult<string?>(entry.Uuid);
}
_store.TryRemove(bundleSha256, out _);
}
return Task.FromResult<string?>(null);
}
public Task SetAsync(string bundleSha256, string rekorUuid, TimeSpan ttl, CancellationToken cancellationToken = default)
{
_store[bundleSha256] = (rekorUuid, DateTimeOffset.UtcNow.Add(ttl));
return Task.CompletedTask;
}
}
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Infrastructure.Storage;
internal sealed class InMemoryAttestorDedupeStore : IAttestorDedupeStore
{
private readonly ConcurrentDictionary<string, (string Uuid, DateTimeOffset ExpiresAt)> _store = new();
public Task<string?> TryGetExistingAsync(string bundleSha256, CancellationToken cancellationToken = default)
{
if (_store.TryGetValue(bundleSha256, out var entry))
{
if (entry.ExpiresAt > DateTimeOffset.UtcNow)
{
return Task.FromResult<string?>(entry.Uuid);
}
_store.TryRemove(bundleSha256, out _);
}
return Task.FromResult<string?>(null);
}
public Task SetAsync(string bundleSha256, string rekorUuid, TimeSpan ttl, CancellationToken cancellationToken = default)
{
_store[bundleSha256] = (rekorUuid, DateTimeOffset.UtcNow.Add(ttl));
return Task.CompletedTask;
}
}

View File

@@ -1,19 +1,19 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Infrastructure.Storage;
internal sealed class NullAttestorArchiveStore : IAttestorArchiveStore
{
private readonly ILogger<NullAttestorArchiveStore> _logger;
public NullAttestorArchiveStore(ILogger<NullAttestorArchiveStore> logger)
{
_logger = logger;
}
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Infrastructure.Storage;
internal sealed class NullAttestorArchiveStore : IAttestorArchiveStore
{
private readonly ILogger<NullAttestorArchiveStore> _logger;
public NullAttestorArchiveStore(ILogger<NullAttestorArchiveStore> logger)
{
_logger = logger;
}
public Task ArchiveBundleAsync(AttestorArchiveBundle bundle, CancellationToken cancellationToken = default)
{
_logger.LogDebug("Archive disabled; skipping bundle {BundleSha}", bundle.BundleSha256);

View File

@@ -1,34 +1,34 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Infrastructure.Storage;
internal sealed class RedisAttestorDedupeStore : IAttestorDedupeStore
{
private readonly IDatabase _database;
private readonly string _prefix;
public RedisAttestorDedupeStore(IConnectionMultiplexer multiplexer, IOptions<AttestorOptions> options)
{
_database = multiplexer.GetDatabase();
_prefix = options.Value.Redis.DedupePrefix ?? "attestor:dedupe:";
}
public async Task<string?> TryGetExistingAsync(string bundleSha256, CancellationToken cancellationToken = default)
{
var value = await _database.StringGetAsync(BuildKey(bundleSha256)).ConfigureAwait(false);
return value.HasValue ? value.ToString() : null;
}
public Task SetAsync(string bundleSha256, string rekorUuid, TimeSpan ttl, CancellationToken cancellationToken = default)
{
return _database.StringSetAsync(BuildKey(bundleSha256), rekorUuid, ttl);
}
private RedisKey BuildKey(string bundleSha256) => new RedisKey(_prefix + bundleSha256);
}
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using StellaOps.Attestor.Core.Options;
using StellaOps.Attestor.Core.Storage;
namespace StellaOps.Attestor.Infrastructure.Storage;
internal sealed class RedisAttestorDedupeStore : IAttestorDedupeStore
{
private readonly IDatabase _database;
private readonly string _prefix;
public RedisAttestorDedupeStore(IConnectionMultiplexer multiplexer, IOptions<AttestorOptions> options)
{
_database = multiplexer.GetDatabase();
_prefix = options.Value.Redis.DedupePrefix ?? "attestor:dedupe:";
}
public async Task<string?> TryGetExistingAsync(string bundleSha256, CancellationToken cancellationToken = default)
{
var value = await _database.StringGetAsync(BuildKey(bundleSha256)).ConfigureAwait(false);
return value.HasValue ? value.ToString() : null;
}
public Task SetAsync(string bundleSha256, string rekorUuid, TimeSpan ttl, CancellationToken cancellationToken = default)
{
return _database.StringSetAsync(BuildKey(bundleSha256), rekorUuid, ttl);
}
private RedisKey BuildKey(string bundleSha256) => new RedisKey(_prefix + bundleSha256);
}

View File

@@ -1,49 +1,49 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestor.Core.Submission;
namespace StellaOps.Attestor.Infrastructure.Submission;
public sealed class DefaultDsseCanonicalizer : IDsseCanonicalizer
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public Task<byte[]> CanonicalizeAsync(AttestorSubmissionRequest request, CancellationToken cancellationToken = default)
{
var node = new JsonObject
{
["payloadType"] = request.Bundle.Dsse.PayloadType,
["payload"] = request.Bundle.Dsse.PayloadBase64,
["signatures"] = CreateSignaturesArray(request)
};
var json = node.ToJsonString(SerializerOptions);
return Task.FromResult(JsonSerializer.SerializeToUtf8Bytes(JsonNode.Parse(json)!, SerializerOptions));
}
private static JsonArray CreateSignaturesArray(AttestorSubmissionRequest request)
{
var array = new JsonArray();
foreach (var signature in request.Bundle.Dsse.Signatures)
{
var obj = new JsonObject
{
["sig"] = signature.Signature
};
if (!string.IsNullOrWhiteSpace(signature.KeyId))
{
obj["keyid"] = signature.KeyId;
}
array.Add(obj);
}
return array;
}
}
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Attestor.Core.Submission;
namespace StellaOps.Attestor.Infrastructure.Submission;
public sealed class DefaultDsseCanonicalizer : IDsseCanonicalizer
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public Task<byte[]> CanonicalizeAsync(AttestorSubmissionRequest request, CancellationToken cancellationToken = default)
{
var node = new JsonObject
{
["payloadType"] = request.Bundle.Dsse.PayloadType,
["payload"] = request.Bundle.Dsse.PayloadBase64,
["signatures"] = CreateSignaturesArray(request)
};
var json = node.ToJsonString(SerializerOptions);
return Task.FromResult(JsonSerializer.SerializeToUtf8Bytes(JsonNode.Parse(json)!, SerializerOptions));
}
private static JsonArray CreateSignaturesArray(AttestorSubmissionRequest request)
{
var array = new JsonArray();
foreach (var signature in request.Bundle.Dsse.Signatures)
{
var obj = new JsonObject
{
["sig"] = signature.Signature
};
if (!string.IsNullOrWhiteSpace(signature.KeyId))
{
obj["keyid"] = signature.KeyId;
}
array.Add(obj);
}
return array;
}
}

View File

@@ -15,37 +15,37 @@ using StellaOps.Attestor.Infrastructure.Rekor;
using StellaOps.Attestor.Infrastructure.Storage;
using StellaOps.Attestor.Infrastructure.Submission;
using StellaOps.Attestor.Tests.Support;
using Xunit;
namespace StellaOps.Attestor.Tests;
public sealed class AttestorSubmissionServiceTests
{
[Fact]
public async Task SubmitAsync_ReturnsDeterministicUuid_OnDuplicateBundle()
{
var options = Options.Create(new AttestorOptions
{
Redis = new AttestorOptions.RedisOptions
{
Url = string.Empty
},
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.stellaops.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
}
}
});
var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer);
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
using Xunit;
namespace StellaOps.Attestor.Tests;
public sealed class AttestorSubmissionServiceTests
{
[Fact]
public async Task SubmitAsync_ReturnsDeterministicUuid_OnDuplicateBundle()
{
var options = Options.Create(new AttestorOptions
{
Redis = new AttestorOptions.RedisOptions
{
Url = string.Empty
},
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.stellaops.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
}
}
});
var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer);
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
var auditSink = new InMemoryAttestorAuditSink();
@@ -66,21 +66,21 @@ public sealed class AttestorSubmissionServiceTests
logger,
TimeProvider.System,
metrics);
var request = CreateValidRequest(canonicalizer);
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor",
CallerClientId = "signer-service",
CallerTenant = "default",
ClientCertificate = null,
MtlsThumbprint = "00"
};
var first = await service.SubmitAsync(request, context);
var second = await service.SubmitAsync(request, context);
var request = CreateValidRequest(canonicalizer);
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor",
CallerClientId = "signer-service",
CallerTenant = "default",
ClientCertificate = null,
MtlsThumbprint = "00"
};
var first = await service.SubmitAsync(request, context);
var second = await service.SubmitAsync(request, context);
Assert.NotNull(first.Uuid);
Assert.Equal(first.Uuid, second.Uuid);
@@ -89,43 +89,43 @@ public sealed class AttestorSubmissionServiceTests
Assert.Equal(first.Uuid, stored!.RekorUuid);
Assert.Single(verificationCache.InvalidatedSubjects);
Assert.Equal(request.Meta.Artifact.Sha256, verificationCache.InvalidatedSubjects[0]);
}
[Fact]
public async Task Validator_ThrowsWhenModeNotAllowed()
{
var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer, new[] { "kms" });
var request = CreateValidRequest(canonicalizer);
request.Bundle.Mode = "keyless";
await Assert.ThrowsAsync<AttestorValidationException>(() => validator.ValidateAsync(request));
}
[Fact]
public async Task SubmitAsync_Throws_WhenMirrorDisabledButRequested()
{
var options = Options.Create(new AttestorOptions
{
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.primary.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
}
}
});
var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer);
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
}
[Fact]
public async Task Validator_ThrowsWhenModeNotAllowed()
{
var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer, new[] { "kms" });
var request = CreateValidRequest(canonicalizer);
request.Bundle.Mode = "keyless";
await Assert.ThrowsAsync<AttestorValidationException>(() => validator.ValidateAsync(request));
}
[Fact]
public async Task SubmitAsync_Throws_WhenMirrorDisabledButRequested()
{
var options = Options.Create(new AttestorOptions
{
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.primary.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
}
}
});
var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer);
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
var auditSink = new InMemoryAttestorAuditSink();
var witnessClient = new TestTransparencyWitnessClient();
@@ -145,53 +145,53 @@ public sealed class AttestorSubmissionServiceTests
logger,
TimeProvider.System,
metrics);
var request = CreateValidRequest(canonicalizer);
request.Meta.LogPreference = "mirror";
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor",
CallerClientId = "signer-service",
CallerTenant = "default"
};
var ex = await Assert.ThrowsAsync<AttestorValidationException>(() => service.SubmitAsync(request, context));
Assert.Equal("mirror_disabled", ex.Code);
}
[Fact]
public async Task SubmitAsync_ReturnsMirrorMetadata_WhenPreferenceBoth()
{
var options = Options.Create(new AttestorOptions
{
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.primary.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
},
Mirror = new AttestorOptions.RekorMirrorOptions
{
Enabled = true,
Url = "https://rekor.mirror.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
}
}
});
var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer);
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var request = CreateValidRequest(canonicalizer);
request.Meta.LogPreference = "mirror";
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor",
CallerClientId = "signer-service",
CallerTenant = "default"
};
var ex = await Assert.ThrowsAsync<AttestorValidationException>(() => service.SubmitAsync(request, context));
Assert.Equal("mirror_disabled", ex.Code);
}
[Fact]
public async Task SubmitAsync_ReturnsMirrorMetadata_WhenPreferenceBoth()
{
var options = Options.Create(new AttestorOptions
{
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.primary.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
},
Mirror = new AttestorOptions.RekorMirrorOptions
{
Enabled = true,
Url = "https://rekor.mirror.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
}
}
});
var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer);
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
var auditSink = new InMemoryAttestorAuditSink();
var witnessClient = new TestTransparencyWitnessClient();
@@ -211,56 +211,56 @@ public sealed class AttestorSubmissionServiceTests
logger,
TimeProvider.System,
metrics);
var request = CreateValidRequest(canonicalizer);
request.Meta.LogPreference = "both";
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor",
CallerClientId = "signer-service",
CallerTenant = "default"
};
var result = await service.SubmitAsync(request, context);
Assert.NotNull(result.Mirror);
Assert.False(string.IsNullOrEmpty(result.Mirror!.Uuid));
Assert.Equal("included", result.Mirror.Status);
}
[Fact]
public async Task SubmitAsync_UsesMirrorAsCanonical_WhenPreferenceMirror()
{
var options = Options.Create(new AttestorOptions
{
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.primary.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
},
Mirror = new AttestorOptions.RekorMirrorOptions
{
Enabled = true,
Url = "https://rekor.mirror.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
}
}
});
var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer);
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var request = CreateValidRequest(canonicalizer);
request.Meta.LogPreference = "both";
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor",
CallerClientId = "signer-service",
CallerTenant = "default"
};
var result = await service.SubmitAsync(request, context);
Assert.NotNull(result.Mirror);
Assert.False(string.IsNullOrEmpty(result.Mirror!.Uuid));
Assert.Equal("included", result.Mirror.Status);
}
[Fact]
public async Task SubmitAsync_UsesMirrorAsCanonical_WhenPreferenceMirror()
{
var options = Options.Create(new AttestorOptions
{
Redis = new AttestorOptions.RedisOptions { Url = string.Empty },
Rekor = new AttestorOptions.RekorOptions
{
Primary = new AttestorOptions.RekorBackendOptions
{
Url = "https://rekor.primary.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
},
Mirror = new AttestorOptions.RekorMirrorOptions
{
Enabled = true,
Url = "https://rekor.mirror.test",
ProofTimeoutMs = 1000,
PollIntervalMs = 50,
MaxAttempts = 2
}
}
});
var canonicalizer = new DefaultDsseCanonicalizer();
var validator = new AttestorSubmissionValidator(canonicalizer);
var repository = new InMemoryAttestorEntryRepository();
var dedupeStore = new InMemoryAttestorDedupeStore();
var rekorClient = new StubRekorClient(new NullLogger<StubRekorClient>());
var archiveStore = new NullAttestorArchiveStore(new NullLogger<NullAttestorArchiveStore>());
var auditSink = new InMemoryAttestorAuditSink();
var witnessClient = new TestTransparencyWitnessClient();
@@ -280,24 +280,24 @@ public sealed class AttestorSubmissionServiceTests
logger,
TimeProvider.System,
metrics);
var request = CreateValidRequest(canonicalizer);
request.Meta.LogPreference = "mirror";
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor",
CallerClientId = "signer-service",
CallerTenant = "default"
};
var result = await service.SubmitAsync(request, context);
Assert.NotNull(result.Uuid);
var stored = await repository.GetByBundleShaAsync(request.Meta.BundleSha256);
Assert.NotNull(stored);
Assert.Equal("mirror", stored!.Log.Backend);
var request = CreateValidRequest(canonicalizer);
request.Meta.LogPreference = "mirror";
var context = new SubmissionContext
{
CallerSubject = "urn:stellaops:signer",
CallerAudience = "attestor",
CallerClientId = "signer-service",
CallerTenant = "default"
};
var result = await service.SubmitAsync(request, context);
Assert.NotNull(result.Uuid);
var stored = await repository.GetByBundleShaAsync(request.Meta.BundleSha256);
Assert.NotNull(stored);
Assert.Equal("mirror", stored!.Log.Backend);
Assert.Null(result.Mirror);
}
@@ -323,36 +323,36 @@ public sealed class AttestorSubmissionServiceTests
var request = new AttestorSubmissionRequest
{
Bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Mode = "keyless",
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = "application/vnd.in-toto+json",
PayloadBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
Signatures =
{
new AttestorSubmissionRequest.DsseSignature
{
KeyId = "test",
Signature = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
}
}
}
},
Meta = new AttestorSubmissionRequest.SubmissionMeta
{
Artifact = new AttestorSubmissionRequest.ArtifactInfo
{
Sha256 = new string('a', 64),
Kind = "sbom"
},
LogPreference = "primary",
Archive = false
}
};
var canonical = canonicalizer.CanonicalizeAsync(request).GetAwaiter().GetResult();
request.Meta.BundleSha256 = Convert.ToHexString(SHA256.HashData(canonical)).ToLowerInvariant();
return request;
}
}
{
Mode = "keyless",
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = "application/vnd.in-toto+json",
PayloadBase64 = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes("{}")),
Signatures =
{
new AttestorSubmissionRequest.DsseSignature
{
KeyId = "test",
Signature = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
}
}
}
},
Meta = new AttestorSubmissionRequest.SubmissionMeta
{
Artifact = new AttestorSubmissionRequest.ArtifactInfo
{
Sha256 = new string('a', 64),
Kind = "sbom"
},
LogPreference = "primary",
Archive = false
}
};
var canonical = canonicalizer.CanonicalizeAsync(request).GetAwaiter().GetResult();
request.Meta.BundleSha256 = Convert.ToHexString(SHA256.HashData(canonical)).ToLowerInvariant();
return request;
}
}

View File

@@ -1,149 +1,149 @@
using System;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Infrastructure.Rekor;
using Xunit;
namespace StellaOps.Attestor.Tests;
public sealed class HttpRekorClientTests
{
[Fact]
public async Task SubmitAsync_ParsesResponse()
{
var payload = new
{
uuid = "123",
index = 42,
logURL = "https://rekor.example/api/v2/log/entries/123",
status = "included",
proof = new
{
checkpoint = new { origin = "rekor", size = 10, rootHash = "abc", timestamp = "2025-10-19T00:00:00Z" },
inclusion = new { leafHash = "leaf", path = new[] { "p1", "p2" } }
}
};
var client = CreateClient(HttpStatusCode.Created, payload);
var rekorClient = new HttpRekorClient(client, NullLogger<HttpRekorClient>.Instance);
var request = new AttestorSubmissionRequest
{
Bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = "application/json",
PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{}")),
Signatures = { new AttestorSubmissionRequest.DsseSignature { Signature = "sig" } }
}
}
};
var backend = new RekorBackend
{
Name = "primary",
Url = new Uri("https://rekor.example/"),
ProofTimeout = TimeSpan.FromSeconds(1),
PollInterval = TimeSpan.FromMilliseconds(100),
MaxAttempts = 1
};
var response = await rekorClient.SubmitAsync(request, backend);
Assert.Equal("123", response.Uuid);
Assert.Equal(42, response.Index);
Assert.Equal("included", response.Status);
Assert.NotNull(response.Proof);
Assert.Equal("leaf", response.Proof!.Inclusion!.LeafHash);
}
[Fact]
public async Task SubmitAsync_ThrowsOnConflict()
{
var client = CreateClient(HttpStatusCode.Conflict, new { error = "duplicate" });
var rekorClient = new HttpRekorClient(client, NullLogger<HttpRekorClient>.Instance);
var request = new AttestorSubmissionRequest
{
Bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = "application/json",
PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{}")),
Signatures = { new AttestorSubmissionRequest.DsseSignature { Signature = "sig" } }
}
}
};
var backend = new RekorBackend
{
Name = "primary",
Url = new Uri("https://rekor.example/"),
ProofTimeout = TimeSpan.FromSeconds(1),
PollInterval = TimeSpan.FromMilliseconds(100),
MaxAttempts = 1
};
await Assert.ThrowsAsync<InvalidOperationException>(() => rekorClient.SubmitAsync(request, backend));
}
[Fact]
public async Task GetProofAsync_ReturnsNullOnNotFound()
{
var client = CreateClient(HttpStatusCode.NotFound, new { });
var rekorClient = new HttpRekorClient(client, NullLogger<HttpRekorClient>.Instance);
var backend = new RekorBackend
{
Name = "primary",
Url = new Uri("https://rekor.example/"),
ProofTimeout = TimeSpan.FromSeconds(1),
PollInterval = TimeSpan.FromMilliseconds(100),
MaxAttempts = 1
};
var proof = await rekorClient.GetProofAsync("abc", backend);
Assert.Null(proof);
}
private static HttpClient CreateClient(HttpStatusCode statusCode, object payload)
{
var handler = new StubHandler(statusCode, payload);
return new HttpClient(handler)
{
BaseAddress = new Uri("https://rekor.example/")
};
}
private sealed class StubHandler : HttpMessageHandler
{
private readonly HttpStatusCode _statusCode;
private readonly object _payload;
public StubHandler(HttpStatusCode statusCode, object payload)
{
_statusCode = statusCode;
_payload = payload;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(_payload);
var response = new HttpResponseMessage(_statusCode)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
return Task.FromResult(response);
}
}
}
using System;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Attestor.Core.Rekor;
using StellaOps.Attestor.Core.Submission;
using StellaOps.Attestor.Infrastructure.Rekor;
using Xunit;
namespace StellaOps.Attestor.Tests;
public sealed class HttpRekorClientTests
{
[Fact]
public async Task SubmitAsync_ParsesResponse()
{
var payload = new
{
uuid = "123",
index = 42,
logURL = "https://rekor.example/api/v2/log/entries/123",
status = "included",
proof = new
{
checkpoint = new { origin = "rekor", size = 10, rootHash = "abc", timestamp = "2025-10-19T00:00:00Z" },
inclusion = new { leafHash = "leaf", path = new[] { "p1", "p2" } }
}
};
var client = CreateClient(HttpStatusCode.Created, payload);
var rekorClient = new HttpRekorClient(client, NullLogger<HttpRekorClient>.Instance);
var request = new AttestorSubmissionRequest
{
Bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = "application/json",
PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{}")),
Signatures = { new AttestorSubmissionRequest.DsseSignature { Signature = "sig" } }
}
}
};
var backend = new RekorBackend
{
Name = "primary",
Url = new Uri("https://rekor.example/"),
ProofTimeout = TimeSpan.FromSeconds(1),
PollInterval = TimeSpan.FromMilliseconds(100),
MaxAttempts = 1
};
var response = await rekorClient.SubmitAsync(request, backend);
Assert.Equal("123", response.Uuid);
Assert.Equal(42, response.Index);
Assert.Equal("included", response.Status);
Assert.NotNull(response.Proof);
Assert.Equal("leaf", response.Proof!.Inclusion!.LeafHash);
}
[Fact]
public async Task SubmitAsync_ThrowsOnConflict()
{
var client = CreateClient(HttpStatusCode.Conflict, new { error = "duplicate" });
var rekorClient = new HttpRekorClient(client, NullLogger<HttpRekorClient>.Instance);
var request = new AttestorSubmissionRequest
{
Bundle = new AttestorSubmissionRequest.SubmissionBundle
{
Dsse = new AttestorSubmissionRequest.DsseEnvelope
{
PayloadType = "application/json",
PayloadBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes("{}")),
Signatures = { new AttestorSubmissionRequest.DsseSignature { Signature = "sig" } }
}
}
};
var backend = new RekorBackend
{
Name = "primary",
Url = new Uri("https://rekor.example/"),
ProofTimeout = TimeSpan.FromSeconds(1),
PollInterval = TimeSpan.FromMilliseconds(100),
MaxAttempts = 1
};
await Assert.ThrowsAsync<InvalidOperationException>(() => rekorClient.SubmitAsync(request, backend));
}
[Fact]
public async Task GetProofAsync_ReturnsNullOnNotFound()
{
var client = CreateClient(HttpStatusCode.NotFound, new { });
var rekorClient = new HttpRekorClient(client, NullLogger<HttpRekorClient>.Instance);
var backend = new RekorBackend
{
Name = "primary",
Url = new Uri("https://rekor.example/"),
ProofTimeout = TimeSpan.FromSeconds(1),
PollInterval = TimeSpan.FromMilliseconds(100),
MaxAttempts = 1
};
var proof = await rekorClient.GetProofAsync("abc", backend);
Assert.Null(proof);
}
private static HttpClient CreateClient(HttpStatusCode statusCode, object payload)
{
var handler = new StubHandler(statusCode, payload);
return new HttpClient(handler)
{
BaseAddress = new Uri("https://rekor.example/")
};
}
private sealed class StubHandler : HttpMessageHandler
{
private readonly HttpStatusCode _statusCode;
private readonly object _payload;
public StubHandler(HttpStatusCode statusCode, object payload)
{
_statusCode = statusCode;
_payload = payload;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(_payload);
var response = new HttpResponseMessage(_statusCode)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
return Task.FromResult(response);
}
}
}

View File

@@ -1,74 +1,74 @@
using System;
using System.Linq;
using System.Security.Claims;
using StellaOps.Auth.Abstractions;
using Xunit;
namespace StellaOps.Auth.Abstractions.Tests;
public class StellaOpsPrincipalBuilderTests
{
[Fact]
public void NormalizedScopes_AreSortedDeduplicatedLowerCased()
{
var builder = new StellaOpsPrincipalBuilder()
.WithScopes(new[] { "Concelier.Jobs.Trigger", " concelier.jobs.trigger ", "AUTHORITY.USERS.MANAGE" })
.WithAudiences(new[] { " api://concelier ", "api://cli", "api://concelier" });
Assert.Equal(
new[] { "authority.users.manage", "concelier.jobs.trigger" },
builder.NormalizedScopes);
Assert.Equal(
new[] { "api://cli", "api://concelier" },
builder.Audiences);
}
[Fact]
public void Build_ConstructsClaimsPrincipalWithNormalisedValues()
{
var now = DateTimeOffset.UtcNow;
var builder = new StellaOpsPrincipalBuilder()
.WithSubject(" user-1 ")
.WithClientId(" cli-01 ")
.WithTenant(" default ")
.WithName(" Jane Doe ")
.WithIdentityProvider(" internal ")
.WithSessionId(" session-123 ")
.WithTokenId(Guid.NewGuid().ToString("N"))
.WithAuthenticationMethod("password")
.WithAuthenticationType(" custom ")
.WithScopes(new[] { "Concelier.Jobs.Trigger", "AUTHORITY.USERS.MANAGE" })
.WithAudience(" api://concelier ")
.WithIssuedAt(now)
.WithExpires(now.AddMinutes(5))
.AddClaim(" custom ", " value ");
var principal = builder.Build();
var identity = Assert.IsType<ClaimsIdentity>(principal.Identity);
Assert.Equal("custom", identity.AuthenticationType);
Assert.Equal("Jane Doe", identity.Name);
Assert.Equal("user-1", principal.FindFirstValue(StellaOpsClaimTypes.Subject));
Assert.Equal("cli-01", principal.FindFirstValue(StellaOpsClaimTypes.ClientId));
Assert.Equal("default", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
Assert.Equal("internal", principal.FindFirstValue(StellaOpsClaimTypes.IdentityProvider));
Assert.Equal("session-123", principal.FindFirstValue(StellaOpsClaimTypes.SessionId));
Assert.Equal("value", principal.FindFirstValue("custom"));
var scopeClaims = principal.Claims.Where(claim => claim.Type == StellaOpsClaimTypes.ScopeItem).Select(claim => claim.Value).ToArray();
Assert.Equal(new[] { "authority.users.manage", "concelier.jobs.trigger" }, scopeClaims);
var scopeList = principal.FindFirstValue(StellaOpsClaimTypes.Scope);
Assert.Equal("authority.users.manage concelier.jobs.trigger", scopeList);
var audienceClaims = principal.Claims.Where(claim => claim.Type == StellaOpsClaimTypes.Audience).Select(claim => claim.Value).ToArray();
Assert.Equal(new[] { "api://concelier" }, audienceClaims);
var issuedAt = principal.FindFirstValue("iat");
Assert.Equal(now.ToUnixTimeSeconds().ToString(), issuedAt);
var expires = principal.FindFirstValue("exp");
Assert.Equal(now.AddMinutes(5).ToUnixTimeSeconds().ToString(), expires);
}
}
using System;
using System.Linq;
using System.Security.Claims;
using StellaOps.Auth.Abstractions;
using Xunit;
namespace StellaOps.Auth.Abstractions.Tests;
public class StellaOpsPrincipalBuilderTests
{
[Fact]
public void NormalizedScopes_AreSortedDeduplicatedLowerCased()
{
var builder = new StellaOpsPrincipalBuilder()
.WithScopes(new[] { "Concelier.Jobs.Trigger", " concelier.jobs.trigger ", "AUTHORITY.USERS.MANAGE" })
.WithAudiences(new[] { " api://concelier ", "api://cli", "api://concelier" });
Assert.Equal(
new[] { "authority.users.manage", "concelier.jobs.trigger" },
builder.NormalizedScopes);
Assert.Equal(
new[] { "api://cli", "api://concelier" },
builder.Audiences);
}
[Fact]
public void Build_ConstructsClaimsPrincipalWithNormalisedValues()
{
var now = DateTimeOffset.UtcNow;
var builder = new StellaOpsPrincipalBuilder()
.WithSubject(" user-1 ")
.WithClientId(" cli-01 ")
.WithTenant(" default ")
.WithName(" Jane Doe ")
.WithIdentityProvider(" internal ")
.WithSessionId(" session-123 ")
.WithTokenId(Guid.NewGuid().ToString("N"))
.WithAuthenticationMethod("password")
.WithAuthenticationType(" custom ")
.WithScopes(new[] { "Concelier.Jobs.Trigger", "AUTHORITY.USERS.MANAGE" })
.WithAudience(" api://concelier ")
.WithIssuedAt(now)
.WithExpires(now.AddMinutes(5))
.AddClaim(" custom ", " value ");
var principal = builder.Build();
var identity = Assert.IsType<ClaimsIdentity>(principal.Identity);
Assert.Equal("custom", identity.AuthenticationType);
Assert.Equal("Jane Doe", identity.Name);
Assert.Equal("user-1", principal.FindFirstValue(StellaOpsClaimTypes.Subject));
Assert.Equal("cli-01", principal.FindFirstValue(StellaOpsClaimTypes.ClientId));
Assert.Equal("default", principal.FindFirstValue(StellaOpsClaimTypes.Tenant));
Assert.Equal("internal", principal.FindFirstValue(StellaOpsClaimTypes.IdentityProvider));
Assert.Equal("session-123", principal.FindFirstValue(StellaOpsClaimTypes.SessionId));
Assert.Equal("value", principal.FindFirstValue("custom"));
var scopeClaims = principal.Claims.Where(claim => claim.Type == StellaOpsClaimTypes.ScopeItem).Select(claim => claim.Value).ToArray();
Assert.Equal(new[] { "authority.users.manage", "concelier.jobs.trigger" }, scopeClaims);
var scopeList = principal.FindFirstValue(StellaOpsClaimTypes.Scope);
Assert.Equal("authority.users.manage concelier.jobs.trigger", scopeList);
var audienceClaims = principal.Claims.Where(claim => claim.Type == StellaOpsClaimTypes.Audience).Select(claim => claim.Value).ToArray();
Assert.Equal(new[] { "api://concelier" }, audienceClaims);
var issuedAt = principal.FindFirstValue("iat");
Assert.Equal(now.ToUnixTimeSeconds().ToString(), issuedAt);
var expires = principal.FindFirstValue("exp");
Assert.Equal(now.AddMinutes(5).ToUnixTimeSeconds().ToString(), expires);
}
}

View File

@@ -1,53 +1,53 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using Xunit;
namespace StellaOps.Auth.Abstractions.Tests;
public class StellaOpsProblemResultFactoryTests
{
[Fact]
public void AuthenticationRequired_ReturnsCanonicalProblem()
{
var result = StellaOpsProblemResultFactory.AuthenticationRequired(instance: "/jobs");
Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode);
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
Assert.Equal("https://docs.stella-ops.org/problems/authentication-required", details.Type);
Assert.Equal("Authentication required", details.Title);
Assert.Equal("/jobs", details.Instance);
Assert.Equal("unauthorized", details.Extensions["error"]);
Assert.Equal(details.Detail, details.Extensions["error_description"]);
}
[Fact]
public void InvalidToken_UsesProvidedDetail()
{
var result = StellaOpsProblemResultFactory.InvalidToken("expired refresh token");
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode);
Assert.Equal("expired refresh token", details.Detail);
Assert.Equal("invalid_token", details.Extensions["error"]);
}
[Fact]
public void InsufficientScope_AddsScopeExtensions()
{
var result = StellaOpsProblemResultFactory.InsufficientScope(
new[] { StellaOpsScopes.ConcelierJobsTrigger },
new[] { StellaOpsScopes.AuthorityUsersManage },
instance: "/jobs/trigger");
Assert.Equal(StatusCodes.Status403Forbidden, result.StatusCode);
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
Assert.Equal("https://docs.stella-ops.org/problems/insufficient-scope", details.Type);
Assert.Equal("insufficient_scope", details.Extensions["error"]);
Assert.Equal(new[] { StellaOpsScopes.ConcelierJobsTrigger }, Assert.IsType<string[]>(details.Extensions["required_scopes"]));
Assert.Equal(new[] { StellaOpsScopes.AuthorityUsersManage }, Assert.IsType<string[]>(details.Extensions["granted_scopes"]));
Assert.Equal("/jobs/trigger", details.Instance);
}
}
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using StellaOps.Auth.Abstractions;
using Xunit;
namespace StellaOps.Auth.Abstractions.Tests;
public class StellaOpsProblemResultFactoryTests
{
[Fact]
public void AuthenticationRequired_ReturnsCanonicalProblem()
{
var result = StellaOpsProblemResultFactory.AuthenticationRequired(instance: "/jobs");
Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode);
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
Assert.Equal("https://docs.stella-ops.org/problems/authentication-required", details.Type);
Assert.Equal("Authentication required", details.Title);
Assert.Equal("/jobs", details.Instance);
Assert.Equal("unauthorized", details.Extensions["error"]);
Assert.Equal(details.Detail, details.Extensions["error_description"]);
}
[Fact]
public void InvalidToken_UsesProvidedDetail()
{
var result = StellaOpsProblemResultFactory.InvalidToken("expired refresh token");
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
Assert.Equal(StatusCodes.Status401Unauthorized, result.StatusCode);
Assert.Equal("expired refresh token", details.Detail);
Assert.Equal("invalid_token", details.Extensions["error"]);
}
[Fact]
public void InsufficientScope_AddsScopeExtensions()
{
var result = StellaOpsProblemResultFactory.InsufficientScope(
new[] { StellaOpsScopes.ConcelierJobsTrigger },
new[] { StellaOpsScopes.AuthorityUsersManage },
instance: "/jobs/trigger");
Assert.Equal(StatusCodes.Status403Forbidden, result.StatusCode);
var details = Assert.IsType<ProblemDetails>(result.ProblemDetails);
Assert.Equal("https://docs.stella-ops.org/problems/insufficient-scope", details.Type);
Assert.Equal("insufficient_scope", details.Extensions["error"]);
Assert.Equal(new[] { StellaOpsScopes.ConcelierJobsTrigger }, Assert.IsType<string[]>(details.Extensions["required_scopes"]));
Assert.Equal(new[] { StellaOpsScopes.AuthorityUsersManage }, Assert.IsType<string[]>(details.Extensions["granted_scopes"]));
Assert.Equal("/jobs/trigger", details.Instance);
}
}

View File

@@ -1,21 +1,21 @@
using StellaOps.Auth.Abstractions;
using Xunit;
using StellaOps.Auth.Abstractions;
using Xunit;
namespace StellaOps.Auth.Abstractions.Tests;
#pragma warning disable CS0618
public class StellaOpsScopesTests
{
[Theory]
[InlineData(StellaOpsScopes.AdvisoryRead)]
{
[Theory]
[InlineData(StellaOpsScopes.AdvisoryRead)]
[InlineData(StellaOpsScopes.AdvisoryIngest)]
[InlineData(StellaOpsScopes.AdvisoryAiView)]
[InlineData(StellaOpsScopes.AdvisoryAiOperate)]
[InlineData(StellaOpsScopes.AdvisoryAiAdmin)]
[InlineData(StellaOpsScopes.VexRead)]
[InlineData(StellaOpsScopes.VexIngest)]
[InlineData(StellaOpsScopes.AocVerify)]
[InlineData(StellaOpsScopes.VexRead)]
[InlineData(StellaOpsScopes.VexIngest)]
[InlineData(StellaOpsScopes.AocVerify)]
[InlineData(StellaOpsScopes.SignalsRead)]
[InlineData(StellaOpsScopes.SignalsWrite)]
[InlineData(StellaOpsScopes.SignalsAdmin)]
@@ -25,23 +25,23 @@ public class StellaOpsScopesTests
[InlineData(StellaOpsScopes.PolicyWrite)]
[InlineData(StellaOpsScopes.PolicyAuthor)]
[InlineData(StellaOpsScopes.PolicySubmit)]
[InlineData(StellaOpsScopes.PolicyApprove)]
[InlineData(StellaOpsScopes.PolicyApprove)]
[InlineData(StellaOpsScopes.PolicyReview)]
[InlineData(StellaOpsScopes.PolicyOperate)]
[InlineData(StellaOpsScopes.PolicyPublish)]
[InlineData(StellaOpsScopes.PolicyPromote)]
[InlineData(StellaOpsScopes.PolicyAudit)]
[InlineData(StellaOpsScopes.PolicyRun)]
[InlineData(StellaOpsScopes.PolicySimulate)]
[InlineData(StellaOpsScopes.FindingsRead)]
[InlineData(StellaOpsScopes.EffectiveWrite)]
[InlineData(StellaOpsScopes.PolicyAudit)]
[InlineData(StellaOpsScopes.PolicyRun)]
[InlineData(StellaOpsScopes.PolicySimulate)]
[InlineData(StellaOpsScopes.FindingsRead)]
[InlineData(StellaOpsScopes.EffectiveWrite)]
[InlineData(StellaOpsScopes.GraphRead)]
[InlineData(StellaOpsScopes.VulnView)]
[InlineData(StellaOpsScopes.VulnInvestigate)]
[InlineData(StellaOpsScopes.VulnOperate)]
[InlineData(StellaOpsScopes.VulnAudit)]
[InlineData(StellaOpsScopes.VulnRead)]
[InlineData(StellaOpsScopes.GraphWrite)]
[InlineData(StellaOpsScopes.GraphWrite)]
[InlineData(StellaOpsScopes.GraphExport)]
[InlineData(StellaOpsScopes.GraphSimulate)]
[InlineData(StellaOpsScopes.OrchRead)]
@@ -73,8 +73,8 @@ public class StellaOpsScopesTests
Assert.Contains(scope, StellaOpsScopes.All);
}
[Theory]
[InlineData("Advisory:Read", StellaOpsScopes.AdvisoryRead)]
[Theory]
[InlineData("Advisory:Read", StellaOpsScopes.AdvisoryRead)]
[InlineData(" VEX:Ingest ", StellaOpsScopes.VexIngest)]
[InlineData("AOC:VERIFY", StellaOpsScopes.AocVerify)]
[InlineData(" Signals:Write ", StellaOpsScopes.SignalsWrite)]

View File

@@ -1,54 +1,54 @@
using System;
using System.Collections.Generic;
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Canonical scope names supported by StellaOps services.
/// </summary>
public static class StellaOpsScopes
{
/// <summary>
/// Scope required to trigger Concelier jobs.
/// </summary>
public const string ConcelierJobsTrigger = "concelier.jobs.trigger";
/// <summary>
/// Scope required to manage Concelier merge operations.
/// </summary>
public const string ConcelierMerge = "concelier.merge";
/// <summary>
/// Scope granting administrative access to Authority user management.
/// </summary>
public const string AuthorityUsersManage = "authority.users.manage";
/// <summary>
/// Scope granting administrative access to Authority client registrations.
/// </summary>
public const string AuthorityClientsManage = "authority.clients.manage";
/// <summary>
/// Scope granting read-only access to Authority audit logs.
/// </summary>
public const string AuthorityAuditRead = "authority.audit.read";
/// <summary>
/// Synthetic scope representing trusted network bypass.
/// </summary>
public const string Bypass = "stellaops.bypass";
/// <summary>
/// Scope granting read-only access to console UX features.
/// </summary>
public const string UiRead = "ui.read";
/// <summary>
/// Scope granting permission to approve exceptions.
/// </summary>
public const string ExceptionsApprove = "exceptions:approve";
/// <summary>
using System;
using System.Collections.Generic;
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Canonical scope names supported by StellaOps services.
/// </summary>
public static class StellaOpsScopes
{
/// <summary>
/// Scope required to trigger Concelier jobs.
/// </summary>
public const string ConcelierJobsTrigger = "concelier.jobs.trigger";
/// <summary>
/// Scope required to manage Concelier merge operations.
/// </summary>
public const string ConcelierMerge = "concelier.merge";
/// <summary>
/// Scope granting administrative access to Authority user management.
/// </summary>
public const string AuthorityUsersManage = "authority.users.manage";
/// <summary>
/// Scope granting administrative access to Authority client registrations.
/// </summary>
public const string AuthorityClientsManage = "authority.clients.manage";
/// <summary>
/// Scope granting read-only access to Authority audit logs.
/// </summary>
public const string AuthorityAuditRead = "authority.audit.read";
/// <summary>
/// Synthetic scope representing trusted network bypass.
/// </summary>
public const string Bypass = "stellaops.bypass";
/// <summary>
/// Scope granting read-only access to console UX features.
/// </summary>
public const string UiRead = "ui.read";
/// <summary>
/// Scope granting permission to approve exceptions.
/// </summary>
public const string ExceptionsApprove = "exceptions:approve";
/// <summary>
/// Scope granting read-only access to raw advisory ingestion data.
/// </summary>
public const string AdvisoryRead = "advisory:read";
@@ -72,34 +72,34 @@ public static class StellaOpsScopes
/// Scope granting administrative control over Advisory AI configuration and profiles.
/// </summary>
public const string AdvisoryAiAdmin = "advisory-ai:admin";
/// <summary>
/// Scope granting read-only access to raw VEX ingestion data.
/// </summary>
public const string VexRead = "vex:read";
/// <summary>
/// Scope granting write access for raw VEX ingestion.
/// </summary>
public const string VexIngest = "vex:ingest";
/// <summary>
/// Scope granting permission to execute aggregation-only contract verification.
/// </summary>
public const string AocVerify = "aoc:verify";
/// <summary>
/// Scope granting read-only access to reachability signals.
/// </summary>
public const string SignalsRead = "signals:read";
/// <summary>
/// Scope granting permission to write reachability signals.
/// </summary>
public const string SignalsWrite = "signals:write";
/// <summary>
/// Scope granting administrative access to reachability signal ingestion.
/// <summary>
/// Scope granting read-only access to raw VEX ingestion data.
/// </summary>
public const string VexRead = "vex:read";
/// <summary>
/// Scope granting write access for raw VEX ingestion.
/// </summary>
public const string VexIngest = "vex:ingest";
/// <summary>
/// Scope granting permission to execute aggregation-only contract verification.
/// </summary>
public const string AocVerify = "aoc:verify";
/// <summary>
/// Scope granting read-only access to reachability signals.
/// </summary>
public const string SignalsRead = "signals:read";
/// <summary>
/// Scope granting permission to write reachability signals.
/// </summary>
public const string SignalsWrite = "signals:write";
/// <summary>
/// Scope granting administrative access to reachability signal ingestion.
/// </summary>
public const string SignalsAdmin = "signals:admin";
@@ -122,38 +122,38 @@ public static class StellaOpsScopes
/// Scope granting permission to create or edit policy drafts.
/// </summary>
public const string PolicyWrite = "policy:write";
/// <summary>
/// Scope granting permission to author Policy Studio workspaces.
/// </summary>
public const string PolicyAuthor = "policy:author";
/// <summary>
/// Scope granting permission to edit policy configurations.
/// </summary>
public const string PolicyEdit = "policy:edit";
/// <summary>
/// Scope granting read-only access to policy metadata.
/// </summary>
public const string PolicyRead = "policy:read";
/// <summary>
/// Scope granting permission to review Policy Studio drafts.
/// </summary>
public const string PolicyReview = "policy:review";
/// <summary>
/// Scope granting permission to submit drafts for review.
/// </summary>
public const string PolicySubmit = "policy:submit";
/// <summary>
/// Scope granting permission to approve or reject policies.
/// </summary>
public const string PolicyApprove = "policy:approve";
/// <summary>
/// <summary>
/// Scope granting permission to author Policy Studio workspaces.
/// </summary>
public const string PolicyAuthor = "policy:author";
/// <summary>
/// Scope granting permission to edit policy configurations.
/// </summary>
public const string PolicyEdit = "policy:edit";
/// <summary>
/// Scope granting read-only access to policy metadata.
/// </summary>
public const string PolicyRead = "policy:read";
/// <summary>
/// Scope granting permission to review Policy Studio drafts.
/// </summary>
public const string PolicyReview = "policy:review";
/// <summary>
/// Scope granting permission to submit drafts for review.
/// </summary>
public const string PolicySubmit = "policy:submit";
/// <summary>
/// Scope granting permission to approve or reject policies.
/// </summary>
public const string PolicyApprove = "policy:approve";
/// <summary>
/// Scope granting permission to operate Policy Studio promotions and runs.
/// </summary>
public const string PolicyOperate = "policy:operate";
@@ -172,37 +172,37 @@ public static class StellaOpsScopes
/// Scope granting permission to audit Policy Studio activity.
/// </summary>
public const string PolicyAudit = "policy:audit";
/// <summary>
/// Scope granting permission to trigger policy runs and activation workflows.
/// </summary>
public const string PolicyRun = "policy:run";
/// <summary>
/// Scope granting permission to activate policies.
/// </summary>
public const string PolicyActivate = "policy:activate";
/// <summary>
/// Scope granting read-only access to effective findings materialised by Policy Engine.
/// </summary>
public const string FindingsRead = "findings:read";
/// <summary>
/// Scope granting permission to run Policy Studio simulations.
/// </summary>
public const string PolicySimulate = "policy:simulate";
/// <summary>
/// Scope granted to Policy Engine service identity for writing effective findings.
/// </summary>
public const string EffectiveWrite = "effective:write";
/// <summary>
/// Scope granting read-only access to graph queries and overlays.
/// </summary>
public const string GraphRead = "graph:read";
/// <summary>
/// Scope granting permission to trigger policy runs and activation workflows.
/// </summary>
public const string PolicyRun = "policy:run";
/// <summary>
/// Scope granting permission to activate policies.
/// </summary>
public const string PolicyActivate = "policy:activate";
/// <summary>
/// Scope granting read-only access to effective findings materialised by Policy Engine.
/// </summary>
public const string FindingsRead = "findings:read";
/// <summary>
/// Scope granting permission to run Policy Studio simulations.
/// </summary>
public const string PolicySimulate = "policy:simulate";
/// <summary>
/// Scope granted to Policy Engine service identity for writing effective findings.
/// </summary>
public const string EffectiveWrite = "effective:write";
/// <summary>
/// Scope granting read-only access to graph queries and overlays.
/// </summary>
public const string GraphRead = "graph:read";
/// <summary>
/// Scope granting read-only access to Vuln Explorer resources and permalinks.
/// </summary>
@@ -269,14 +269,14 @@ public static class StellaOpsScopes
/// </summary>
public const string ObservabilityIncident = "obs:incident";
/// <summary>
/// Scope granting read-only access to export center runs and bundles.
/// </summary>
public const string ExportViewer = "export.viewer";
/// <summary>
/// Scope granting permission to operate export center scheduling and run execution.
/// </summary>
/// <summary>
/// Scope granting read-only access to export center runs and bundles.
/// </summary>
public const string ExportViewer = "export.viewer";
/// <summary>
/// Scope granting permission to operate export center scheduling and run execution.
/// </summary>
public const string ExportOperator = "export.operator";
/// <summary>
@@ -339,27 +339,27 @@ public static class StellaOpsScopes
/// </summary>
public const string PacksApprove = "packs.approve";
/// <summary>
/// Scope granting permission to enqueue or mutate graph build jobs.
/// </summary>
public const string GraphWrite = "graph:write";
/// <summary>
/// Scope granting permission to export graph artefacts (GraphML/JSONL/etc.).
/// </summary>
public const string GraphExport = "graph:export";
/// <summary>
/// Scope granting permission to trigger what-if simulations on graphs.
/// </summary>
public const string GraphSimulate = "graph:simulate";
/// <summary>
/// Scope granting read-only access to Orchestrator job state and telemetry.
/// </summary>
public const string OrchRead = "orch:read";
/// <summary>
/// <summary>
/// Scope granting permission to enqueue or mutate graph build jobs.
/// </summary>
public const string GraphWrite = "graph:write";
/// <summary>
/// Scope granting permission to export graph artefacts (GraphML/JSONL/etc.).
/// </summary>
public const string GraphExport = "graph:export";
/// <summary>
/// Scope granting permission to trigger what-if simulations on graphs.
/// </summary>
public const string GraphSimulate = "graph:simulate";
/// <summary>
/// Scope granting read-only access to Orchestrator job state and telemetry.
/// </summary>
public const string OrchRead = "orch:read";
/// <summary>
/// Scope granting permission to execute Orchestrator control actions.
/// </summary>
public const string OrchOperate = "orch:operate";
@@ -374,21 +374,21 @@ public static class StellaOpsScopes
/// </summary>
public const string OrchBackfill = "orch:backfill";
/// <summary>
/// Scope granting read-only access to Authority tenant catalog APIs.
/// </summary>
public const string AuthorityTenantsRead = "authority:tenants.read";
private static readonly HashSet<string> KnownScopes = new(StringComparer.OrdinalIgnoreCase)
{
ConcelierJobsTrigger,
ConcelierMerge,
AuthorityUsersManage,
AuthorityClientsManage,
AuthorityAuditRead,
Bypass,
UiRead,
ExceptionsApprove,
/// <summary>
/// Scope granting read-only access to Authority tenant catalog APIs.
/// </summary>
public const string AuthorityTenantsRead = "authority:tenants.read";
private static readonly HashSet<string> KnownScopes = new(StringComparer.OrdinalIgnoreCase)
{
ConcelierJobsTrigger,
ConcelierMerge,
AuthorityUsersManage,
AuthorityClientsManage,
AuthorityAuditRead,
Bypass,
UiRead,
ExceptionsApprove,
AdvisoryRead,
AdvisoryIngest,
AdvisoryAiView,
@@ -406,8 +406,8 @@ public static class StellaOpsScopes
PolicyWrite,
PolicyAuthor,
PolicyEdit,
PolicyRead,
PolicyReview,
PolicyRead,
PolicyReview,
PolicySubmit,
PolicyApprove,
PolicyOperate,
@@ -416,9 +416,9 @@ public static class StellaOpsScopes
PolicyAudit,
PolicyRun,
PolicyActivate,
PolicySimulate,
FindingsRead,
EffectiveWrite,
PolicySimulate,
FindingsRead,
EffectiveWrite,
GraphRead,
VulnView,
VulnInvestigate,
@@ -458,33 +458,33 @@ public static class StellaOpsScopes
OrchQuota,
AuthorityTenantsRead
};
/// <summary>
/// Normalises a scope string (trim/convert to lower case).
/// </summary>
/// <param name="scope">Scope raw value.</param>
/// <returns>Normalised scope or <c>null</c> when the input is blank.</returns>
public static string? Normalize(string? scope)
{
if (string.IsNullOrWhiteSpace(scope))
{
return null;
}
return scope.Trim().ToLowerInvariant();
}
/// <summary>
/// Checks whether the provided scope is registered as a built-in StellaOps scope.
/// </summary>
public static bool IsKnown(string scope)
{
ArgumentNullException.ThrowIfNull(scope);
return KnownScopes.Contains(scope);
}
/// <summary>
/// Returns the full set of built-in scopes.
/// </summary>
public static IReadOnlyCollection<string> All => KnownScopes;
}
/// <summary>
/// Normalises a scope string (trim/convert to lower case).
/// </summary>
/// <param name="scope">Scope raw value.</param>
/// <returns>Normalised scope or <c>null</c> when the input is blank.</returns>
public static string? Normalize(string? scope)
{
if (string.IsNullOrWhiteSpace(scope))
{
return null;
}
return scope.Trim().ToLowerInvariant();
}
/// <summary>
/// Checks whether the provided scope is registered as a built-in StellaOps scope.
/// </summary>
public static bool IsKnown(string scope)
{
ArgumentNullException.ThrowIfNull(scope);
return KnownScopes.Contains(scope);
}
/// <summary>
/// Returns the full set of built-in scopes.
/// </summary>
public static IReadOnlyCollection<string> All => KnownScopes;
}

View File

@@ -1,27 +1,27 @@
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Canonical identifiers for StellaOps service principals.
/// </summary>
public static class StellaOpsServiceIdentities
{
/// <summary>
/// Service identity used by Policy Engine when materialising effective findings.
/// </summary>
public const string PolicyEngine = "policy-engine";
/// <summary>
/// Service identity used by Cartographer when constructing and maintaining graph projections.
/// </summary>
public const string Cartographer = "cartographer";
/// <summary>
/// Service identity used by Vuln Explorer when issuing scoped permalink requests.
/// </summary>
public const string VulnExplorer = "vuln-explorer";
/// <summary>
/// Service identity used by Signals components when managing reachability facts.
/// </summary>
public const string Signals = "signals";
}
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Canonical identifiers for StellaOps service principals.
/// </summary>
public static class StellaOpsServiceIdentities
{
/// <summary>
/// Service identity used by Policy Engine when materialising effective findings.
/// </summary>
public const string PolicyEngine = "policy-engine";
/// <summary>
/// Service identity used by Cartographer when constructing and maintaining graph projections.
/// </summary>
public const string Cartographer = "cartographer";
/// <summary>
/// Service identity used by Vuln Explorer when issuing scoped permalink requests.
/// </summary>
public const string VulnExplorer = "vuln-explorer";
/// <summary>
/// Service identity used by Signals components when managing reachability facts.
/// </summary>
public const string Signals = "signals";
}

View File

@@ -1,12 +1,12 @@
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Shared tenancy default values used across StellaOps services.
/// </summary>
public static class StellaOpsTenancyDefaults
{
/// <summary>
/// Sentinel value indicating the token is not scoped to a specific project.
/// </summary>
public const string AnyProject = "*";
}
namespace StellaOps.Auth.Abstractions;
/// <summary>
/// Shared tenancy default values used across StellaOps services.
/// </summary>
public static class StellaOpsTenancyDefaults
{
/// <summary>
/// Sentinel value indicating the token is not scoped to a specific project.
/// </summary>
public const string AnyProject = "*";
}

View File

@@ -1,84 +1,84 @@
using System;
using StellaOps.Auth.Client;
using Xunit;
namespace StellaOps.Auth.Client.Tests;
public class StellaOpsAuthClientOptionsTests
{
[Fact]
public void Validate_NormalizesScopes()
{
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
ClientId = "cli",
HttpTimeout = TimeSpan.FromSeconds(15)
};
options.DefaultScopes.Add(" Concelier.Jobs.Trigger ");
options.DefaultScopes.Add("concelier.jobs.trigger");
options.DefaultScopes.Add("AUTHORITY.USERS.MANAGE");
options.Validate();
Assert.Equal(new[] { "authority.users.manage", "concelier.jobs.trigger" }, options.NormalizedScopes);
Assert.Equal(new Uri("https://authority.test"), options.AuthorityUri);
Assert.Equal<TimeSpan>(options.RetryDelays, options.NormalizedRetryDelays);
}
[Fact]
public void Validate_Throws_When_AuthorityMissing()
{
var options = new StellaOpsAuthClientOptions();
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_NormalizesRetryDelays()
{
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test"
};
options.RetryDelays.Clear();
options.RetryDelays.Add(TimeSpan.Zero);
options.RetryDelays.Add(TimeSpan.FromSeconds(3));
options.RetryDelays.Add(TimeSpan.FromMilliseconds(-1));
options.Validate();
Assert.Equal<TimeSpan>(new[] { TimeSpan.FromSeconds(3) }, options.NormalizedRetryDelays);
Assert.Equal<TimeSpan>(options.NormalizedRetryDelays, options.RetryDelays);
}
[Fact]
public void Validate_DisabledRetries_ProducesEmptyDelays()
{
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
EnableRetries = false
};
options.Validate();
Assert.Empty(options.NormalizedRetryDelays);
}
[Fact]
public void Validate_Throws_When_OfflineToleranceNegative()
{
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
OfflineCacheTolerance = TimeSpan.FromSeconds(-1)
};
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Offline cache tolerance", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}
using System;
using StellaOps.Auth.Client;
using Xunit;
namespace StellaOps.Auth.Client.Tests;
public class StellaOpsAuthClientOptionsTests
{
[Fact]
public void Validate_NormalizesScopes()
{
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
ClientId = "cli",
HttpTimeout = TimeSpan.FromSeconds(15)
};
options.DefaultScopes.Add(" Concelier.Jobs.Trigger ");
options.DefaultScopes.Add("concelier.jobs.trigger");
options.DefaultScopes.Add("AUTHORITY.USERS.MANAGE");
options.Validate();
Assert.Equal(new[] { "authority.users.manage", "concelier.jobs.trigger" }, options.NormalizedScopes);
Assert.Equal(new Uri("https://authority.test"), options.AuthorityUri);
Assert.Equal<TimeSpan>(options.RetryDelays, options.NormalizedRetryDelays);
}
[Fact]
public void Validate_Throws_When_AuthorityMissing()
{
var options = new StellaOpsAuthClientOptions();
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_NormalizesRetryDelays()
{
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test"
};
options.RetryDelays.Clear();
options.RetryDelays.Add(TimeSpan.Zero);
options.RetryDelays.Add(TimeSpan.FromSeconds(3));
options.RetryDelays.Add(TimeSpan.FromMilliseconds(-1));
options.Validate();
Assert.Equal<TimeSpan>(new[] { TimeSpan.FromSeconds(3) }, options.NormalizedRetryDelays);
Assert.Equal<TimeSpan>(options.NormalizedRetryDelays, options.RetryDelays);
}
[Fact]
public void Validate_DisabledRetries_ProducesEmptyDelays()
{
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
EnableRetries = false
};
options.Validate();
Assert.Empty(options.NormalizedRetryDelays);
}
[Fact]
public void Validate_Throws_When_OfflineToleranceNegative()
{
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
OfflineCacheTolerance = TimeSpan.FromSeconds(-1)
};
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Offline cache tolerance", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -1,111 +1,111 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Auth.Client;
using Xunit;
namespace StellaOps.Auth.Client.Tests;
public class StellaOpsTokenClientTests
{
[Fact]
public async Task RequestPasswordToken_ReturnsResultAndCaches()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-02-01T00:00:00Z"));
var responses = new Queue<HttpResponseMessage>();
responses.Enqueue(CreateJsonResponse("{\"token_endpoint\":\"https://authority.test/connect/token\",\"jwks_uri\":\"https://authority.test/jwks\"}"));
responses.Enqueue(CreateJsonResponse("{\"access_token\":\"abc\",\"token_type\":\"Bearer\",\"expires_in\":120,\"scope\":\"concelier.jobs.trigger\"}"));
responses.Enqueue(CreateJsonResponse("{\"keys\":[]}"));
var handler = new StubHttpMessageHandler((request, cancellationToken) =>
{
Assert.True(responses.Count > 0, $"Unexpected request {request.Method} {request.RequestUri}");
return Task.FromResult(responses.Dequeue());
});
var httpClient = new HttpClient(handler);
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
ClientId = "cli"
};
options.DefaultScopes.Add("concelier.jobs.trigger");
options.Validate();
var optionsMonitor = new TestOptionsMonitor<StellaOpsAuthClientOptions>(options);
var cache = new InMemoryTokenCache(timeProvider, TimeSpan.FromSeconds(5));
var discoveryCache = new StellaOpsDiscoveryCache(httpClient, optionsMonitor, timeProvider);
var jwksCache = new StellaOpsJwksCache(httpClient, discoveryCache, optionsMonitor, timeProvider);
var client = new StellaOpsTokenClient(httpClient, discoveryCache, jwksCache, optionsMonitor, cache, timeProvider, NullLogger<StellaOpsTokenClient>.Instance);
var result = await client.RequestPasswordTokenAsync("user", "pass");
Assert.Equal("abc", result.AccessToken);
Assert.Contains("concelier.jobs.trigger", result.Scopes);
await client.CacheTokenAsync("key", result.ToCacheEntry());
var cached = await client.GetCachedTokenAsync("key");
Assert.NotNull(cached);
Assert.Equal("abc", cached!.AccessToken);
var jwks = await client.GetJsonWebKeySetAsync();
Assert.Empty(jwks.Keys);
}
private static HttpResponseMessage CreateJsonResponse(string json)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json)
{
Headers = { ContentType = new MediaTypeHeaderValue("application/json") }
}
};
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder;
public StubHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder)
{
this.responder = responder;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> responder(request, cancellationToken);
}
private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
where TOptions : class
{
private readonly TOptions value;
public TestOptionsMonitor(TOptions value)
{
this.value = value;
}
public TOptions CurrentValue => value;
public TOptions Get(string? name) => value;
public IDisposable OnChange(Action<TOptions, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static NullDisposable Instance { get; } = new();
public void Dispose()
{
}
}
}
}
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Auth.Client;
using Xunit;
namespace StellaOps.Auth.Client.Tests;
public class StellaOpsTokenClientTests
{
[Fact]
public async Task RequestPasswordToken_ReturnsResultAndCaches()
{
var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2025-02-01T00:00:00Z"));
var responses = new Queue<HttpResponseMessage>();
responses.Enqueue(CreateJsonResponse("{\"token_endpoint\":\"https://authority.test/connect/token\",\"jwks_uri\":\"https://authority.test/jwks\"}"));
responses.Enqueue(CreateJsonResponse("{\"access_token\":\"abc\",\"token_type\":\"Bearer\",\"expires_in\":120,\"scope\":\"concelier.jobs.trigger\"}"));
responses.Enqueue(CreateJsonResponse("{\"keys\":[]}"));
var handler = new StubHttpMessageHandler((request, cancellationToken) =>
{
Assert.True(responses.Count > 0, $"Unexpected request {request.Method} {request.RequestUri}");
return Task.FromResult(responses.Dequeue());
});
var httpClient = new HttpClient(handler);
var options = new StellaOpsAuthClientOptions
{
Authority = "https://authority.test",
ClientId = "cli"
};
options.DefaultScopes.Add("concelier.jobs.trigger");
options.Validate();
var optionsMonitor = new TestOptionsMonitor<StellaOpsAuthClientOptions>(options);
var cache = new InMemoryTokenCache(timeProvider, TimeSpan.FromSeconds(5));
var discoveryCache = new StellaOpsDiscoveryCache(httpClient, optionsMonitor, timeProvider);
var jwksCache = new StellaOpsJwksCache(httpClient, discoveryCache, optionsMonitor, timeProvider);
var client = new StellaOpsTokenClient(httpClient, discoveryCache, jwksCache, optionsMonitor, cache, timeProvider, NullLogger<StellaOpsTokenClient>.Instance);
var result = await client.RequestPasswordTokenAsync("user", "pass");
Assert.Equal("abc", result.AccessToken);
Assert.Contains("concelier.jobs.trigger", result.Scopes);
await client.CacheTokenAsync("key", result.ToCacheEntry());
var cached = await client.GetCachedTokenAsync("key");
Assert.NotNull(cached);
Assert.Equal("abc", cached!.AccessToken);
var jwks = await client.GetJsonWebKeySetAsync();
Assert.Empty(jwks.Keys);
}
private static HttpResponseMessage CreateJsonResponse(string json)
{
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json)
{
Headers = { ContentType = new MediaTypeHeaderValue("application/json") }
}
};
}
private sealed class StubHttpMessageHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder;
public StubHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> responder)
{
this.responder = responder;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
=> responder(request, cancellationToken);
}
private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions>
where TOptions : class
{
private readonly TOptions value;
public TestOptionsMonitor(TOptions value)
{
this.value = value;
}
public TOptions CurrentValue => value;
public TOptions Get(string? name) => value;
public IDisposable OnChange(Action<TOptions, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static NullDisposable Instance { get; } = new();
public void Dispose()
{
}
}
}
}

View File

@@ -1,42 +1,42 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.Client;
/// <summary>
/// Abstraction for requesting tokens from StellaOps Authority.
/// </summary>
public interface IStellaOpsTokenClient
{
/// <summary>
/// Requests an access token using the resource owner password credentials flow.
/// </summary>
Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
/// <summary>
/// Requests an access token using the client credentials flow.
/// </summary>
Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves the cached JWKS document.
/// </summary>
Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a cached token entry.
/// </summary>
ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default);
/// <summary>
/// Persists a token entry in the cache.
/// </summary>
ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default);
/// <summary>
/// Removes a cached entry.
/// </summary>
ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default);
}
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.Client;
/// <summary>
/// Abstraction for requesting tokens from StellaOps Authority.
/// </summary>
public interface IStellaOpsTokenClient
{
/// <summary>
/// Requests an access token using the resource owner password credentials flow.
/// </summary>
Task<StellaOpsTokenResult> RequestPasswordTokenAsync(string username, string password, string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
/// <summary>
/// Requests an access token using the client credentials flow.
/// </summary>
Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves the cached JWKS document.
/// </summary>
Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Retrieves a cached token entry.
/// </summary>
ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default);
/// <summary>
/// Persists a token entry in the cache.
/// </summary>
ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default);
/// <summary>
/// Removes a cached entry.
/// </summary>
ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default);
}

View File

@@ -1,236 +1,236 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.Client;
/// <summary>
/// Default implementation of <see cref="IStellaOpsTokenClient"/>.
/// </summary>
public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
{
private static readonly MediaTypeHeaderValue JsonMediaType = new("application/json");
private readonly HttpClient httpClient;
private readonly StellaOpsDiscoveryCache discoveryCache;
private readonly StellaOpsJwksCache jwksCache;
private readonly IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor;
private readonly IStellaOpsTokenCache tokenCache;
private readonly TimeProvider timeProvider;
private readonly ILogger<StellaOpsTokenClient>? logger;
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web);
public StellaOpsTokenClient(
HttpClient httpClient,
StellaOpsDiscoveryCache discoveryCache,
StellaOpsJwksCache jwksCache,
IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor,
IStellaOpsTokenCache tokenCache,
TimeProvider? timeProvider = null,
ILogger<StellaOpsTokenClient>? logger = null)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.discoveryCache = discoveryCache ?? throw new ArgumentNullException(nameof(discoveryCache));
this.jwksCache = jwksCache ?? throw new ArgumentNullException(nameof(jwksCache));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.tokenCache = tokenCache ?? throw new ArgumentNullException(nameof(tokenCache));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger;
}
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(
string username,
string password,
string? scope = null,
IReadOnlyDictionary<string, string>? additionalParameters = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(username);
ArgumentException.ThrowIfNullOrWhiteSpace(password);
var options = optionsMonitor.CurrentValue;
var parameters = new Dictionary<string, string>(StringComparer.Ordinal)
{
["grant_type"] = "password",
["username"] = username,
["password"] = password,
["client_id"] = options.ClientId
};
if (!string.IsNullOrEmpty(options.ClientSecret))
{
parameters["client_secret"] = options.ClientSecret;
}
AppendScope(parameters, scope, options);
if (additionalParameters is not null)
{
foreach (var (key, value) in additionalParameters)
{
if (string.IsNullOrWhiteSpace(key) || value is null)
{
continue;
}
parameters[key] = value;
}
}
return RequestTokenAsync(parameters, cancellationToken);
}
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
{
var options = optionsMonitor.CurrentValue;
if (string.IsNullOrWhiteSpace(options.ClientId))
{
throw new InvalidOperationException("Client credentials flow requires ClientId to be configured.");
}
var parameters = new Dictionary<string, string>(StringComparer.Ordinal)
{
["grant_type"] = "client_credentials",
["client_id"] = options.ClientId
};
if (!string.IsNullOrEmpty(options.ClientSecret))
{
parameters["client_secret"] = options.ClientSecret;
}
AppendScope(parameters, scope, options);
if (additionalParameters is not null)
{
foreach (var (key, value) in additionalParameters)
{
if (string.IsNullOrWhiteSpace(key) || value is null)
{
continue;
}
parameters[key] = value;
}
}
return RequestTokenAsync(parameters, cancellationToken);
}
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
=> jwksCache.GetAsync(cancellationToken);
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> tokenCache.GetAsync(key, cancellationToken);
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
=> tokenCache.SetAsync(key, entry, cancellationToken);
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> tokenCache.RemoveAsync(key, cancellationToken);
private async Task<StellaOpsTokenResult> RequestTokenAsync(Dictionary<string, string> parameters, CancellationToken cancellationToken)
{
var options = optionsMonitor.CurrentValue;
var configuration = await discoveryCache.GetAsync(cancellationToken).ConfigureAwait(false);
using var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint)
{
Content = new FormUrlEncodedContent(parameters)
};
request.Headers.Accept.TryParseAdd(JsonMediaType.ToString());
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
logger?.LogWarning("Token request failed with status {StatusCode}: {Payload}", response.StatusCode, payload);
throw new InvalidOperationException($"Token request failed with status {(int)response.StatusCode}.");
}
var document = JsonSerializer.Deserialize<TokenResponseDocument>(payload, serializerOptions);
if (document is null || string.IsNullOrWhiteSpace(document.AccessToken))
{
throw new InvalidOperationException("Token response did not contain an access_token.");
}
var expiresIn = document.ExpiresIn ?? 3600;
var expiresAt = timeProvider.GetUtcNow() + TimeSpan.FromSeconds(expiresIn);
var normalizedScopes = ParseScopes(document.Scope ?? parameters.GetValueOrDefault("scope"));
var result = new StellaOpsTokenResult(
document.AccessToken,
document.TokenType ?? "Bearer",
expiresAt,
normalizedScopes,
document.RefreshToken,
document.IdToken,
payload);
logger?.LogDebug("Token issued; expires at {ExpiresAt}.", expiresAt);
return result;
}
private static void AppendScope(IDictionary<string, string> parameters, string? scope, StellaOpsAuthClientOptions options)
{
var resolvedScope = scope;
if (string.IsNullOrWhiteSpace(resolvedScope) && options.NormalizedScopes.Count > 0)
{
resolvedScope = string.Join(' ', options.NormalizedScopes);
}
if (!string.IsNullOrWhiteSpace(resolvedScope))
{
parameters["scope"] = resolvedScope;
}
}
private static string[] ParseScopes(string? scope)
{
if (string.IsNullOrWhiteSpace(scope))
{
return Array.Empty<string>();
}
var parts = scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 0)
{
return Array.Empty<string>();
}
var unique = new HashSet<string>(parts.Length, StringComparer.Ordinal);
foreach (var part in parts)
{
unique.Add(part);
}
var result = new string[unique.Count];
unique.CopyTo(result);
Array.Sort(result, StringComparer.Ordinal);
return result;
}
private sealed record TokenResponseDocument(
[property: JsonPropertyName("access_token")] string? AccessToken,
[property: JsonPropertyName("refresh_token")] string? RefreshToken,
[property: JsonPropertyName("id_token")] string? IdToken,
[property: JsonPropertyName("token_type")] string? TokenType,
[property: JsonPropertyName("expires_in")] int? ExpiresIn,
[property: JsonPropertyName("scope")] string? Scope,
[property: JsonPropertyName("error")] string? Error,
[property: JsonPropertyName("error_description")] string? ErrorDescription);
}
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.Client;
/// <summary>
/// Default implementation of <see cref="IStellaOpsTokenClient"/>.
/// </summary>
public sealed class StellaOpsTokenClient : IStellaOpsTokenClient
{
private static readonly MediaTypeHeaderValue JsonMediaType = new("application/json");
private readonly HttpClient httpClient;
private readonly StellaOpsDiscoveryCache discoveryCache;
private readonly StellaOpsJwksCache jwksCache;
private readonly IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor;
private readonly IStellaOpsTokenCache tokenCache;
private readonly TimeProvider timeProvider;
private readonly ILogger<StellaOpsTokenClient>? logger;
private readonly JsonSerializerOptions serializerOptions = new(JsonSerializerDefaults.Web);
public StellaOpsTokenClient(
HttpClient httpClient,
StellaOpsDiscoveryCache discoveryCache,
StellaOpsJwksCache jwksCache,
IOptionsMonitor<StellaOpsAuthClientOptions> optionsMonitor,
IStellaOpsTokenCache tokenCache,
TimeProvider? timeProvider = null,
ILogger<StellaOpsTokenClient>? logger = null)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.discoveryCache = discoveryCache ?? throw new ArgumentNullException(nameof(discoveryCache));
this.jwksCache = jwksCache ?? throw new ArgumentNullException(nameof(jwksCache));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.tokenCache = tokenCache ?? throw new ArgumentNullException(nameof(tokenCache));
this.timeProvider = timeProvider ?? TimeProvider.System;
this.logger = logger;
}
public Task<StellaOpsTokenResult> RequestPasswordTokenAsync(
string username,
string password,
string? scope = null,
IReadOnlyDictionary<string, string>? additionalParameters = null,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(username);
ArgumentException.ThrowIfNullOrWhiteSpace(password);
var options = optionsMonitor.CurrentValue;
var parameters = new Dictionary<string, string>(StringComparer.Ordinal)
{
["grant_type"] = "password",
["username"] = username,
["password"] = password,
["client_id"] = options.ClientId
};
if (!string.IsNullOrEmpty(options.ClientSecret))
{
parameters["client_secret"] = options.ClientSecret;
}
AppendScope(parameters, scope, options);
if (additionalParameters is not null)
{
foreach (var (key, value) in additionalParameters)
{
if (string.IsNullOrWhiteSpace(key) || value is null)
{
continue;
}
parameters[key] = value;
}
}
return RequestTokenAsync(parameters, cancellationToken);
}
public Task<StellaOpsTokenResult> RequestClientCredentialsTokenAsync(string? scope = null, IReadOnlyDictionary<string, string>? additionalParameters = null, CancellationToken cancellationToken = default)
{
var options = optionsMonitor.CurrentValue;
if (string.IsNullOrWhiteSpace(options.ClientId))
{
throw new InvalidOperationException("Client credentials flow requires ClientId to be configured.");
}
var parameters = new Dictionary<string, string>(StringComparer.Ordinal)
{
["grant_type"] = "client_credentials",
["client_id"] = options.ClientId
};
if (!string.IsNullOrEmpty(options.ClientSecret))
{
parameters["client_secret"] = options.ClientSecret;
}
AppendScope(parameters, scope, options);
if (additionalParameters is not null)
{
foreach (var (key, value) in additionalParameters)
{
if (string.IsNullOrWhiteSpace(key) || value is null)
{
continue;
}
parameters[key] = value;
}
}
return RequestTokenAsync(parameters, cancellationToken);
}
public Task<JsonWebKeySet> GetJsonWebKeySetAsync(CancellationToken cancellationToken = default)
=> jwksCache.GetAsync(cancellationToken);
public ValueTask<StellaOpsTokenCacheEntry?> GetCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> tokenCache.GetAsync(key, cancellationToken);
public ValueTask CacheTokenAsync(string key, StellaOpsTokenCacheEntry entry, CancellationToken cancellationToken = default)
=> tokenCache.SetAsync(key, entry, cancellationToken);
public ValueTask ClearCachedTokenAsync(string key, CancellationToken cancellationToken = default)
=> tokenCache.RemoveAsync(key, cancellationToken);
private async Task<StellaOpsTokenResult> RequestTokenAsync(Dictionary<string, string> parameters, CancellationToken cancellationToken)
{
var options = optionsMonitor.CurrentValue;
var configuration = await discoveryCache.GetAsync(cancellationToken).ConfigureAwait(false);
using var request = new HttpRequestMessage(HttpMethod.Post, configuration.TokenEndpoint)
{
Content = new FormUrlEncodedContent(parameters)
};
request.Headers.Accept.TryParseAdd(JsonMediaType.ToString());
using var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
logger?.LogWarning("Token request failed with status {StatusCode}: {Payload}", response.StatusCode, payload);
throw new InvalidOperationException($"Token request failed with status {(int)response.StatusCode}.");
}
var document = JsonSerializer.Deserialize<TokenResponseDocument>(payload, serializerOptions);
if (document is null || string.IsNullOrWhiteSpace(document.AccessToken))
{
throw new InvalidOperationException("Token response did not contain an access_token.");
}
var expiresIn = document.ExpiresIn ?? 3600;
var expiresAt = timeProvider.GetUtcNow() + TimeSpan.FromSeconds(expiresIn);
var normalizedScopes = ParseScopes(document.Scope ?? parameters.GetValueOrDefault("scope"));
var result = new StellaOpsTokenResult(
document.AccessToken,
document.TokenType ?? "Bearer",
expiresAt,
normalizedScopes,
document.RefreshToken,
document.IdToken,
payload);
logger?.LogDebug("Token issued; expires at {ExpiresAt}.", expiresAt);
return result;
}
private static void AppendScope(IDictionary<string, string> parameters, string? scope, StellaOpsAuthClientOptions options)
{
var resolvedScope = scope;
if (string.IsNullOrWhiteSpace(resolvedScope) && options.NormalizedScopes.Count > 0)
{
resolvedScope = string.Join(' ', options.NormalizedScopes);
}
if (!string.IsNullOrWhiteSpace(resolvedScope))
{
parameters["scope"] = resolvedScope;
}
}
private static string[] ParseScopes(string? scope)
{
if (string.IsNullOrWhiteSpace(scope))
{
return Array.Empty<string>();
}
var parts = scope.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length == 0)
{
return Array.Empty<string>();
}
var unique = new HashSet<string>(parts.Length, StringComparer.Ordinal);
foreach (var part in parts)
{
unique.Add(part);
}
var result = new string[unique.Count];
unique.CopyTo(result);
Array.Sort(result, StringComparer.Ordinal);
return result;
}
private sealed record TokenResponseDocument(
[property: JsonPropertyName("access_token")] string? AccessToken,
[property: JsonPropertyName("refresh_token")] string? RefreshToken,
[property: JsonPropertyName("id_token")] string? IdToken,
[property: JsonPropertyName("token_type")] string? TokenType,
[property: JsonPropertyName("expires_in")] int? ExpiresIn,
[property: JsonPropertyName("scope")] string? Scope,
[property: JsonPropertyName("error")] string? Error,
[property: JsonPropertyName("error_description")] string? ErrorDescription);
}

View File

@@ -1,43 +1,43 @@
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using Xunit;
namespace StellaOps.Auth.ServerIntegration.Tests;
public class ServiceCollectionExtensionsTests
{
[Fact]
public void AddStellaOpsResourceServerAuthentication_ConfiguresJwtBearer()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:ResourceServer:Authority"] = "https://authority.example",
["Authority:ResourceServer:Audiences:0"] = "api://concelier",
["Authority:ResourceServer:RequiredScopes:0"] = "concelier.jobs.trigger",
["Authority:ResourceServer:BypassNetworks:0"] = "127.0.0.1/32"
})
.Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddStellaOpsResourceServerAuthentication(configuration);
using var provider = services.BuildServiceProvider();
var resourceOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsResourceServerOptions>>().CurrentValue;
var jwtOptions = provider.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>().Get(StellaOpsAuthenticationDefaults.AuthenticationScheme);
Assert.NotNull(jwtOptions.Authority);
Assert.Equal(new Uri("https://authority.example/"), new Uri(jwtOptions.Authority!));
Assert.True(jwtOptions.TokenValidationParameters.ValidateAudience);
Assert.Contains("api://concelier", jwtOptions.TokenValidationParameters.ValidAudiences);
using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.ServerIntegration;
using Xunit;
namespace StellaOps.Auth.ServerIntegration.Tests;
public class ServiceCollectionExtensionsTests
{
[Fact]
public void AddStellaOpsResourceServerAuthentication_ConfiguresJwtBearer()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Authority:ResourceServer:Authority"] = "https://authority.example",
["Authority:ResourceServer:Audiences:0"] = "api://concelier",
["Authority:ResourceServer:RequiredScopes:0"] = "concelier.jobs.trigger",
["Authority:ResourceServer:BypassNetworks:0"] = "127.0.0.1/32"
})
.Build();
var services = new ServiceCollection();
services.AddLogging();
services.AddStellaOpsResourceServerAuthentication(configuration);
using var provider = services.BuildServiceProvider();
var resourceOptions = provider.GetRequiredService<IOptionsMonitor<StellaOpsResourceServerOptions>>().CurrentValue;
var jwtOptions = provider.GetRequiredService<IOptionsMonitor<JwtBearerOptions>>().Get(StellaOpsAuthenticationDefaults.AuthenticationScheme);
Assert.NotNull(jwtOptions.Authority);
Assert.Equal(new Uri("https://authority.example/"), new Uri(jwtOptions.Authority!));
Assert.True(jwtOptions.TokenValidationParameters.ValidateAudience);
Assert.Contains("api://concelier", jwtOptions.TokenValidationParameters.ValidAudiences);
Assert.Equal(TimeSpan.FromSeconds(60), jwtOptions.TokenValidationParameters.ClockSkew);
Assert.Equal(new[] { "concelier.jobs.trigger" }, resourceOptions.NormalizedScopes);
Assert.IsType<StellaOpsAuthorityConfigurationManager>(jwtOptions.ConfigurationManager);

View File

@@ -1,55 +1,55 @@
using System;
using System.Net;
using StellaOps.Auth.ServerIntegration;
using Xunit;
namespace StellaOps.Auth.ServerIntegration.Tests;
public class StellaOpsResourceServerOptionsTests
{
[Fact]
public void Validate_NormalisesCollections()
{
var options = new StellaOpsResourceServerOptions
{
Authority = "https://authority.stella-ops.test",
BackchannelTimeout = TimeSpan.FromSeconds(10),
TokenClockSkew = TimeSpan.FromSeconds(30)
};
options.Audiences.Add(" api://concelier ");
options.Audiences.Add("api://concelier");
options.Audiences.Add("api://concelier-admin");
options.RequiredScopes.Add(" Concelier.Jobs.Trigger ");
options.RequiredScopes.Add("concelier.jobs.trigger");
options.RequiredScopes.Add("AUTHORITY.USERS.MANAGE");
options.RequiredTenants.Add(" Tenant-Alpha ");
options.RequiredTenants.Add("tenant-alpha");
options.RequiredTenants.Add("Tenant-Beta");
options.BypassNetworks.Add("127.0.0.1/32");
options.BypassNetworks.Add(" 127.0.0.1/32 ");
options.BypassNetworks.Add("::1/128");
options.Validate();
Assert.Equal(new Uri("https://authority.stella-ops.test"), options.AuthorityUri);
Assert.Equal(new[] { "api://concelier", "api://concelier-admin" }, options.Audiences);
Assert.Equal(new[] { "authority.users.manage", "concelier.jobs.trigger" }, options.NormalizedScopes);
Assert.Equal(new[] { "tenant-alpha", "tenant-beta" }, options.NormalizedTenants);
Assert.True(options.BypassMatcher.IsAllowed(IPAddress.Parse("127.0.0.1")));
Assert.True(options.BypassMatcher.IsAllowed(IPAddress.IPv6Loopback));
}
[Fact]
public void Validate_Throws_When_AuthorityMissing()
{
var options = new StellaOpsResourceServerOptions();
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}
using System;
using System.Net;
using StellaOps.Auth.ServerIntegration;
using Xunit;
namespace StellaOps.Auth.ServerIntegration.Tests;
public class StellaOpsResourceServerOptionsTests
{
[Fact]
public void Validate_NormalisesCollections()
{
var options = new StellaOpsResourceServerOptions
{
Authority = "https://authority.stella-ops.test",
BackchannelTimeout = TimeSpan.FromSeconds(10),
TokenClockSkew = TimeSpan.FromSeconds(30)
};
options.Audiences.Add(" api://concelier ");
options.Audiences.Add("api://concelier");
options.Audiences.Add("api://concelier-admin");
options.RequiredScopes.Add(" Concelier.Jobs.Trigger ");
options.RequiredScopes.Add("concelier.jobs.trigger");
options.RequiredScopes.Add("AUTHORITY.USERS.MANAGE");
options.RequiredTenants.Add(" Tenant-Alpha ");
options.RequiredTenants.Add("tenant-alpha");
options.RequiredTenants.Add("Tenant-Beta");
options.BypassNetworks.Add("127.0.0.1/32");
options.BypassNetworks.Add(" 127.0.0.1/32 ");
options.BypassNetworks.Add("::1/128");
options.Validate();
Assert.Equal(new Uri("https://authority.stella-ops.test"), options.AuthorityUri);
Assert.Equal(new[] { "api://concelier", "api://concelier-admin" }, options.Audiences);
Assert.Equal(new[] { "authority.users.manage", "concelier.jobs.trigger" }, options.NormalizedScopes);
Assert.Equal(new[] { "tenant-alpha", "tenant-beta" }, options.NormalizedTenants);
Assert.True(options.BypassMatcher.IsAllowed(IPAddress.Parse("127.0.0.1")));
Assert.True(options.BypassMatcher.IsAllowed(IPAddress.IPv6Loopback));
}
[Fact]
public void Validate_Throws_When_AuthorityMissing()
{
var options = new StellaOpsResourceServerOptions();
var exception = Assert.Throws<InvalidOperationException>(() => options.Validate());
Assert.Contains("Authority", exception.Message, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -15,21 +15,21 @@ using StellaOps.Auth.ServerIntegration;
using StellaOps.Cryptography.Audit;
using OpenIddict.Abstractions;
using Xunit;
namespace StellaOps.Auth.ServerIntegration.Tests;
public class StellaOpsScopeAuthorizationHandlerTests
{
[Fact]
public async Task HandleRequirement_Succeeds_WhenScopePresent()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredTenants.Add("tenant-alpha");
options.Validate();
});
namespace StellaOps.Auth.ServerIntegration.Tests;
public class StellaOpsScopeAuthorizationHandlerTests
{
[Fact]
public async Task HandleRequirement_Succeeds_WhenScopePresent()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredTenants.Add("tenant-alpha");
options.Validate();
});
var (handler, accessor, sink) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1"));
var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger });
var principal = new StellaOpsPrincipalBuilder()
@@ -108,9 +108,9 @@ public class StellaOpsScopeAuthorizationHandlerTests
}
[Fact]
public async Task HandleRequirement_Fails_WhenScopeMissingAndNoBypass()
{
var optionsMonitor = CreateOptionsMonitor(options =>
public async Task HandleRequirement_Fails_WhenScopeMissingAndNoBypass()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.Validate();
@@ -133,9 +133,9 @@ public class StellaOpsScopeAuthorizationHandlerTests
[Fact]
public async Task HandleRequirement_Fails_WhenDefaultScopeMissing()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredScopes.Add(StellaOpsScopes.PolicyRun);
options.Validate();
});
@@ -162,9 +162,9 @@ public class StellaOpsScopeAuthorizationHandlerTests
[Fact]
public async Task HandleRequirement_Succeeds_WhenDefaultScopePresent()
{
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
var optionsMonitor = CreateOptionsMonitor(options =>
{
options.Authority = "https://authority.example";
options.RequiredScopes.Add(StellaOpsScopes.PolicyRun);
options.Validate();
});
@@ -514,24 +514,24 @@ public class StellaOpsScopeAuthorizationHandlerTests
{
private readonly TOptions value;
public TestOptionsMonitor(Action<TOptions> configure)
{
value = new TOptions();
configure(value);
}
public TOptions CurrentValue => value;
public TOptions Get(string? name) => value;
public IDisposable OnChange(Action<TOptions, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static NullDisposable Instance { get; } = new();
public void Dispose()
{
}
}
}
}
public TestOptionsMonitor(Action<TOptions> configure)
{
value = new TOptions();
configure(value);
}
public TOptions CurrentValue => value;
public TOptions Get(string? name) => value;
public IDisposable OnChange(Action<TOptions, string> listener) => NullDisposable.Instance;
private sealed class NullDisposable : IDisposable
{
public static NullDisposable Instance { get; } = new();
public void Dispose()
{
}
}
}
}

View File

@@ -1,92 +1,92 @@
using System;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Auth.ServerIntegration;
/// <summary>
/// Dependency injection helpers for configuring StellaOps resource server authentication.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers JWT bearer authentication and related authorisation helpers using the provided configuration section.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">Application configuration.</param>
/// <param name="configurationSection">
/// Optional configuration section path. Defaults to <c>Authority:ResourceServer</c>. Provide <c>null</c> to skip binding.
/// </param>
/// <param name="configure">Optional callback allowing additional mutation of <see cref="StellaOpsResourceServerOptions"/>.</param>
public static IServiceCollection AddStellaOpsResourceServerAuthentication(
this IServiceCollection services,
IConfiguration configuration,
string? configurationSection = "Authority:ResourceServer",
Action<StellaOpsResourceServerOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.AddHttpContextAccessor();
services.AddAuthorization();
services.AddStellaOpsScopeHandler();
services.TryAddSingleton<StellaOpsBypassEvaluator>();
services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System);
services.AddHttpClient(StellaOpsAuthorityConfigurationManager.HttpClientName);
services.AddSingleton<StellaOpsAuthorityConfigurationManager>();
var optionsBuilder = services.AddOptions<StellaOpsResourceServerOptions>();
if (!string.IsNullOrWhiteSpace(configurationSection))
{
optionsBuilder.Bind(configuration.GetSection(configurationSection));
}
if (configure is not null)
{
optionsBuilder.Configure(configure);
}
optionsBuilder.PostConfigure(static options => options.Validate());
var authenticationBuilder = services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme ??= StellaOpsAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme ??= StellaOpsAuthenticationDefaults.AuthenticationScheme;
});
authenticationBuilder.AddJwtBearer(StellaOpsAuthenticationDefaults.AuthenticationScheme);
services.AddOptions<JwtBearerOptions>(StellaOpsAuthenticationDefaults.AuthenticationScheme)
.Configure<IServiceProvider, IOptionsMonitor<StellaOpsResourceServerOptions>>((jwt, provider, monitor) =>
{
var resourceOptions = monitor.CurrentValue;
jwt.Authority = resourceOptions.AuthorityUri.ToString();
if (!string.IsNullOrWhiteSpace(resourceOptions.MetadataAddress))
{
jwt.MetadataAddress = resourceOptions.MetadataAddress;
}
jwt.RequireHttpsMetadata = resourceOptions.RequireHttpsMetadata;
jwt.BackchannelTimeout = resourceOptions.BackchannelTimeout;
jwt.MapInboundClaims = false;
jwt.SaveToken = false;
jwt.TokenValidationParameters ??= new TokenValidationParameters();
jwt.TokenValidationParameters.ValidIssuer = resourceOptions.AuthorityUri.ToString();
jwt.TokenValidationParameters.ValidateAudience = resourceOptions.Audiences.Count > 0;
jwt.TokenValidationParameters.ValidAudiences = resourceOptions.Audiences;
jwt.TokenValidationParameters.ClockSkew = resourceOptions.TokenClockSkew;
jwt.TokenValidationParameters.NameClaimType = ClaimTypes.Name;
jwt.TokenValidationParameters.RoleClaimType = ClaimTypes.Role;
jwt.ConfigurationManager = provider.GetRequiredService<StellaOpsAuthorityConfigurationManager>();
});
return services;
}
}
using System;
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Auth.ServerIntegration;
/// <summary>
/// Dependency injection helpers for configuring StellaOps resource server authentication.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers JWT bearer authentication and related authorisation helpers using the provided configuration section.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="configuration">Application configuration.</param>
/// <param name="configurationSection">
/// Optional configuration section path. Defaults to <c>Authority:ResourceServer</c>. Provide <c>null</c> to skip binding.
/// </param>
/// <param name="configure">Optional callback allowing additional mutation of <see cref="StellaOpsResourceServerOptions"/>.</param>
public static IServiceCollection AddStellaOpsResourceServerAuthentication(
this IServiceCollection services,
IConfiguration configuration,
string? configurationSection = "Authority:ResourceServer",
Action<StellaOpsResourceServerOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services.AddHttpContextAccessor();
services.AddAuthorization();
services.AddStellaOpsScopeHandler();
services.TryAddSingleton<StellaOpsBypassEvaluator>();
services.TryAddSingleton<TimeProvider>(_ => TimeProvider.System);
services.AddHttpClient(StellaOpsAuthorityConfigurationManager.HttpClientName);
services.AddSingleton<StellaOpsAuthorityConfigurationManager>();
var optionsBuilder = services.AddOptions<StellaOpsResourceServerOptions>();
if (!string.IsNullOrWhiteSpace(configurationSection))
{
optionsBuilder.Bind(configuration.GetSection(configurationSection));
}
if (configure is not null)
{
optionsBuilder.Configure(configure);
}
optionsBuilder.PostConfigure(static options => options.Validate());
var authenticationBuilder = services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme ??= StellaOpsAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme ??= StellaOpsAuthenticationDefaults.AuthenticationScheme;
});
authenticationBuilder.AddJwtBearer(StellaOpsAuthenticationDefaults.AuthenticationScheme);
services.AddOptions<JwtBearerOptions>(StellaOpsAuthenticationDefaults.AuthenticationScheme)
.Configure<IServiceProvider, IOptionsMonitor<StellaOpsResourceServerOptions>>((jwt, provider, monitor) =>
{
var resourceOptions = monitor.CurrentValue;
jwt.Authority = resourceOptions.AuthorityUri.ToString();
if (!string.IsNullOrWhiteSpace(resourceOptions.MetadataAddress))
{
jwt.MetadataAddress = resourceOptions.MetadataAddress;
}
jwt.RequireHttpsMetadata = resourceOptions.RequireHttpsMetadata;
jwt.BackchannelTimeout = resourceOptions.BackchannelTimeout;
jwt.MapInboundClaims = false;
jwt.SaveToken = false;
jwt.TokenValidationParameters ??= new TokenValidationParameters();
jwt.TokenValidationParameters.ValidIssuer = resourceOptions.AuthorityUri.ToString();
jwt.TokenValidationParameters.ValidateAudience = resourceOptions.Audiences.Count > 0;
jwt.TokenValidationParameters.ValidAudiences = resourceOptions.Audiences;
jwt.TokenValidationParameters.ClockSkew = resourceOptions.TokenClockSkew;
jwt.TokenValidationParameters.NameClaimType = ClaimTypes.Name;
jwt.TokenValidationParameters.RoleClaimType = ClaimTypes.Role;
jwt.ConfigurationManager = provider.GetRequiredService<StellaOpsAuthorityConfigurationManager>();
});
return services;
}
}

View File

@@ -1,116 +1,116 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.ServerIntegration;
/// <summary>
/// Cached configuration manager for StellaOps Authority metadata and JWKS.
/// </summary>
internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationManager<OpenIdConnectConfiguration>
{
internal const string HttpClientName = "StellaOps.Auth.ServerIntegration.Metadata";
private readonly IHttpClientFactory httpClientFactory;
private readonly IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor;
private readonly TimeProvider timeProvider;
private readonly ILogger<StellaOpsAuthorityConfigurationManager> logger;
private readonly SemaphoreSlim refreshLock = new(1, 1);
private OpenIdConnectConfiguration? cachedConfiguration;
private DateTimeOffset cacheExpiresAt;
public StellaOpsAuthorityConfigurationManager(
IHttpClientFactory httpClientFactory,
IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor,
TimeProvider timeProvider,
ILogger<StellaOpsAuthorityConfigurationManager> logger)
{
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<OpenIdConnectConfiguration> GetConfigurationAsync(CancellationToken cancellationToken)
{
var now = timeProvider.GetUtcNow();
var current = Volatile.Read(ref cachedConfiguration);
if (current is not null && now < cacheExpiresAt)
{
return current;
}
await refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (cachedConfiguration is not null && now < cacheExpiresAt)
{
return cachedConfiguration;
}
var options = optionsMonitor.CurrentValue;
var metadataAddress = ResolveMetadataAddress(options);
var httpClient = httpClientFactory.CreateClient(HttpClientName);
httpClient.Timeout = options.BackchannelTimeout;
var retriever = new HttpDocumentRetriever(httpClient)
{
RequireHttps = options.RequireHttpsMetadata
};
logger.LogDebug("Fetching OpenID Connect configuration from {MetadataAddress}.", metadataAddress);
var configuration = await OpenIdConnectConfigurationRetriever.GetAsync(metadataAddress, retriever, cancellationToken).ConfigureAwait(false);
configuration.Issuer ??= options.AuthorityUri.ToString();
if (!string.IsNullOrWhiteSpace(configuration.JwksUri))
{
logger.LogDebug("Fetching JWKS from {JwksUri}.", configuration.JwksUri);
var jwksDocument = await retriever.GetDocumentAsync(configuration.JwksUri, cancellationToken).ConfigureAwait(false);
var jsonWebKeySet = new JsonWebKeySet(jwksDocument);
configuration.SigningKeys.Clear();
foreach (JsonWebKey key in jsonWebKeySet.Keys)
{
configuration.SigningKeys.Add(key);
}
}
cachedConfiguration = configuration;
cacheExpiresAt = now + options.MetadataCacheLifetime;
return configuration;
}
finally
{
refreshLock.Release();
}
}
public void RequestRefresh()
{
Volatile.Write(ref cachedConfiguration, null);
cacheExpiresAt = DateTimeOffset.MinValue;
}
private static string ResolveMetadataAddress(StellaOpsResourceServerOptions options)
{
if (!string.IsNullOrWhiteSpace(options.MetadataAddress))
{
return options.MetadataAddress;
}
var authority = options.AuthorityUri;
if (!authority.AbsoluteUri.EndsWith("/", StringComparison.Ordinal))
{
authority = new Uri(authority.AbsoluteUri + "/", UriKind.Absolute);
}
return new Uri(authority, ".well-known/openid-configuration").AbsoluteUri;
}
}
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Protocols;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;
using Microsoft.IdentityModel.Tokens;
namespace StellaOps.Auth.ServerIntegration;
/// <summary>
/// Cached configuration manager for StellaOps Authority metadata and JWKS.
/// </summary>
internal sealed class StellaOpsAuthorityConfigurationManager : IConfigurationManager<OpenIdConnectConfiguration>
{
internal const string HttpClientName = "StellaOps.Auth.ServerIntegration.Metadata";
private readonly IHttpClientFactory httpClientFactory;
private readonly IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor;
private readonly TimeProvider timeProvider;
private readonly ILogger<StellaOpsAuthorityConfigurationManager> logger;
private readonly SemaphoreSlim refreshLock = new(1, 1);
private OpenIdConnectConfiguration? cachedConfiguration;
private DateTimeOffset cacheExpiresAt;
public StellaOpsAuthorityConfigurationManager(
IHttpClientFactory httpClientFactory,
IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor,
TimeProvider timeProvider,
ILogger<StellaOpsAuthorityConfigurationManager> logger)
{
this.httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor));
this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<OpenIdConnectConfiguration> GetConfigurationAsync(CancellationToken cancellationToken)
{
var now = timeProvider.GetUtcNow();
var current = Volatile.Read(ref cachedConfiguration);
if (current is not null && now < cacheExpiresAt)
{
return current;
}
await refreshLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (cachedConfiguration is not null && now < cacheExpiresAt)
{
return cachedConfiguration;
}
var options = optionsMonitor.CurrentValue;
var metadataAddress = ResolveMetadataAddress(options);
var httpClient = httpClientFactory.CreateClient(HttpClientName);
httpClient.Timeout = options.BackchannelTimeout;
var retriever = new HttpDocumentRetriever(httpClient)
{
RequireHttps = options.RequireHttpsMetadata
};
logger.LogDebug("Fetching OpenID Connect configuration from {MetadataAddress}.", metadataAddress);
var configuration = await OpenIdConnectConfigurationRetriever.GetAsync(metadataAddress, retriever, cancellationToken).ConfigureAwait(false);
configuration.Issuer ??= options.AuthorityUri.ToString();
if (!string.IsNullOrWhiteSpace(configuration.JwksUri))
{
logger.LogDebug("Fetching JWKS from {JwksUri}.", configuration.JwksUri);
var jwksDocument = await retriever.GetDocumentAsync(configuration.JwksUri, cancellationToken).ConfigureAwait(false);
var jsonWebKeySet = new JsonWebKeySet(jwksDocument);
configuration.SigningKeys.Clear();
foreach (JsonWebKey key in jsonWebKeySet.Keys)
{
configuration.SigningKeys.Add(key);
}
}
cachedConfiguration = configuration;
cacheExpiresAt = now + options.MetadataCacheLifetime;
return configuration;
}
finally
{
refreshLock.Release();
}
}
public void RequestRefresh()
{
Volatile.Write(ref cachedConfiguration, null);
cacheExpiresAt = DateTimeOffset.MinValue;
}
private static string ResolveMetadataAddress(StellaOpsResourceServerOptions options)
{
if (!string.IsNullOrWhiteSpace(options.MetadataAddress))
{
return options.MetadataAddress;
}
var authority = options.AuthorityUri;
if (!authority.AbsoluteUri.EndsWith("/", StringComparison.Ordinal))
{
authority = new Uri(authority.AbsoluteUri + "/", UriKind.Absolute);
}
return new Uri(authority, ".well-known/openid-configuration").AbsoluteUri;
}
}

View File

@@ -1,178 +1,178 @@
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Auth.ServerIntegration;
/// <summary>
/// Options controlling StellaOps resource server authentication.
/// </summary>
public sealed class StellaOpsResourceServerOptions
{
private readonly List<string> audiences = new();
private readonly List<string> requiredScopes = new();
private readonly List<string> requiredTenants = new();
private readonly List<string> bypassNetworks = new();
/// <summary>
/// Gets or sets the Authority (issuer) URL that exposes OpenID discovery.
/// </summary>
public string Authority { get; set; } = string.Empty;
/// <summary>
/// Optional explicit OpenID Connect metadata address.
/// </summary>
public string? MetadataAddress { get; set; }
/// <summary>
/// Audiences accepted by the resource server (validated against the <c>aud</c> claim).
/// </summary>
public IList<string> Audiences => audiences;
/// <summary>
/// Scopes enforced by default authorisation policies.
/// </summary>
public IList<string> RequiredScopes => requiredScopes;
/// <summary>
/// Tenants permitted to access the resource server (empty list disables tenant checks).
/// </summary>
public IList<string> RequiredTenants => requiredTenants;
/// <summary>
/// Networks permitted to bypass authentication (used for trusted on-host automation).
/// </summary>
public IList<string> BypassNetworks => bypassNetworks;
/// <summary>
/// Whether HTTPS metadata is required when communicating with Authority.
/// </summary>
public bool RequireHttpsMetadata { get; set; } = true;
/// <summary>
/// Back-channel timeout when fetching metadata/JWKS.
/// </summary>
public TimeSpan BackchannelTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Clock skew tolerated when validating tokens.
/// </summary>
public TimeSpan TokenClockSkew { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>
/// Lifetime for cached discovery/JWKS metadata before forcing a refresh.
/// </summary>
public TimeSpan MetadataCacheLifetime { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets the canonical Authority URI (populated during validation).
/// </summary>
public Uri AuthorityUri { get; private set; } = null!;
/// <summary>
/// Gets the normalised scope list (populated during validation).
/// </summary>
public IReadOnlyList<string> NormalizedScopes { get; private set; } = Array.Empty<string>();
/// <summary>
/// Gets the normalised tenant list (populated during validation).
/// </summary>
public IReadOnlyList<string> NormalizedTenants { get; private set; } = Array.Empty<string>();
/// <summary>
/// Gets the network matcher used for bypass checks (populated during validation).
/// </summary>
public NetworkMaskMatcher BypassMatcher { get; private set; } = NetworkMaskMatcher.DenyAll;
/// <summary>
/// Validates provided configuration and normalises collections.
/// </summary>
public void Validate()
{
if (string.IsNullOrWhiteSpace(Authority))
{
throw new InvalidOperationException("Resource server authentication requires an Authority URL.");
}
if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var authorityUri))
{
throw new InvalidOperationException("Resource server Authority URL must be an absolute URI.");
}
if (RequireHttpsMetadata &&
!authorityUri.IsLoopback &&
!string.Equals(authorityUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Resource server Authority URL must use HTTPS when HTTPS metadata is required.");
}
if (BackchannelTimeout <= TimeSpan.Zero)
{
throw new InvalidOperationException("Resource server back-channel timeout must be greater than zero.");
}
if (TokenClockSkew < TimeSpan.Zero || TokenClockSkew > TimeSpan.FromMinutes(5))
{
throw new InvalidOperationException("Resource server token clock skew must be between 0 seconds and 5 minutes.");
}
if (MetadataCacheLifetime <= TimeSpan.Zero || MetadataCacheLifetime > TimeSpan.FromHours(24))
{
throw new InvalidOperationException("Resource server metadata cache lifetime must be greater than zero and less than or equal to 24 hours.");
}
AuthorityUri = authorityUri;
NormalizeList(audiences, toLower: false);
NormalizeList(requiredScopes, toLower: true);
NormalizeList(requiredTenants, toLower: true);
NormalizeList(bypassNetworks, toLower: false);
NormalizedScopes = requiredScopes.Count == 0
? Array.Empty<string>()
: requiredScopes.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
NormalizedTenants = requiredTenants.Count == 0
? Array.Empty<string>()
: requiredTenants.OrderBy(static tenant => tenant, StringComparer.Ordinal).ToArray();
BypassMatcher = bypassNetworks.Count == 0
? NetworkMaskMatcher.DenyAll
: new NetworkMaskMatcher(bypassNetworks);
}
private static void NormalizeList(IList<string> values, bool toLower)
{
if (values.Count == 0)
{
return;
}
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var index = values.Count - 1; index >= 0; index--)
{
var value = values[index];
if (string.IsNullOrWhiteSpace(value))
{
values.RemoveAt(index);
continue;
}
var trimmed = value.Trim();
if (toLower)
{
trimmed = trimmed.ToLowerInvariant();
}
if (!seen.Add(trimmed))
{
values.RemoveAt(index);
continue;
}
values[index] = trimmed;
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Auth.ServerIntegration;
/// <summary>
/// Options controlling StellaOps resource server authentication.
/// </summary>
public sealed class StellaOpsResourceServerOptions
{
private readonly List<string> audiences = new();
private readonly List<string> requiredScopes = new();
private readonly List<string> requiredTenants = new();
private readonly List<string> bypassNetworks = new();
/// <summary>
/// Gets or sets the Authority (issuer) URL that exposes OpenID discovery.
/// </summary>
public string Authority { get; set; } = string.Empty;
/// <summary>
/// Optional explicit OpenID Connect metadata address.
/// </summary>
public string? MetadataAddress { get; set; }
/// <summary>
/// Audiences accepted by the resource server (validated against the <c>aud</c> claim).
/// </summary>
public IList<string> Audiences => audiences;
/// <summary>
/// Scopes enforced by default authorisation policies.
/// </summary>
public IList<string> RequiredScopes => requiredScopes;
/// <summary>
/// Tenants permitted to access the resource server (empty list disables tenant checks).
/// </summary>
public IList<string> RequiredTenants => requiredTenants;
/// <summary>
/// Networks permitted to bypass authentication (used for trusted on-host automation).
/// </summary>
public IList<string> BypassNetworks => bypassNetworks;
/// <summary>
/// Whether HTTPS metadata is required when communicating with Authority.
/// </summary>
public bool RequireHttpsMetadata { get; set; } = true;
/// <summary>
/// Back-channel timeout when fetching metadata/JWKS.
/// </summary>
public TimeSpan BackchannelTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Clock skew tolerated when validating tokens.
/// </summary>
public TimeSpan TokenClockSkew { get; set; } = TimeSpan.FromSeconds(60);
/// <summary>
/// Lifetime for cached discovery/JWKS metadata before forcing a refresh.
/// </summary>
public TimeSpan MetadataCacheLifetime { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>
/// Gets the canonical Authority URI (populated during validation).
/// </summary>
public Uri AuthorityUri { get; private set; } = null!;
/// <summary>
/// Gets the normalised scope list (populated during validation).
/// </summary>
public IReadOnlyList<string> NormalizedScopes { get; private set; } = Array.Empty<string>();
/// <summary>
/// Gets the normalised tenant list (populated during validation).
/// </summary>
public IReadOnlyList<string> NormalizedTenants { get; private set; } = Array.Empty<string>();
/// <summary>
/// Gets the network matcher used for bypass checks (populated during validation).
/// </summary>
public NetworkMaskMatcher BypassMatcher { get; private set; } = NetworkMaskMatcher.DenyAll;
/// <summary>
/// Validates provided configuration and normalises collections.
/// </summary>
public void Validate()
{
if (string.IsNullOrWhiteSpace(Authority))
{
throw new InvalidOperationException("Resource server authentication requires an Authority URL.");
}
if (!Uri.TryCreate(Authority.Trim(), UriKind.Absolute, out var authorityUri))
{
throw new InvalidOperationException("Resource server Authority URL must be an absolute URI.");
}
if (RequireHttpsMetadata &&
!authorityUri.IsLoopback &&
!string.Equals(authorityUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Resource server Authority URL must use HTTPS when HTTPS metadata is required.");
}
if (BackchannelTimeout <= TimeSpan.Zero)
{
throw new InvalidOperationException("Resource server back-channel timeout must be greater than zero.");
}
if (TokenClockSkew < TimeSpan.Zero || TokenClockSkew > TimeSpan.FromMinutes(5))
{
throw new InvalidOperationException("Resource server token clock skew must be between 0 seconds and 5 minutes.");
}
if (MetadataCacheLifetime <= TimeSpan.Zero || MetadataCacheLifetime > TimeSpan.FromHours(24))
{
throw new InvalidOperationException("Resource server metadata cache lifetime must be greater than zero and less than or equal to 24 hours.");
}
AuthorityUri = authorityUri;
NormalizeList(audiences, toLower: false);
NormalizeList(requiredScopes, toLower: true);
NormalizeList(requiredTenants, toLower: true);
NormalizeList(bypassNetworks, toLower: false);
NormalizedScopes = requiredScopes.Count == 0
? Array.Empty<string>()
: requiredScopes.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray();
NormalizedTenants = requiredTenants.Count == 0
? Array.Empty<string>()
: requiredTenants.OrderBy(static tenant => tenant, StringComparer.Ordinal).ToArray();
BypassMatcher = bypassNetworks.Count == 0
? NetworkMaskMatcher.DenyAll
: new NetworkMaskMatcher(bypassNetworks);
}
private static void NormalizeList(IList<string> values, bool toLower)
{
if (values.Count == 0)
{
return;
}
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
for (var index = values.Count - 1; index >= 0; index--)
{
var value = values[index];
if (string.IsNullOrWhiteSpace(value))
{
values.RemoveAt(index);
continue;
}
var trimmed = value.Trim();
if (toLower)
{
trimmed = trimmed.ToLowerInvariant();
}
if (!seen.Add(trimmed))
{
values.RemoveAt(index);
continue;
}
values[index] = trimmed;
}
}
}

View File

@@ -9,9 +9,9 @@ using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Tests.Fakes;
using StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Auth.Abstractions;
using Xunit;

View File

@@ -10,9 +10,9 @@ using StellaOps.Authority.Plugin.Ldap.Monitoring;
using StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;
using StellaOps.Authority.Plugin.Ldap.Tests.Fakes;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Storage.InMemory.Sessions;
using Xunit;
namespace StellaOps.Authority.Plugin.Ldap.Tests.Credentials;

View File

@@ -1,7 +1,7 @@
using System.Collections.Concurrent;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
namespace StellaOps.Authority.Plugin.Ldap.Tests.TestHelpers;

View File

@@ -5,12 +5,12 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Authority.InMemoryDriver;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Security;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Auth.Abstractions;
namespace StellaOps.Authority.Plugin.Ldap.ClientProvisioning;

View File

@@ -11,8 +11,8 @@ using StellaOps.Authority.Plugin.Ldap.ClientProvisioning;
using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Monitoring;
using StellaOps.Authority.Plugin.Ldap.Security;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Plugin.Ldap.Credentials;

View File

@@ -9,7 +9,7 @@ using StellaOps.Authority.Plugin.Ldap.Connections;
using StellaOps.Authority.Plugin.Ldap.Credentials;
using StellaOps.Authority.Plugin.Ldap.Monitoring;
using StellaOps.Authority.Plugin.Ldap.Security;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.InMemory.Stores;
namespace StellaOps.Authority.Plugin.Ldap;

View File

@@ -18,7 +18,7 @@
<ItemGroup>
<ProjectReference Include="..\\StellaOps.Authority.Plugins.Abstractions\\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\\StellaOps.Auth.Abstractions\\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\\StellaOps.Authority.Storage.Mongo\\StellaOps.Authority.Storage.Mongo.csproj" />
<ProjectReference Include="..\\StellaOps.Authority.Storage.InMemory\\StellaOps.Authority.Storage.InMemory.csproj" />
<ProjectReference Include="..\\..\\..\\__Libraries\\StellaOps.Plugin\\StellaOps.Plugin.csproj" />
<ProjectReference Include="..\\..\\__Libraries\\StellaOps.Authority.Storage.Postgres\\StellaOps.Authority.Storage.Postgres.csproj" />
</ItemGroup>

View File

@@ -1,183 +1,183 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MongoDB.Driver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using Xunit;
namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardClientProvisioningStoreTests
{
[Fact]
public async Task CreateOrUpdateAsync_HashesSecretAndPersistsDocument()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var registration = new AuthorityClientRegistration(
clientId: "bootstrap-client",
confidential: true,
displayName: "Bootstrap",
clientSecret: "SuperSecret1!",
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "scopeA" });
var result = await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.True(result.Succeeded);
Assert.True(store.Documents.TryGetValue("bootstrap-client", out var document));
Assert.NotNull(document);
Assert.Equal(AuthoritySecretHasher.ComputeHash("SuperSecret1!"), document!.SecretHash);
Assert.Equal("standard", document.Plugin);
var descriptor = await provisioning.FindByClientIdAsync("bootstrap-client", CancellationToken.None);
Assert.NotNull(descriptor);
Assert.Equal("bootstrap-client", descriptor!.ClientId);
Assert.True(descriptor.Confidential);
Assert.Contains("client_credentials", descriptor.AllowedGrantTypes);
Assert.Contains("scopea", descriptor.AllowedScopes);
}
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using StellaOps.Authority.InMemoryDriver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using Xunit;
namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardClientProvisioningStoreTests
{
[Fact]
public async Task CreateOrUpdateAsync_HashesSecretAndPersistsDocument()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var registration = new AuthorityClientRegistration(
clientId: "bootstrap-client",
confidential: true,
displayName: "Bootstrap",
clientSecret: "SuperSecret1!",
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "scopeA" });
var result = await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.True(result.Succeeded);
Assert.True(store.Documents.TryGetValue("bootstrap-client", out var document));
Assert.NotNull(document);
Assert.Equal(AuthoritySecretHasher.ComputeHash("SuperSecret1!"), document!.SecretHash);
Assert.Equal("standard", document.Plugin);
var descriptor = await provisioning.FindByClientIdAsync("bootstrap-client", CancellationToken.None);
Assert.NotNull(descriptor);
Assert.Equal("bootstrap-client", descriptor!.ClientId);
Assert.True(descriptor.Confidential);
Assert.Contains("client_credentials", descriptor.AllowedGrantTypes);
Assert.Contains("scopea", descriptor.AllowedScopes);
}
[Fact]
public async Task CreateOrUpdateAsync_NormalisesTenant()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var registration = new AuthorityClientRegistration(
clientId: "tenant-client",
confidential: false,
displayName: "Tenant Client",
clientSecret: null,
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "scopeA" },
tenant: " Tenant-Alpha " );
await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.True(store.Documents.TryGetValue("tenant-client", out var document));
Assert.NotNull(document);
Assert.Equal("tenant-alpha", document!.Properties[AuthorityClientMetadataKeys.Tenant]);
var descriptor = await provisioning.FindByClientIdAsync("tenant-client", CancellationToken.None);
Assert.NotNull(descriptor);
Assert.Equal("tenant-alpha", descriptor!.Tenant);
}
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var registration = new AuthorityClientRegistration(
clientId: "tenant-client",
confidential: false,
displayName: "Tenant Client",
clientSecret: null,
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "scopeA" },
tenant: " Tenant-Alpha " );
await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.True(store.Documents.TryGetValue("tenant-client", out var document));
Assert.NotNull(document);
Assert.Equal("tenant-alpha", document!.Properties[AuthorityClientMetadataKeys.Tenant]);
var descriptor = await provisioning.FindByClientIdAsync("tenant-client", CancellationToken.None);
Assert.NotNull(descriptor);
Assert.Equal("tenant-alpha", descriptor!.Tenant);
}
[Fact]
public async Task CreateOrUpdateAsync_StoresAudiences()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var registration = new AuthorityClientRegistration(
clientId: "signer",
confidential: false,
displayName: "Signer",
clientSecret: null,
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "signer.sign" },
allowedAudiences: new[] { "attestor", "signer" });
var result = await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.True(result.Succeeded);
Assert.True(store.Documents.TryGetValue("signer", out var document));
Assert.NotNull(document);
Assert.Equal("attestor signer", document!.Properties[AuthorityClientMetadataKeys.Audiences]);
var descriptor = await provisioning.FindByClientIdAsync("signer", CancellationToken.None);
Assert.NotNull(descriptor);
Assert.Equal(new[] { "attestor", "signer" }, descriptor!.AllowedAudiences.OrderBy(value => value, StringComparer.Ordinal));
}
[Fact]
public async Task CreateOrUpdateAsync_MapsCertificateBindings()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var bindingRegistration = new AuthorityClientCertificateBindingRegistration(
thumbprint: "aa:bb:cc:dd",
serialNumber: "01ff",
subject: "CN=mtls-client",
issuer: "CN=test-ca",
subjectAlternativeNames: new[] { "client.mtls.test", "spiffe://client" },
notBefore: DateTimeOffset.UtcNow.AddMinutes(-5),
notAfter: DateTimeOffset.UtcNow.AddHours(1),
label: "primary");
var registration = new AuthorityClientRegistration(
clientId: "mtls-client",
confidential: true,
displayName: "MTLS Client",
clientSecret: "secret",
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "signer.sign" },
allowedAudiences: new[] { "signer" },
certificateBindings: new[] { bindingRegistration });
await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.True(store.Documents.TryGetValue("mtls-client", out var document));
Assert.NotNull(document);
var binding = Assert.Single(document!.CertificateBindings);
Assert.Equal("AABBCCDD", binding.Thumbprint);
Assert.Equal("01ff", binding.SerialNumber);
Assert.Equal("CN=mtls-client", binding.Subject);
Assert.Equal("CN=test-ca", binding.Issuer);
Assert.Equal(new[] { "client.mtls.test", "spiffe://client" }, binding.SubjectAlternativeNames);
Assert.Equal(bindingRegistration.NotBefore, binding.NotBefore);
Assert.Equal(bindingRegistration.NotAfter, binding.NotAfter);
Assert.Equal("primary", binding.Label);
}
private sealed class TrackingClientStore : IAuthorityClientStore
{
public Dictionary<string, AuthorityClientDocument> Documents { get; } = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Documents.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Documents[document.ClientId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var removed = Documents.Remove(clientId);
return ValueTask.FromResult(removed);
}
}
private sealed class TrackingRevocationStore : IAuthorityRevocationStore
{
public List<AuthorityRevocationDocument> Upserts { get; } = new();
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Upserts.Add(document);
return ValueTask.CompletedTask;
}
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(true);
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
}
}
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var registration = new AuthorityClientRegistration(
clientId: "signer",
confidential: false,
displayName: "Signer",
clientSecret: null,
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "signer.sign" },
allowedAudiences: new[] { "attestor", "signer" });
var result = await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.True(result.Succeeded);
Assert.True(store.Documents.TryGetValue("signer", out var document));
Assert.NotNull(document);
Assert.Equal("attestor signer", document!.Properties[AuthorityClientMetadataKeys.Audiences]);
var descriptor = await provisioning.FindByClientIdAsync("signer", CancellationToken.None);
Assert.NotNull(descriptor);
Assert.Equal(new[] { "attestor", "signer" }, descriptor!.AllowedAudiences.OrderBy(value => value, StringComparer.Ordinal));
}
[Fact]
public async Task CreateOrUpdateAsync_MapsCertificateBindings()
{
var store = new TrackingClientStore();
var revocations = new TrackingRevocationStore();
var provisioning = new StandardClientProvisioningStore("standard", store, revocations, TimeProvider.System);
var bindingRegistration = new AuthorityClientCertificateBindingRegistration(
thumbprint: "aa:bb:cc:dd",
serialNumber: "01ff",
subject: "CN=mtls-client",
issuer: "CN=test-ca",
subjectAlternativeNames: new[] { "client.mtls.test", "spiffe://client" },
notBefore: DateTimeOffset.UtcNow.AddMinutes(-5),
notAfter: DateTimeOffset.UtcNow.AddHours(1),
label: "primary");
var registration = new AuthorityClientRegistration(
clientId: "mtls-client",
confidential: true,
displayName: "MTLS Client",
clientSecret: "secret",
allowedGrantTypes: new[] { "client_credentials" },
allowedScopes: new[] { "signer.sign" },
allowedAudiences: new[] { "signer" },
certificateBindings: new[] { bindingRegistration });
await provisioning.CreateOrUpdateAsync(registration, CancellationToken.None);
Assert.True(store.Documents.TryGetValue("mtls-client", out var document));
Assert.NotNull(document);
var binding = Assert.Single(document!.CertificateBindings);
Assert.Equal("AABBCCDD", binding.Thumbprint);
Assert.Equal("01ff", binding.SerialNumber);
Assert.Equal("CN=mtls-client", binding.Subject);
Assert.Equal("CN=test-ca", binding.Issuer);
Assert.Equal(new[] { "client.mtls.test", "spiffe://client" }, binding.SubjectAlternativeNames);
Assert.Equal(bindingRegistration.NotBefore, binding.NotBefore);
Assert.Equal(bindingRegistration.NotAfter, binding.NotAfter);
Assert.Equal("primary", binding.Label);
}
private sealed class TrackingClientStore : IAuthorityClientStore
{
public Dictionary<string, AuthorityClientDocument> Documents { get; } = new(StringComparer.OrdinalIgnoreCase);
public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Documents.TryGetValue(clientId, out var document);
return ValueTask.FromResult(document);
}
public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Documents[document.ClientId] = document;
return ValueTask.CompletedTask;
}
public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
var removed = Documents.Remove(clientId);
return ValueTask.FromResult(removed);
}
}
private sealed class TrackingRevocationStore : IAuthorityRevocationStore
{
public List<AuthorityRevocationDocument> Upserts { get; } = new();
public ValueTask UpsertAsync(AuthorityRevocationDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null)
{
Upserts.Add(document);
return ValueTask.CompletedTask;
}
public ValueTask<bool> RemoveAsync(string category, string revocationId, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult(true);
public ValueTask<IReadOnlyList<AuthorityRevocationDocument>> GetActiveAsync(DateTimeOffset asOf, CancellationToken cancellationToken, IClientSessionHandle? session = null)
=> ValueTask.FromResult<IReadOnlyList<AuthorityRevocationDocument>>(Array.Empty<AuthorityRevocationDocument>());
}
}

View File

@@ -8,13 +8,13 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using StellaOps.Authority.InMemoryDriver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard;
using StellaOps.Authority.Plugin.Standard.Bootstrap;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Cryptography.Audit;
namespace StellaOps.Authority.Plugin.Standard.Tests;
@@ -24,7 +24,7 @@ public class StandardPluginRegistrarTests
[Fact]
public async Task Register_ConfiguresIdentityProviderAndSeedsBootstrapUser()
{
var client = new InMemoryMongoClient();
var client = new InMemoryClient();
var database = client.GetDatabase("registrar-tests");
var configuration = new ConfigurationBuilder()
@@ -86,7 +86,7 @@ public class StandardPluginRegistrarTests
[Fact]
public void Register_LogsWarning_WhenPasswordPolicyWeaker()
{
var client = new InMemoryMongoClient();
var client = new InMemoryClient();
var database = client.GetDatabase("registrar-password-policy");
var configuration = new ConfigurationBuilder()
@@ -131,7 +131,7 @@ public class StandardPluginRegistrarTests
[Fact]
public void Register_ForcesPasswordCapability_WhenManifestMissing()
{
var client = new InMemoryMongoClient();
var client = new InMemoryClient();
var database = client.GetDatabase("registrar-capabilities");
var configuration = new ConfigurationBuilder().Build();
@@ -163,7 +163,7 @@ public class StandardPluginRegistrarTests
[Fact]
public void Register_Throws_WhenBootstrapConfigurationIncomplete()
{
var client = new InMemoryMongoClient();
var client = new InMemoryClient();
var database = client.GetDatabase("registrar-bootstrap-validation");
var configuration = new ConfigurationBuilder()
@@ -197,7 +197,7 @@ public class StandardPluginRegistrarTests
[Fact]
public void Register_NormalizesTokenSigningKeyDirectory()
{
var client = new InMemoryMongoClient();
var client = new InMemoryClient();
var database = client.GetDatabase("registrar-token-signing");
var configuration = new ConfigurationBuilder()
@@ -389,7 +389,7 @@ internal sealed class TestAuthEventSink : IAuthEventSink
internal static class StandardPluginRegistrarTestHelpers
{
public static ServiceCollection CreateServiceCollection(
IMongoDatabase database,
IDatabase database,
IAuthEventSink? authEventSink = null,
IAuthorityCredentialAuditContextAccessor? auditContextAccessor = null)
{

View File

@@ -5,7 +5,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using MongoDB.Driver;
using StellaOps.Authority.InMemoryDriver;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Authority.Plugin.Standard.Storage;
@@ -16,14 +16,14 @@ namespace StellaOps.Authority.Plugin.Standard.Tests;
public class StandardUserCredentialStoreTests : IAsyncLifetime
{
private readonly IMongoDatabase database;
private readonly IDatabase database;
private readonly StandardPluginOptions options;
private readonly StandardUserCredentialStore store;
private readonly TestAuditLogger auditLogger;
public StandardUserCredentialStoreTests()
{
var client = new InMemoryMongoClient();
var client = new InMemoryClient();
database = client.GetDatabase("authority-tests");
options = new StandardPluginOptions
{
@@ -171,9 +171,9 @@ public class StandardUserCredentialStoreTests : IAsyncLifetime
Assert.True(auditEntry.Success);
Assert.Equal("legacy", auditEntry.Username);
var updated = await database.GetCollection<StandardUserDocument>("authority_users_standard")
.Find(u => u.NormalizedUsername == "legacy")
.FirstOrDefaultAsync();
var results = await database.GetCollection<StandardUserDocument>("authority_users_standard")
.FindAsync(u => u.NormalizedUsername == "legacy");
var updated = results.FirstOrDefault();
Assert.NotNull(updated);
Assert.StartsWith("$argon2id$", updated!.PasswordHash, StringComparison.Ordinal);

View File

@@ -1,44 +1,44 @@
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Plugin.Standard.Storage;
namespace StellaOps.Authority.Plugin.Standard.Bootstrap;
internal sealed class StandardPluginBootstrapper : IHostedService
{
private readonly string pluginName;
private readonly IServiceScopeFactory scopeFactory;
private readonly ILogger<StandardPluginBootstrapper> logger;
public StandardPluginBootstrapper(
string pluginName,
IServiceScopeFactory scopeFactory,
ILogger<StandardPluginBootstrapper> logger)
{
this.pluginName = pluginName;
this.scopeFactory = scopeFactory;
this.logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = scopeFactory.CreateScope();
var optionsMonitor = scope.ServiceProvider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var credentialStore = scope.ServiceProvider.GetRequiredService<StandardUserCredentialStore>();
var options = optionsMonitor.Get(pluginName);
if (options.BootstrapUser is null || !options.BootstrapUser.IsConfigured)
{
return;
}
logger.LogInformation("Standard Authority plugin '{PluginName}' ensuring bootstrap user.", pluginName);
await credentialStore.EnsureBootstrapUserAsync(options.BootstrapUser, cancellationToken).ConfigureAwait(false);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Plugin.Standard.Storage;
namespace StellaOps.Authority.Plugin.Standard.Bootstrap;
internal sealed class StandardPluginBootstrapper : IHostedService
{
private readonly string pluginName;
private readonly IServiceScopeFactory scopeFactory;
private readonly ILogger<StandardPluginBootstrapper> logger;
public StandardPluginBootstrapper(
string pluginName,
IServiceScopeFactory scopeFactory,
ILogger<StandardPluginBootstrapper> logger)
{
this.pluginName = pluginName;
this.scopeFactory = scopeFactory;
this.logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
using var scope = scopeFactory.CreateScope();
var optionsMonitor = scope.ServiceProvider.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var credentialStore = scope.ServiceProvider.GetRequiredService<StandardUserCredentialStore>();
var options = optionsMonitor.Get(pluginName);
if (options.BootstrapUser is null || !options.BootstrapUser.IsConfigured)
{
return;
}
logger.LogInformation("Standard Authority plugin '{PluginName}' ensuring bootstrap user.", pluginName);
await credentialStore.EnsureBootstrapUserAsync(options.BootstrapUser, cancellationToken).ConfigureAwait(false);
}
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}

View File

@@ -1,122 +1,122 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Bootstrap;
using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.Postgres.Repositories;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
namespace StellaOps.Authority.Plugin.Standard;
internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
{
private const string DefaultTenantId = "default";
public string PluginType => "standard";
public void Register(AuthorityPluginRegistrationContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
var pluginName = context.Plugin.Manifest.Name;
context.Services.AddSingleton<StandardClaimsEnricher>();
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
context.Services.AddStellaOpsCrypto();
var configPath = context.Plugin.Manifest.ConfigPath;
context.Services.AddOptions<StandardPluginOptions>(pluginName)
.Bind(context.Plugin.Configuration)
.PostConfigure(options =>
{
options.Normalize(configPath);
options.Validate(pluginName);
})
.ValidateOnStart();
context.Services.AddScoped<IStandardCredentialAuditLogger, StandardCredentialAuditLogger>();
context.Services.AddScoped(sp =>
{
var userRepository = sp.GetRequiredService<IUserRepository>();
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var pluginOptions = optionsMonitor.Get(pluginName);
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
var baselinePolicy = new PasswordPolicyOptions();
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
{
registrarLogger.LogWarning(
"Standard plugin '{Plugin}' configured a weaker password policy (minLength={Length}, requireUpper={Upper}, requireLower={Lower}, requireDigit={Digit}, requireSymbol={Symbol}) than the baseline (minLength={BaseLength}, requireUpper={BaseUpper}, requireLower={BaseLower}, requireDigit={BaseDigit}, requireSymbol={BaseSymbol}).",
pluginName,
pluginOptions.PasswordPolicy.MinimumLength,
pluginOptions.PasswordPolicy.RequireUppercase,
pluginOptions.PasswordPolicy.RequireLowercase,
pluginOptions.PasswordPolicy.RequireDigit,
pluginOptions.PasswordPolicy.RequireSymbol,
baselinePolicy.MinimumLength,
baselinePolicy.RequireUppercase,
baselinePolicy.RequireLowercase,
baselinePolicy.RequireDigit,
baselinePolicy.RequireSymbol);
}
// Use tenant from options or default
var tenantId = pluginOptions.TenantId ?? DefaultTenantId;
return new StandardUserCredentialStore(
pluginName,
tenantId,
userRepository,
pluginOptions,
passwordHasher,
auditLogger,
loggerFactory.CreateLogger<StandardUserCredentialStore>());
});
context.Services.AddScoped(sp =>
{
var clientStore = sp.GetRequiredService<IAuthorityClientStore>();
var revocationStore = sp.GetRequiredService<IAuthorityRevocationStore>();
var timeProvider = sp.GetRequiredService<TimeProvider>();
return new StandardClientProvisioningStore(pluginName, clientStore, revocationStore, timeProvider);
});
context.Services.AddScoped<IIdentityProviderPlugin>(sp =>
{
var store = sp.GetRequiredService<StandardUserCredentialStore>();
var clientProvisioningStore = sp.GetRequiredService<StandardClientProvisioningStore>();
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
return new StandardIdentityProviderPlugin(
context.Plugin,
store,
clientProvisioningStore,
sp.GetRequiredService<StandardClaimsEnricher>(),
loggerFactory.CreateLogger<StandardIdentityProviderPlugin>());
});
context.Services.AddScoped<IClientProvisioningStore>(sp =>
sp.GetRequiredService<StandardClientProvisioningStore>());
context.Services.AddSingleton<IHostedService>(sp =>
new StandardPluginBootstrapper(
pluginName,
sp.GetRequiredService<IServiceScopeFactory>(),
sp.GetRequiredService<ILogger<StandardPluginBootstrapper>>()));
}
}
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Plugin.Standard.Bootstrap;
using StellaOps.Authority.Plugin.Standard.Security;
using StellaOps.Authority.Plugin.Standard.Storage;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Storage.Postgres.Repositories;
using StellaOps.Cryptography;
using StellaOps.Cryptography.DependencyInjection;
namespace StellaOps.Authority.Plugin.Standard;
internal sealed class StandardPluginRegistrar : IAuthorityPluginRegistrar
{
private const string DefaultTenantId = "default";
public string PluginType => "standard";
public void Register(AuthorityPluginRegistrationContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
var pluginName = context.Plugin.Manifest.Name;
context.Services.AddSingleton<StandardClaimsEnricher>();
context.Services.AddSingleton<IClaimsEnricher>(sp => sp.GetRequiredService<StandardClaimsEnricher>());
context.Services.AddStellaOpsCrypto();
var configPath = context.Plugin.Manifest.ConfigPath;
context.Services.AddOptions<StandardPluginOptions>(pluginName)
.Bind(context.Plugin.Configuration)
.PostConfigure(options =>
{
options.Normalize(configPath);
options.Validate(pluginName);
})
.ValidateOnStart();
context.Services.AddScoped<IStandardCredentialAuditLogger, StandardCredentialAuditLogger>();
context.Services.AddScoped(sp =>
{
var userRepository = sp.GetRequiredService<IUserRepository>();
var optionsMonitor = sp.GetRequiredService<IOptionsMonitor<StandardPluginOptions>>();
var pluginOptions = optionsMonitor.Get(pluginName);
var cryptoProvider = sp.GetRequiredService<ICryptoProvider>();
var passwordHasher = new CryptoPasswordHasher(pluginOptions, cryptoProvider);
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var registrarLogger = loggerFactory.CreateLogger<StandardPluginRegistrar>();
var auditLogger = sp.GetRequiredService<IStandardCredentialAuditLogger>();
var baselinePolicy = new PasswordPolicyOptions();
if (pluginOptions.PasswordPolicy.IsWeakerThan(baselinePolicy))
{
registrarLogger.LogWarning(
"Standard plugin '{Plugin}' configured a weaker password policy (minLength={Length}, requireUpper={Upper}, requireLower={Lower}, requireDigit={Digit}, requireSymbol={Symbol}) than the baseline (minLength={BaseLength}, requireUpper={BaseUpper}, requireLower={BaseLower}, requireDigit={BaseDigit}, requireSymbol={BaseSymbol}).",
pluginName,
pluginOptions.PasswordPolicy.MinimumLength,
pluginOptions.PasswordPolicy.RequireUppercase,
pluginOptions.PasswordPolicy.RequireLowercase,
pluginOptions.PasswordPolicy.RequireDigit,
pluginOptions.PasswordPolicy.RequireSymbol,
baselinePolicy.MinimumLength,
baselinePolicy.RequireUppercase,
baselinePolicy.RequireLowercase,
baselinePolicy.RequireDigit,
baselinePolicy.RequireSymbol);
}
// Use tenant from options or default
var tenantId = pluginOptions.TenantId ?? DefaultTenantId;
return new StandardUserCredentialStore(
pluginName,
tenantId,
userRepository,
pluginOptions,
passwordHasher,
auditLogger,
loggerFactory.CreateLogger<StandardUserCredentialStore>());
});
context.Services.AddScoped(sp =>
{
var clientStore = sp.GetRequiredService<IAuthorityClientStore>();
var revocationStore = sp.GetRequiredService<IAuthorityRevocationStore>();
var timeProvider = sp.GetRequiredService<TimeProvider>();
return new StandardClientProvisioningStore(pluginName, clientStore, revocationStore, timeProvider);
});
context.Services.AddScoped<IIdentityProviderPlugin>(sp =>
{
var store = sp.GetRequiredService<StandardUserCredentialStore>();
var clientProvisioningStore = sp.GetRequiredService<StandardClientProvisioningStore>();
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
return new StandardIdentityProviderPlugin(
context.Plugin,
store,
clientProvisioningStore,
sp.GetRequiredService<StandardClaimsEnricher>(),
loggerFactory.CreateLogger<StandardIdentityProviderPlugin>());
});
context.Services.AddScoped<IClientProvisioningStore>(sp =>
sp.GetRequiredService<StandardClientProvisioningStore>());
context.Services.AddSingleton<IHostedService>(sp =>
new StandardPluginBootstrapper(
pluginName,
sp.GetRequiredService<IServiceScopeFactory>(),
sp.GetRequiredService<ILogger<StandardPluginBootstrapper>>()));
}
}

View File

@@ -16,7 +16,7 @@
<ItemGroup>
<ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" />
<ProjectReference Include="..\StellaOps.Authority.Storage.InMemory\StellaOps.Authority.Storage.InMemory.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
<ProjectReference Include="../../__Libraries/StellaOps.Authority.Storage.Postgres/StellaOps.Authority.Storage.Postgres.csproj" />
<ProjectReference Include="../../../__Libraries/StellaOps.Cryptography/StellaOps.Cryptography.csproj" />

View File

@@ -1,70 +1,70 @@
using System.Collections.Generic;
using System.Linq;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.Plugin.Standard.Storage;
internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
{
private readonly string pluginName;
private readonly IAuthorityClientStore clientStore;
private readonly IAuthorityRevocationStore revocationStore;
private readonly TimeProvider clock;
public StandardClientProvisioningStore(
string pluginName,
IAuthorityClientStore clientStore,
IAuthorityRevocationStore revocationStore,
TimeProvider clock)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.revocationStore = revocationStore ?? throw new ArgumentNullException(nameof(revocationStore));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
}
public async ValueTask<AuthorityPluginOperationResult<AuthorityClientDescriptor>> CreateOrUpdateAsync(
AuthorityClientRegistration registration,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(registration);
if (registration.Confidential && string.IsNullOrWhiteSpace(registration.ClientSecret))
{
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Failure("secret_required", "Confidential clients require a client secret.");
}
var document = await clientStore.FindByClientIdAsync(registration.ClientId, cancellationToken).ConfigureAwait(false)
?? new AuthorityClientDocument { ClientId = registration.ClientId, CreatedAt = clock.GetUtcNow() };
document.Plugin = pluginName;
document.ClientType = registration.Confidential ? "confidential" : "public";
document.DisplayName = registration.DisplayName;
document.SecretHash = registration.Confidential && registration.ClientSecret is not null
? AuthoritySecretHasher.ComputeHash(registration.ClientSecret)
: null;
document.UpdatedAt = clock.GetUtcNow();
document.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList();
document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList();
document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = JoinValues(registration.AllowedGrantTypes);
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = JoinValues(registration.AllowedScopes);
document.Properties[AuthorityClientMetadataKeys.Audiences] = JoinValues(registration.AllowedAudiences);
document.Properties[AuthorityClientMetadataKeys.RedirectUris] = string.Join(" ", document.RedirectUris);
document.Properties[AuthorityClientMetadataKeys.PostLogoutRedirectUris] = string.Join(" ", document.PostLogoutRedirectUris);
if (registration.CertificateBindings is not null)
{
var now = clock.GetUtcNow();
document.CertificateBindings = registration.CertificateBindings
.Select(binding => MapCertificateBinding(binding, now))
.OrderBy(binding => binding.Thumbprint, StringComparer.Ordinal)
.ToList();
}
using System.Collections.Generic;
using System.Linq;
using StellaOps.Authority.Plugins.Abstractions;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
namespace StellaOps.Authority.Plugin.Standard.Storage;
internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
{
private readonly string pluginName;
private readonly IAuthorityClientStore clientStore;
private readonly IAuthorityRevocationStore revocationStore;
private readonly TimeProvider clock;
public StandardClientProvisioningStore(
string pluginName,
IAuthorityClientStore clientStore,
IAuthorityRevocationStore revocationStore,
TimeProvider clock)
{
this.pluginName = pluginName ?? throw new ArgumentNullException(nameof(pluginName));
this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore));
this.revocationStore = revocationStore ?? throw new ArgumentNullException(nameof(revocationStore));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
}
public async ValueTask<AuthorityPluginOperationResult<AuthorityClientDescriptor>> CreateOrUpdateAsync(
AuthorityClientRegistration registration,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(registration);
if (registration.Confidential && string.IsNullOrWhiteSpace(registration.ClientSecret))
{
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Failure("secret_required", "Confidential clients require a client secret.");
}
var document = await clientStore.FindByClientIdAsync(registration.ClientId, cancellationToken).ConfigureAwait(false)
?? new AuthorityClientDocument { ClientId = registration.ClientId, CreatedAt = clock.GetUtcNow() };
document.Plugin = pluginName;
document.ClientType = registration.Confidential ? "confidential" : "public";
document.DisplayName = registration.DisplayName;
document.SecretHash = registration.Confidential && registration.ClientSecret is not null
? AuthoritySecretHasher.ComputeHash(registration.ClientSecret)
: null;
document.UpdatedAt = clock.GetUtcNow();
document.RedirectUris = registration.RedirectUris.Select(static uri => uri.ToString()).ToList();
document.PostLogoutRedirectUris = registration.PostLogoutRedirectUris.Select(static uri => uri.ToString()).ToList();
document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = JoinValues(registration.AllowedGrantTypes);
document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = JoinValues(registration.AllowedScopes);
document.Properties[AuthorityClientMetadataKeys.Audiences] = JoinValues(registration.AllowedAudiences);
document.Properties[AuthorityClientMetadataKeys.RedirectUris] = string.Join(" ", document.RedirectUris);
document.Properties[AuthorityClientMetadataKeys.PostLogoutRedirectUris] = string.Join(" ", document.PostLogoutRedirectUris);
if (registration.CertificateBindings is not null)
{
var now = clock.GetUtcNow();
document.CertificateBindings = registration.CertificateBindings
.Select(binding => MapCertificateBinding(binding, now))
.OrderBy(binding => binding.Thumbprint, StringComparer.Ordinal)
.ToList();
}
foreach (var (key, value) in registration.Properties)
{
document.Properties[key] = value;
@@ -79,113 +79,113 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
{
document.Properties.Remove(AuthorityClientMetadataKeys.Tenant);
}
if (registration.Properties.TryGetValue(AuthorityClientMetadataKeys.SenderConstraint, out var senderConstraintRaw))
{
var normalizedConstraint = NormalizeSenderConstraint(senderConstraintRaw);
if (normalizedConstraint is not null)
{
document.SenderConstraint = normalizedConstraint;
document.Properties[AuthorityClientMetadataKeys.SenderConstraint] = normalizedConstraint;
}
else
{
document.SenderConstraint = null;
document.Properties.Remove(AuthorityClientMetadataKeys.SenderConstraint);
}
}
await clientStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
await revocationStore.RemoveAsync("client", registration.ClientId, cancellationToken).ConfigureAwait(false);
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Success(ToDescriptor(document));
}
public async ValueTask<AuthorityClientDescriptor?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
{
var document = await clientStore.FindByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
return document is null ? null : ToDescriptor(document);
}
public async ValueTask<AuthorityPluginOperationResult> DeleteAsync(string clientId, CancellationToken cancellationToken)
{
var deleted = await clientStore.DeleteByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
if (!deleted)
{
return AuthorityPluginOperationResult.Failure("not_found", "Client was not found.");
}
var now = clock.GetUtcNow();
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["plugin"] = pluginName
};
var revocation = new AuthorityRevocationDocument
{
Category = "client",
RevocationId = clientId,
ClientId = clientId,
Reason = "operator_request",
ReasonDescription = $"Client '{clientId}' deleted via plugin '{pluginName}'.",
RevokedAt = now,
EffectiveAt = now,
Metadata = metadata
};
try
{
await revocationStore.UpsertAsync(revocation, cancellationToken).ConfigureAwait(false);
}
catch
{
// Revocation export should proceed even if the metadata write fails.
}
return AuthorityPluginOperationResult.Success();
}
private static AuthorityClientDescriptor ToDescriptor(AuthorityClientDocument document)
{
var allowedGrantTypes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedGrantTypes);
var allowedScopes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedScopes);
var redirectUris = document.RedirectUris
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
.Where(static uri => uri is not null)
.Cast<Uri>()
.ToArray();
var postLogoutUris = document.PostLogoutRedirectUris
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
.Where(static uri => uri is not null)
.Cast<Uri>()
.ToArray();
var audiences = Split(document.Properties, AuthorityClientMetadataKeys.Audiences);
return new AuthorityClientDescriptor(
document.ClientId,
document.DisplayName,
string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase),
allowedGrantTypes,
allowedScopes,
audiences,
redirectUris,
postLogoutUris,
document.Properties);
}
private static IReadOnlyCollection<string> Split(IReadOnlyDictionary<string, string?> properties, string key)
{
if (!properties.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
{
return Array.Empty<string>();
}
return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
if (registration.Properties.TryGetValue(AuthorityClientMetadataKeys.SenderConstraint, out var senderConstraintRaw))
{
var normalizedConstraint = NormalizeSenderConstraint(senderConstraintRaw);
if (normalizedConstraint is not null)
{
document.SenderConstraint = normalizedConstraint;
document.Properties[AuthorityClientMetadataKeys.SenderConstraint] = normalizedConstraint;
}
else
{
document.SenderConstraint = null;
document.Properties.Remove(AuthorityClientMetadataKeys.SenderConstraint);
}
}
await clientStore.UpsertAsync(document, cancellationToken).ConfigureAwait(false);
await revocationStore.RemoveAsync("client", registration.ClientId, cancellationToken).ConfigureAwait(false);
return AuthorityPluginOperationResult<AuthorityClientDescriptor>.Success(ToDescriptor(document));
}
public async ValueTask<AuthorityClientDescriptor?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken)
{
var document = await clientStore.FindByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
return document is null ? null : ToDescriptor(document);
}
public async ValueTask<AuthorityPluginOperationResult> DeleteAsync(string clientId, CancellationToken cancellationToken)
{
var deleted = await clientStore.DeleteByClientIdAsync(clientId, cancellationToken).ConfigureAwait(false);
if (!deleted)
{
return AuthorityPluginOperationResult.Failure("not_found", "Client was not found.");
}
var now = clock.GetUtcNow();
var metadata = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase)
{
["plugin"] = pluginName
};
var revocation = new AuthorityRevocationDocument
{
Category = "client",
RevocationId = clientId,
ClientId = clientId,
Reason = "operator_request",
ReasonDescription = $"Client '{clientId}' deleted via plugin '{pluginName}'.",
RevokedAt = now,
EffectiveAt = now,
Metadata = metadata
};
try
{
await revocationStore.UpsertAsync(revocation, cancellationToken).ConfigureAwait(false);
}
catch
{
// Revocation export should proceed even if the metadata write fails.
}
return AuthorityPluginOperationResult.Success();
}
private static AuthorityClientDescriptor ToDescriptor(AuthorityClientDocument document)
{
var allowedGrantTypes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedGrantTypes);
var allowedScopes = Split(document.Properties, AuthorityClientMetadataKeys.AllowedScopes);
var redirectUris = document.RedirectUris
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
.Where(static uri => uri is not null)
.Cast<Uri>()
.ToArray();
var postLogoutUris = document.PostLogoutRedirectUris
.Select(static value => Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : null)
.Where(static uri => uri is not null)
.Cast<Uri>()
.ToArray();
var audiences = Split(document.Properties, AuthorityClientMetadataKeys.Audiences);
return new AuthorityClientDescriptor(
document.ClientId,
document.DisplayName,
string.Equals(document.ClientType, "confidential", StringComparison.OrdinalIgnoreCase),
allowedGrantTypes,
allowedScopes,
audiences,
redirectUris,
postLogoutUris,
document.Properties);
}
private static IReadOnlyCollection<string> Split(IReadOnlyDictionary<string, string?> properties, string key)
{
if (!properties.TryGetValue(key, out var value) || string.IsNullOrWhiteSpace(value))
{
return Array.Empty<string>();
}
return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static string JoinValues(IReadOnlyCollection<string> values)
{
if (values is null || values.Count == 0)
@@ -207,42 +207,42 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore
private static AuthorityClientCertificateBinding MapCertificateBinding(
AuthorityClientCertificateBindingRegistration registration,
DateTimeOffset now)
{
var subjectAlternativeNames = registration.SubjectAlternativeNames.Count == 0
? new List<string>()
: registration.SubjectAlternativeNames
.Select(name => name.Trim())
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
return new AuthorityClientCertificateBinding
{
Thumbprint = registration.Thumbprint,
SerialNumber = registration.SerialNumber,
Subject = registration.Subject,
Issuer = registration.Issuer,
SubjectAlternativeNames = subjectAlternativeNames,
NotBefore = registration.NotBefore,
NotAfter = registration.NotAfter,
Label = registration.Label,
CreatedAt = now,
UpdatedAt = now
};
}
private static string? NormalizeSenderConstraint(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim() switch
{
{ Length: 0 } => null,
var constraint when string.Equals(constraint, "dpop", StringComparison.OrdinalIgnoreCase) => "dpop",
var constraint when string.Equals(constraint, "mtls", StringComparison.OrdinalIgnoreCase) => "mtls",
_ => null
};
}
}
{
var subjectAlternativeNames = registration.SubjectAlternativeNames.Count == 0
? new List<string>()
: registration.SubjectAlternativeNames
.Select(name => name.Trim())
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
return new AuthorityClientCertificateBinding
{
Thumbprint = registration.Thumbprint,
SerialNumber = registration.SerialNumber,
Subject = registration.Subject,
Issuer = registration.Issuer,
SubjectAlternativeNames = subjectAlternativeNames,
NotBefore = registration.NotBefore,
NotAfter = registration.NotAfter,
Label = registration.Label,
CreatedAt = now,
UpdatedAt = now
};
}
private static string? NormalizeSenderConstraint(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return null;
}
return value.Trim() switch
{
{ Length: 0 } => null,
var constraint when string.Equals(constraint, "dpop", StringComparison.OrdinalIgnoreCase) => "dpop",
var constraint when string.Equals(constraint, "mtls", StringComparison.OrdinalIgnoreCase) => "mtls",
_ => null
};
}
}

View File

@@ -1,32 +1,32 @@
using System;
using StellaOps.Authority.Plugins.Abstractions;
namespace StellaOps.Authority.Plugins.Abstractions.Tests;
public class AuthorityClientRegistrationTests
{
[Fact]
public void Constructor_Throws_WhenClientIdMissing()
{
Assert.Throws<ArgumentException>(() => new AuthorityClientRegistration(string.Empty, false, null, null));
}
[Fact]
public void Constructor_RequiresSecret_ForConfidentialClients()
{
Assert.Throws<ArgumentException>(() => new AuthorityClientRegistration("cli", true, null, null));
}
[Fact]
public void WithClientSecret_ReturnsCopy()
{
var registration = new AuthorityClientRegistration("cli", false, null, null, tenant: "Tenant-Alpha");
var updated = registration.WithClientSecret("secret");
Assert.Equal("cli", updated.ClientId);
Assert.Equal("secret", updated.ClientSecret);
Assert.False(updated.Confidential);
Assert.Equal("tenant-alpha", updated.Tenant);
}
}
using System;
using StellaOps.Authority.Plugins.Abstractions;
namespace StellaOps.Authority.Plugins.Abstractions.Tests;
public class AuthorityClientRegistrationTests
{
[Fact]
public void Constructor_Throws_WhenClientIdMissing()
{
Assert.Throws<ArgumentException>(() => new AuthorityClientRegistration(string.Empty, false, null, null));
}
[Fact]
public void Constructor_RequiresSecret_ForConfidentialClients()
{
Assert.Throws<ArgumentException>(() => new AuthorityClientRegistration("cli", true, null, null));
}
[Fact]
public void WithClientSecret_ReturnsCopy()
{
var registration = new AuthorityClientRegistration("cli", false, null, null, tenant: "Tenant-Alpha");
var updated = registration.WithClientSecret("secret");
Assert.Equal("cli", updated.ClientId);
Assert.Equal("secret", updated.ClientSecret);
Assert.False(updated.Confidential);
Assert.Equal("tenant-alpha", updated.Tenant);
}
}

View File

@@ -1,117 +1,117 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Authority.Plugins.Abstractions;
/// <summary>
/// Well-known Authority plugin capability identifiers.
/// </summary>
public static class AuthorityPluginCapabilities
{
public const string Password = "password";
public const string Bootstrap = "bootstrap";
public const string Mfa = "mfa";
public const string ClientProvisioning = "clientProvisioning";
}
/// <summary>
/// Immutable description of an Authority plugin loaded from configuration.
/// </summary>
/// <param name="Name">Logical name derived from configuration key.</param>
/// <param name="Type">Plugin type identifier (used for capability routing).</param>
/// <param name="Enabled">Whether the plugin is enabled.</param>
/// <param name="AssemblyName">Assembly name without extension.</param>
/// <param name="AssemblyPath">Explicit assembly path override.</param>
/// <param name="Capabilities">Capability hints exposed by the plugin.</param>
/// <param name="Metadata">Additional metadata forwarded to plugin implementations.</param>
/// <param name="ConfigPath">Absolute path to the plugin configuration manifest.</param>
public sealed record AuthorityPluginManifest(
string Name,
string Type,
bool Enabled,
string? AssemblyName,
string? AssemblyPath,
IReadOnlyList<string> Capabilities,
IReadOnlyDictionary<string, string?> Metadata,
string ConfigPath)
{
/// <summary>
/// Determines whether the manifest declares the specified capability.
/// </summary>
/// <param name="capability">Capability identifier to check.</param>
public bool HasCapability(string capability)
{
if (string.IsNullOrWhiteSpace(capability))
{
return false;
}
foreach (var entry in Capabilities)
{
if (string.Equals(entry, capability, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}
/// <summary>
/// Runtime context combining plugin manifest metadata and its bound configuration.
/// </summary>
/// <param name="Manifest">Manifest describing the plugin.</param>
/// <param name="Configuration">Root configuration built from the plugin YAML manifest.</param>
public sealed record AuthorityPluginContext(
AuthorityPluginManifest Manifest,
IConfiguration Configuration);
/// <summary>
/// Registry exposing the set of Authority plugins loaded at runtime.
/// </summary>
public interface IAuthorityPluginRegistry
{
IReadOnlyCollection<AuthorityPluginContext> Plugins { get; }
bool TryGet(string name, [NotNullWhen(true)] out AuthorityPluginContext? context);
AuthorityPluginContext GetRequired(string name)
{
if (TryGet(name, out var context))
{
return context;
}
throw new KeyNotFoundException($"Authority plugin '{name}' is not registered.");
}
}
/// <summary>
/// Registry exposing loaded identity provider plugins and their capabilities.
/// </summary>
public interface IAuthorityIdentityProviderRegistry
{
/// <summary>
/// Gets metadata for all registered identity provider plugins.
/// </summary>
IReadOnlyCollection<AuthorityIdentityProviderMetadata> Providers { get; }
/// <summary>
/// Gets metadata for identity providers that advertise password support.
/// </summary>
IReadOnlyCollection<AuthorityIdentityProviderMetadata> PasswordProviders { get; }
/// <summary>
/// Gets metadata for identity providers that advertise multi-factor authentication support.
/// </summary>
IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders { get; }
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace StellaOps.Authority.Plugins.Abstractions;
/// <summary>
/// Well-known Authority plugin capability identifiers.
/// </summary>
public static class AuthorityPluginCapabilities
{
public const string Password = "password";
public const string Bootstrap = "bootstrap";
public const string Mfa = "mfa";
public const string ClientProvisioning = "clientProvisioning";
}
/// <summary>
/// Immutable description of an Authority plugin loaded from configuration.
/// </summary>
/// <param name="Name">Logical name derived from configuration key.</param>
/// <param name="Type">Plugin type identifier (used for capability routing).</param>
/// <param name="Enabled">Whether the plugin is enabled.</param>
/// <param name="AssemblyName">Assembly name without extension.</param>
/// <param name="AssemblyPath">Explicit assembly path override.</param>
/// <param name="Capabilities">Capability hints exposed by the plugin.</param>
/// <param name="Metadata">Additional metadata forwarded to plugin implementations.</param>
/// <param name="ConfigPath">Absolute path to the plugin configuration manifest.</param>
public sealed record AuthorityPluginManifest(
string Name,
string Type,
bool Enabled,
string? AssemblyName,
string? AssemblyPath,
IReadOnlyList<string> Capabilities,
IReadOnlyDictionary<string, string?> Metadata,
string ConfigPath)
{
/// <summary>
/// Determines whether the manifest declares the specified capability.
/// </summary>
/// <param name="capability">Capability identifier to check.</param>
public bool HasCapability(string capability)
{
if (string.IsNullOrWhiteSpace(capability))
{
return false;
}
foreach (var entry in Capabilities)
{
if (string.Equals(entry, capability, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}
/// <summary>
/// Runtime context combining plugin manifest metadata and its bound configuration.
/// </summary>
/// <param name="Manifest">Manifest describing the plugin.</param>
/// <param name="Configuration">Root configuration built from the plugin YAML manifest.</param>
public sealed record AuthorityPluginContext(
AuthorityPluginManifest Manifest,
IConfiguration Configuration);
/// <summary>
/// Registry exposing the set of Authority plugins loaded at runtime.
/// </summary>
public interface IAuthorityPluginRegistry
{
IReadOnlyCollection<AuthorityPluginContext> Plugins { get; }
bool TryGet(string name, [NotNullWhen(true)] out AuthorityPluginContext? context);
AuthorityPluginContext GetRequired(string name)
{
if (TryGet(name, out var context))
{
return context;
}
throw new KeyNotFoundException($"Authority plugin '{name}' is not registered.");
}
}
/// <summary>
/// Registry exposing loaded identity provider plugins and their capabilities.
/// </summary>
public interface IAuthorityIdentityProviderRegistry
{
/// <summary>
/// Gets metadata for all registered identity provider plugins.
/// </summary>
IReadOnlyCollection<AuthorityIdentityProviderMetadata> Providers { get; }
/// <summary>
/// Gets metadata for identity providers that advertise password support.
/// </summary>
IReadOnlyCollection<AuthorityIdentityProviderMetadata> PasswordProviders { get; }
/// <summary>
/// Gets metadata for identity providers that advertise multi-factor authentication support.
/// </summary>
IReadOnlyCollection<AuthorityIdentityProviderMetadata> MfaProviders { get; }
/// <summary>
/// Gets metadata for identity providers that advertise client provisioning support.
/// </summary>
@@ -126,91 +126,91 @@ public interface IAuthorityIdentityProviderRegistry
/// Aggregate capability flags across all registered providers.
/// </summary>
AuthorityIdentityProviderCapabilities AggregateCapabilities { get; }
/// <summary>
/// Attempts to resolve identity provider metadata by name.
/// </summary>
bool TryGet(string name, [NotNullWhen(true)] out AuthorityIdentityProviderMetadata? metadata);
/// <summary>
/// Resolves identity provider metadata by name or throws when not found.
/// </summary>
AuthorityIdentityProviderMetadata GetRequired(string name)
{
if (TryGet(name, out var metadata))
{
return metadata;
}
throw new KeyNotFoundException($"Identity provider plugin '{name}' is not registered.");
}
/// <summary>
/// Acquires a scoped handle to the specified identity provider.
/// </summary>
/// <param name="name">Logical provider name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Handle managing the provider instance lifetime.</returns>
ValueTask<AuthorityIdentityProviderHandle> AcquireAsync(string name, CancellationToken cancellationToken);
}
/// <summary>
/// Immutable metadata describing a registered identity provider.
/// </summary>
/// <param name="Name">Logical provider name from the manifest.</param>
/// <param name="Type">Provider type identifier.</param>
/// <param name="Capabilities">Capability flags advertised by the provider.</param>
public sealed record AuthorityIdentityProviderMetadata(
string Name,
string Type,
AuthorityIdentityProviderCapabilities Capabilities);
/// <summary>
/// Represents a scoped identity provider instance and manages its disposal.
/// </summary>
public sealed class AuthorityIdentityProviderHandle : IAsyncDisposable, IDisposable
{
private readonly AsyncServiceScope scope;
private bool disposed;
public AuthorityIdentityProviderHandle(AsyncServiceScope scope, AuthorityIdentityProviderMetadata metadata, IIdentityProviderPlugin provider)
{
this.scope = scope;
Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
Provider = provider ?? throw new ArgumentNullException(nameof(provider));
}
/// <summary>
/// Gets the metadata associated with the provider instance.
/// </summary>
public AuthorityIdentityProviderMetadata Metadata { get; }
/// <summary>
/// Gets the active provider instance.
/// </summary>
public IIdentityProviderPlugin Provider { get; }
/// <inheritdoc />
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
scope.Dispose();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (disposed)
{
return;
}
disposed = true;
await scope.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Attempts to resolve identity provider metadata by name.
/// </summary>
bool TryGet(string name, [NotNullWhen(true)] out AuthorityIdentityProviderMetadata? metadata);
/// <summary>
/// Resolves identity provider metadata by name or throws when not found.
/// </summary>
AuthorityIdentityProviderMetadata GetRequired(string name)
{
if (TryGet(name, out var metadata))
{
return metadata;
}
throw new KeyNotFoundException($"Identity provider plugin '{name}' is not registered.");
}
/// <summary>
/// Acquires a scoped handle to the specified identity provider.
/// </summary>
/// <param name="name">Logical provider name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Handle managing the provider instance lifetime.</returns>
ValueTask<AuthorityIdentityProviderHandle> AcquireAsync(string name, CancellationToken cancellationToken);
}
/// <summary>
/// Immutable metadata describing a registered identity provider.
/// </summary>
/// <param name="Name">Logical provider name from the manifest.</param>
/// <param name="Type">Provider type identifier.</param>
/// <param name="Capabilities">Capability flags advertised by the provider.</param>
public sealed record AuthorityIdentityProviderMetadata(
string Name,
string Type,
AuthorityIdentityProviderCapabilities Capabilities);
/// <summary>
/// Represents a scoped identity provider instance and manages its disposal.
/// </summary>
public sealed class AuthorityIdentityProviderHandle : IAsyncDisposable, IDisposable
{
private readonly AsyncServiceScope scope;
private bool disposed;
public AuthorityIdentityProviderHandle(AsyncServiceScope scope, AuthorityIdentityProviderMetadata metadata, IIdentityProviderPlugin provider)
{
this.scope = scope;
Metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
Provider = provider ?? throw new ArgumentNullException(nameof(provider));
}
/// <summary>
/// Gets the metadata associated with the provider instance.
/// </summary>
public AuthorityIdentityProviderMetadata Metadata { get; }
/// <summary>
/// Gets the active provider instance.
/// </summary>
public IIdentityProviderPlugin Provider { get; }
/// <inheritdoc />
public void Dispose()
{
if (disposed)
{
return;
}
disposed = true;
scope.Dispose();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (disposed)
{
return;
}
disposed = true;
await scope.DisposeAsync().ConfigureAwait(false);
}
}

View File

@@ -1,14 +1,14 @@
using System.Linq.Expressions;
namespace MongoDB.Driver;
namespace StellaOps.Authority.InMemoryDriver;
/// <summary>
/// Compatibility shim for MongoDB IMongoCollection interface.
/// In PostgreSQL mode, this provides an in-memory implementation.
/// Compatibility shim for collection interface.
/// Provides an in-memory implementation.
/// </summary>
public interface IMongoCollection<TDocument>
public interface ICollection<TDocument>
{
IMongoDatabase Database { get; }
IDatabase Database { get; }
string CollectionNamespace { get; }
Task<TDocument?> FindOneAsync(Expression<Func<TDocument, bool>> filter, CancellationToken cancellationToken = default);
@@ -20,38 +20,38 @@ public interface IMongoCollection<TDocument>
}
/// <summary>
/// Compatibility shim for MongoDB IMongoDatabase interface.
/// Compatibility shim for database interface.
/// </summary>
public interface IMongoDatabase
public interface IDatabase
{
string DatabaseNamespace { get; }
IMongoCollection<TDocument> GetCollection<TDocument>(string name);
ICollection<TDocument> GetCollection<TDocument>(string name);
}
/// <summary>
/// Compatibility shim for MongoDB IMongoClient interface.
/// Compatibility shim for client interface.
/// </summary>
public interface IMongoClient
public interface IClient
{
IMongoDatabase GetDatabase(string name);
IDatabase GetDatabase(string name);
}
/// <summary>
/// In-memory implementation of IMongoCollection for compatibility.
/// In-memory implementation of ICollection for compatibility.
/// </summary>
public class InMemoryMongoCollection<TDocument> : IMongoCollection<TDocument>
public class InMemoryCollection<TDocument> : ICollection<TDocument>
{
private readonly List<TDocument> _documents = new();
private readonly IMongoDatabase _database;
private readonly IDatabase _database;
private readonly string _name;
public InMemoryMongoCollection(IMongoDatabase database, string name)
public InMemoryCollection(IDatabase database, string name)
{
_database = database;
_name = name;
}
public IMongoDatabase Database => _database;
public IDatabase Database => _database;
public string CollectionNamespace => _name;
public Task<TDocument?> FindOneAsync(Expression<Func<TDocument, bool>> filter, CancellationToken cancellationToken = default)
@@ -109,43 +109,43 @@ public class InMemoryMongoCollection<TDocument> : IMongoCollection<TDocument>
}
/// <summary>
/// In-memory implementation of IMongoDatabase for compatibility.
/// In-memory implementation of IDatabase for compatibility.
/// </summary>
public class InMemoryMongoDatabase : IMongoDatabase
public class InMemoryDatabase : IDatabase
{
private readonly Dictionary<string, object> _collections = new();
private readonly string _name;
public InMemoryMongoDatabase(string name)
public InMemoryDatabase(string name)
{
_name = name;
}
public string DatabaseNamespace => _name;
public IMongoCollection<TDocument> GetCollection<TDocument>(string name)
public ICollection<TDocument> GetCollection<TDocument>(string name)
{
if (!_collections.TryGetValue(name, out var collection))
{
collection = new InMemoryMongoCollection<TDocument>(this, name);
collection = new InMemoryCollection<TDocument>(this, name);
_collections[name] = collection;
}
return (IMongoCollection<TDocument>)collection;
return (ICollection<TDocument>)collection;
}
}
/// <summary>
/// In-memory implementation of IMongoClient for compatibility.
/// In-memory implementation of IClient for compatibility.
/// </summary>
public class InMemoryMongoClient : IMongoClient
public class InMemoryClient : IClient
{
private readonly Dictionary<string, IMongoDatabase> _databases = new();
private readonly Dictionary<string, IDatabase> _databases = new();
public IMongoDatabase GetDatabase(string name)
public IDatabase GetDatabase(string name)
{
if (!_databases.TryGetValue(name, out var database))
{
database = new InMemoryMongoDatabase(name);
database = new InMemoryDatabase(name);
_databases[name] = database;
}
return database;

View File

@@ -1,15 +1,15 @@
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
using StellaOps.Authority.Storage.Mongo.Initialization;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.InMemoryDriver;
using StellaOps.Authority.Storage.InMemory.Initialization;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
namespace StellaOps.Authority.Storage.Mongo.Extensions;
/// <summary>
/// Compatibility shim storage options. In PostgreSQL mode, these are largely unused.
/// </summary>
public sealed class AuthorityMongoStorageOptions
public sealed class AuthorityStorageOptions
{
public string ConnectionString { get; set; } = string.Empty;
public string DatabaseName { get; set; } = "authority";
@@ -28,9 +28,9 @@ public static class ServiceCollectionExtensions
/// </summary>
public static IServiceCollection AddAuthorityMongoStorage(
this IServiceCollection services,
Action<AuthorityMongoStorageOptions> configureOptions)
Action<AuthorityStorageOptions> configureOptions)
{
var options = new AuthorityMongoStorageOptions();
var options = new AuthorityStorageOptions();
configureOptions(options);
services.AddSingleton(options);
@@ -38,19 +38,19 @@ public static class ServiceCollectionExtensions
return services;
}
private static void RegisterMongoCompatServices(IServiceCollection services, AuthorityMongoStorageOptions options)
private static void RegisterMongoCompatServices(IServiceCollection services, AuthorityStorageOptions options)
{
// Register the initializer (no-op for Postgres mode)
services.AddSingleton<AuthorityMongoInitializer>();
services.AddSingleton<AuthorityStorageInitializer>();
// Register null session accessor
services.AddSingleton<IAuthorityMongoSessionAccessor, NullAuthorityMongoSessionAccessor>();
services.AddSingleton<IAuthoritySessionAccessor, NullAuthoritySessionAccessor>();
// Register in-memory MongoDB shims for compatibility
var inMemoryClient = new InMemoryMongoClient();
// Register in-memory shims for compatibility
var inMemoryClient = new InMemoryClient();
var inMemoryDatabase = inMemoryClient.GetDatabase(options.DatabaseName);
services.AddSingleton<IMongoClient>(inMemoryClient);
services.AddSingleton<IMongoDatabase>(inMemoryDatabase);
services.AddSingleton<IClient>(inMemoryClient);
services.AddSingleton<IDatabase>(inMemoryDatabase);
// Register in-memory store implementations
// These should be replaced by Postgres-backed implementations over time

View File

@@ -1,10 +1,10 @@
namespace StellaOps.Authority.Storage.Mongo.Initialization;
namespace StellaOps.Authority.Storage.InMemory.Initialization;
/// <summary>
/// Compatibility shim for MongoDB initializer. In PostgreSQL mode, this is a no-op.
/// Compatibility shim for storage initializer. In PostgreSQL mode, this is a no-op.
/// The actual initialization is handled by PostgreSQL migrations.
/// </summary>
public sealed class AuthorityMongoInitializer
public sealed class AuthorityStorageInitializer
{
/// <summary>
/// Initializes the database. In PostgreSQL mode, this is a no-op as migrations handle setup.

View File

@@ -8,9 +8,9 @@ public interface IClientSessionHandle : IDisposable
}
/// <summary>
/// Compatibility shim for MongoDB session accessor. In PostgreSQL mode, this returns null.
/// Compatibility shim for database session accessor. In PostgreSQL mode, this returns null.
/// </summary>
public interface IAuthorityMongoSessionAccessor
public interface IAuthoritySessionAccessor
{
IClientSessionHandle? CurrentSession { get; }
ValueTask<IClientSessionHandle?> GetSessionAsync(CancellationToken cancellationToken);
@@ -19,7 +19,7 @@ public interface IAuthorityMongoSessionAccessor
/// <summary>
/// In-memory implementation that always returns null session.
/// </summary>
public sealed class NullAuthorityMongoSessionAccessor : IAuthorityMongoSessionAccessor
public sealed class NullAuthoritySessionAccessor : IAuthoritySessionAccessor
{
public IClientSessionHandle? CurrentSession => null;

View File

@@ -1,7 +1,7 @@
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
namespace StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.Storage.InMemory.Stores;
/// <summary>
/// Store interface for bootstrap invites.

View File

@@ -1,9 +1,9 @@
using System.Collections.Concurrent;
using System.Threading;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
namespace StellaOps.Authority.Storage.Mongo.Stores;
namespace StellaOps.Authority.Storage.InMemory.Stores;
/// <summary>
/// In-memory implementation of bootstrap invite store for development/testing.

View File

@@ -9,9 +9,9 @@ using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Tests.Infrastructure;
using StellaOps.Configuration;
using Xunit;

View File

@@ -13,9 +13,9 @@ using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Auth.Abstractions;
using StellaOps.Authority.Airgap;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Tests.Infrastructure;
using Xunit;
@@ -171,7 +171,7 @@ public sealed class AirgapAuditEndpointsTests : IClassFixture<AuthorityWebApplic
var store = new TestAirgapAuditStore();
_airgapStore = store;
services.Replace(ServiceDescriptor.Singleton<IAuthorityAirgapAuditStore>(store));
services.Replace(ServiceDescriptor.Singleton<IAuthorityMongoSessionAccessor, NullAuthorityMongoSessionAccessor>());
services.Replace(ServiceDescriptor.Singleton<IAuthoritySessionAccessor, NullAuthoritySessionAccessor>());
services.Replace(ServiceDescriptor.Singleton<TimeProvider>(timeProvider));
services.AddAuthentication(options =>
{

View File

@@ -1,10 +1,10 @@
using System.Linq;
using Microsoft.Extensions.Logging;
using StellaOps.Authority.Audit;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Cryptography.Audit;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.InMemory.Sessions;
namespace StellaOps.Authority.Tests.Audit;

View File

@@ -6,9 +6,9 @@ using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using StellaOps.Authority.Bootstrap;
using StellaOps.Authority.Storage.Mongo.Documents;
using StellaOps.Authority.Storage.Mongo.Stores;
using StellaOps.Authority.Storage.Mongo.Sessions;
using StellaOps.Authority.Storage.InMemory.Documents;
using StellaOps.Authority.Storage.InMemory.Stores;
using StellaOps.Authority.Storage.InMemory.Sessions;
using StellaOps.Cryptography.Audit;
using Xunit;

Some files were not shown because too many files have changed in this diff Show More