Add Policy DSL Validator, Schema Exporter, and Simulation Smoke tools
	
		
			
	
		
	
	
		
	
		
			Some checks failed
		
		
	
	
		
			
				
	
				Docs CI / lint-and-preview (push) Has been cancelled
				
			
		
		
	
	
				
					
				
			
		
			Some checks failed
		
		
	
	Docs CI / lint-and-preview (push) Has been cancelled
				
			- Implemented PolicyDslValidator with command-line options for strict mode and JSON output. - Created PolicySchemaExporter to generate JSON schemas for policy-related models. - Developed PolicySimulationSmoke tool to validate policy simulations against expected outcomes. - Added project files and necessary dependencies for each tool. - Ensured proper error handling and usage instructions across tools.
This commit is contained in:
		| @@ -1,4 +1,4 @@ | ||||
| <Project> | ||||
| <Project> | ||||
|   <PropertyGroup> | ||||
|     <ConcelierPluginOutputRoot Condition="'$(ConcelierPluginOutputRoot)' == ''">$(SolutionDir)StellaOps.Concelier.PluginBinaries</ConcelierPluginOutputRoot> | ||||
|     <ConcelierPluginOutputRoot Condition="'$(ConcelierPluginOutputRoot)' == '' and '$(SolutionDir)' == ''">$(MSBuildThisFileDirectory)StellaOps.Concelier.PluginBinaries</ConcelierPluginOutputRoot> | ||||
| @@ -35,15 +35,15 @@ | ||||
|   <ItemGroup Condition="$([System.String]::Copy('$(MSBuildProjectName)').EndsWith('.Tests')) and '$(UseConcelierTestInfra)' != 'false'"> | ||||
|     <PackageReference Include="coverlet.collector" Version="6.0.4" /> | ||||
|     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" /> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" /> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" /> | ||||
|     <PackageReference Include="Mongo2Go" Version="4.1.0" /> | ||||
|     <PackageReference Include="xunit" Version="2.9.2" /> | ||||
|     <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="8.4.0" /> | ||||
|     <Compile Include="$(MSBuildThisFileDirectory)StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" Link="Shared\AssemblyInfo.cs" /> | ||||
|     <Compile Include="$(MSBuildThisFileDirectory)StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" Link="Shared\MongoFixtureCollection.cs" /> | ||||
|     <ProjectReference Include="$(MSBuildThisFileDirectory)StellaOps.Concelier.Testing\StellaOps.Concelier.Testing.csproj" /> | ||||
|     <Using Include="StellaOps.Concelier.Testing" /> | ||||
|     <Using Include="Xunit" /> | ||||
|   </ItemGroup> | ||||
|     <PackageReference Include="xunit" Version="2.9.2" /> | ||||
|     <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="9.10.0" /> | ||||
|     <Compile Include="$(MSBuildThisFileDirectory)StellaOps.Concelier.Tests.Shared\AssemblyInfo.cs" Link="Shared\AssemblyInfo.cs" /> | ||||
|     <Compile Include="$(MSBuildThisFileDirectory)StellaOps.Concelier.Tests.Shared\MongoFixtureCollection.cs" Link="Shared\MongoFixtureCollection.cs" /> | ||||
|     <ProjectReference Include="$(MSBuildThisFileDirectory)StellaOps.Concelier.Testing\StellaOps.Concelier.Testing.csproj" /> | ||||
|     <Using Include="StellaOps.Concelier.Testing" /> | ||||
|     <Using Include="Xunit" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
|   | ||||
							
								
								
									
										113
									
								
								src/StellaOps.Aoc.Tests/AocWriteGuardTests.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										113
									
								
								src/StellaOps.Aoc.Tests/AocWriteGuardTests.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,113 @@ | ||||
| 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); | ||||
|         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] | ||||
|     public void Validate_FlagsForbiddenField() | ||||
|     { | ||||
|         using var document = JsonDocument.Parse(""" | ||||
|         { | ||||
|           "tenant": "default", | ||||
|           "severity": "high", | ||||
|           "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_001" && v.Path == "/severity"); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void Validate_FlagsInvalidSignatureMetadata() | ||||
|     { | ||||
|         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")); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										41
									
								
								src/StellaOps.Aoc.Tests/StellaOps.Aoc.Tests.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								src/StellaOps.Aoc.Tests/StellaOps.Aoc.Tests.csproj
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|  | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|     <OutputType>Exe</OutputType> | ||||
|     <IsPackable>false</IsPackable> | ||||
|     <UseConcelierTestInfra>false</UseConcelierTestInfra> | ||||
|  | ||||
|     <!-- To enable Microsoft.Testing.Platform, uncomment the following line. --> | ||||
|     <!-- <UseMicrosoftTestingPlatformRunner>true</UseMicrosoftTestingPlatformRunner> --> | ||||
|     <!-- Note: to use Microsoft.Testing.Platform correctly with dotnet test: --> | ||||
|     <!-- 1. You must add dotnet.config specifying the test runner to be Microsoft.Testing.Platform --> | ||||
|     <!-- 2. You must use .NET 10 SDK or later --> | ||||
|     <!-- For more information, see https://aka.ms/dotnet-test/mtp and https://xunit.net/docs/getting-started/v3/microsoft-testing-platform --> | ||||
|     <!-- To enable code coverage with Microsoft.Testing.Platform, add a package reference to Microsoft.Testing.Extensions.CodeCoverage --> | ||||
|     <!-- https://learn.microsoft.comdotnet/core/testing/microsoft-testing-platform-extensions-code-coverage --> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> | ||||
|     <PackageReference Include="xunit.v3" Version="3.0.0" /> | ||||
|     <PackageReference Include="xunit.runner.visualstudio" Version="3.1.3" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <Using Include="Xunit" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Aoc\StellaOps.Aoc.csproj" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
| </Project> | ||||
							
								
								
									
										10
									
								
								src/StellaOps.Aoc.Tests/UnitTest1.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/StellaOps.Aoc.Tests/UnitTest1.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| namespace StellaOps.Aoc.Tests; | ||||
|  | ||||
| public class UnitTest1 | ||||
| { | ||||
|     [Fact] | ||||
|     public void Test1() | ||||
|     { | ||||
|  | ||||
|     } | ||||
| } | ||||
							
								
								
									
										3
									
								
								src/StellaOps.Aoc.Tests/xunit.runner.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/StellaOps.Aoc.Tests/xunit.runner.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| { | ||||
|     "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json" | ||||
| } | ||||
							
								
								
									
										25
									
								
								src/StellaOps.Aoc/AocForbiddenKeys.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								src/StellaOps.Aoc/AocForbiddenKeys.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +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); | ||||
| } | ||||
							
								
								
									
										17
									
								
								src/StellaOps.Aoc/AocGuardException.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/StellaOps.Aoc/AocGuardException.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +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; | ||||
| } | ||||
							
								
								
									
										22
									
								
								src/StellaOps.Aoc/AocGuardExtensions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/StellaOps.Aoc/AocGuardExtensions.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +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; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										29
									
								
								src/StellaOps.Aoc/AocGuardOptions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/StellaOps.Aoc/AocGuardOptions.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | ||||
| using System.Collections.Immutable; | ||||
|  | ||||
| namespace StellaOps.Aoc; | ||||
|  | ||||
| public sealed record AocGuardOptions | ||||
| { | ||||
|     private static readonly ImmutableHashSet<string> DefaultRequiredTopLevel = new[] | ||||
|     { | ||||
|         "tenant", | ||||
|         "source", | ||||
|         "upstream", | ||||
|         "content", | ||||
|         "linkset", | ||||
|     }.ToImmutableHashSet(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|     public static AocGuardOptions Default { get; } = new(); | ||||
|  | ||||
|     public ImmutableHashSet<string> RequiredTopLevelFields { get; init; } = DefaultRequiredTopLevel; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// When true, signature metadata is required under upstream.signature. | ||||
|     /// </summary> | ||||
|     public bool RequireSignatureMetadata { get; init; } = true; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// When true, tenant must be a non-empty string. | ||||
|     /// </summary> | ||||
|     public bool RequireTenant { get; init; } = true; | ||||
| } | ||||
							
								
								
									
										14
									
								
								src/StellaOps.Aoc/AocGuardResult.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/StellaOps.Aoc/AocGuardResult.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +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); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										13
									
								
								src/StellaOps.Aoc/AocViolation.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/StellaOps.Aoc/AocViolation.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +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); | ||||
| } | ||||
							
								
								
									
										34
									
								
								src/StellaOps.Aoc/AocViolationCode.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/StellaOps.Aoc/AocViolationCode.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +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", | ||||
|     }; | ||||
| } | ||||
							
								
								
									
										127
									
								
								src/StellaOps.Aoc/AocWriteGuard.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										127
									
								
								src/StellaOps.Aoc/AocWriteGuard.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,127 @@ | ||||
| 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; | ||||
|         var violations = ImmutableArray.CreateBuilder<AocViolation>(); | ||||
|         var presentTopLevel = new HashSet<string>(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|         foreach (var property in document.EnumerateObject()) | ||||
|         { | ||||
|             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)) | ||||
|             { | ||||
|                 violations.Add(AocViolation.Create(AocViolationCode.DerivedFindingDetected, $"/{property.Name}", $"Derived field '{property.Name}' must not be written during ingestion.")); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         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.")); | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										17
									
								
								src/StellaOps.Aoc/ServiceCollectionExtensions.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/StellaOps.Aoc/ServiceCollectionExtensions.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +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; | ||||
|     } | ||||
| } | ||||
							
								
								
									
										12
									
								
								src/StellaOps.Aoc/StellaOps.Aoc.csproj
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/StellaOps.Aoc/StellaOps.Aoc.csproj
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| @@ -1,21 +1,21 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" /> | ||||
|     <PackageReference Include="MongoDB.Driver" Version="3.5.0" /> | ||||
|     <PackageReference Include="StackExchange.Redis" Version="2.8.24" /> | ||||
|     <PackageReference Include="AWSSDK.S3" Version="3.7.307.6" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" /> | ||||
|     <PackageReference Include="MongoDB.Driver" Version="3.5.0" /> | ||||
|     <PackageReference Include="StackExchange.Redis" Version="2.8.24" /> | ||||
|     <PackageReference Include="AWSSDK.S3" Version="3.7.307.6" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
|   | ||||
| @@ -1,25 +1,25 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|     <UseConcelierTestInfra>false</UseConcelierTestInfra> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.8" /> | ||||
|     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" /> | ||||
|     <PackageReference Include="Mongo2Go" Version="3.1.3" /> | ||||
|     <PackageReference Include="xunit" Version="2.9.2" /> | ||||
|     <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> | ||||
|     <PackageReference Include="coverlet.collector" Version="6.0.4" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Attestor.WebService\StellaOps.Attestor.WebService.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|     <UseConcelierTestInfra>false</UseConcelierTestInfra> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.0-rc.2.25502.107" /> | ||||
|     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" /> | ||||
|     <PackageReference Include="Mongo2Go" Version="3.1.3" /> | ||||
|     <PackageReference Include="xunit" Version="2.9.2" /> | ||||
|     <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" /> | ||||
|     <PackageReference Include="coverlet.collector" Version="6.0.4" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Attestor.WebService\StellaOps.Attestor.WebService.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
|   | ||||
| @@ -1,30 +1,30 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk.Web"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.8" /> | ||||
|     <PackageReference Include="MongoDB.Driver" Version="3.5.0" /> | ||||
|     <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" /> | ||||
|     <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" /> | ||||
|     <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" /> | ||||
|     <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" /> | ||||
|     <PackageReference Include="Serilog.AspNetCore" Version="8.0.1" /> | ||||
|     <PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" /> | ||||
|     <PackageReference Include="StackExchange.Redis" Version="2.8.24" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| <Project Sdk="Microsoft.NET.Sdk.Web"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0-rc.2.25502.107" /> | ||||
|     <PackageReference Include="MongoDB.Driver" Version="3.5.0" /> | ||||
|     <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" /> | ||||
|     <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" /> | ||||
|     <PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" /> | ||||
|     <PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" /> | ||||
|     <PackageReference Include="Serilog.AspNetCore" Version="8.0.1" /> | ||||
|     <PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" /> | ||||
|     <PackageReference Include="StackExchange.Redis" Version="2.8.24" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Attestor.Core\StellaOps.Attestor.Core.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Attestor.Infrastructure\StellaOps.Attestor.Infrastructure.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.DependencyInjection\StellaOps.DependencyInjection.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Authority\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Authority\StellaOps.Auth.Client\StellaOps.Auth.Client.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Authority\StellaOps.Auth.ServerIntegration\StellaOps.Auth.ServerIntegration.csproj" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
|   | ||||
| @@ -1,38 +1,38 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <PropertyGroup> | ||||
|     <Description>Sender-constrained authentication primitives (DPoP, mTLS) shared across StellaOps services.</Description> | ||||
|     <PackageId>StellaOps.Auth.Security</PackageId> | ||||
|     <Authors>StellaOps</Authors> | ||||
|     <Company>StellaOps</Company> | ||||
|     <PackageTags>stellaops;dpop;mtls;oauth2;security</PackageTags> | ||||
|     <PackageLicenseExpression>AGPL-3.0-or-later</PackageLicenseExpression> | ||||
|     <PackageProjectUrl>https://stella-ops.org</PackageProjectUrl> | ||||
|     <RepositoryUrl>https://git.stella-ops.org/stella-ops.org/git.stella-ops.org</RepositoryUrl> | ||||
|     <RepositoryType>git</RepositoryType> | ||||
|     <PublishRepositoryUrl>true</PublishRepositoryUrl> | ||||
|     <EmbedUntrackedSources>true</EmbedUntrackedSources> | ||||
|     <IncludeSymbols>true</IncludeSymbols> | ||||
|     <SymbolPackageFormat>snupkg</SymbolPackageFormat> | ||||
|     <PackageReadmeFile>README.md</PackageReadmeFile> | ||||
|     <VersionPrefix>1.0.0-preview.1</VersionPrefix> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <FrameworkReference Include="Microsoft.AspNetCore.App" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="7.2.0" /> | ||||
|     <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.2.0" /> | ||||
|     <PackageReference Include="StackExchange.Redis" Version="2.8.24" /> | ||||
|     <PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <None Include="README.md" Pack="true" PackagePath="" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <PropertyGroup> | ||||
|     <Description>Sender-constrained authentication primitives (DPoP, mTLS) shared across StellaOps services.</Description> | ||||
|     <PackageId>StellaOps.Auth.Security</PackageId> | ||||
|     <Authors>StellaOps</Authors> | ||||
|     <Company>StellaOps</Company> | ||||
|     <PackageTags>stellaops;dpop;mtls;oauth2;security</PackageTags> | ||||
|     <PackageLicenseExpression>AGPL-3.0-or-later</PackageLicenseExpression> | ||||
|     <PackageProjectUrl>https://stella-ops.org</PackageProjectUrl> | ||||
|     <RepositoryUrl>https://git.stella-ops.org/stella-ops.org/git.stella-ops.org</RepositoryUrl> | ||||
|     <RepositoryType>git</RepositoryType> | ||||
|     <PublishRepositoryUrl>true</PublishRepositoryUrl> | ||||
|     <EmbedUntrackedSources>true</EmbedUntrackedSources> | ||||
|     <IncludeSymbols>true</IncludeSymbols> | ||||
|     <SymbolPackageFormat>snupkg</SymbolPackageFormat> | ||||
|     <PackageReadmeFile>README.md</PackageReadmeFile> | ||||
|     <VersionPrefix>1.0.0-preview.1</VersionPrefix> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <FrameworkReference Include="Microsoft.AspNetCore.App" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.14.0" /> | ||||
|     <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="7.2.0" /> | ||||
|     <PackageReference Include="StackExchange.Redis" Version="2.8.24" /> | ||||
|     <PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <None Include="README.md" Pack="true" PackagePath="" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
|   | ||||
| @@ -0,0 +1,38 @@ | ||||
| using StellaOps.Auth.Abstractions; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Auth.Abstractions.Tests; | ||||
|  | ||||
| public class StellaOpsScopesTests | ||||
| { | ||||
|     [Theory] | ||||
|     [InlineData(StellaOpsScopes.AdvisoryRead)] | ||||
|     [InlineData(StellaOpsScopes.AdvisoryIngest)] | ||||
|     [InlineData(StellaOpsScopes.VexRead)] | ||||
|     [InlineData(StellaOpsScopes.VexIngest)] | ||||
|     [InlineData(StellaOpsScopes.AocVerify)] | ||||
|     [InlineData(StellaOpsScopes.PolicyWrite)] | ||||
|     [InlineData(StellaOpsScopes.PolicySubmit)] | ||||
|     [InlineData(StellaOpsScopes.PolicyApprove)] | ||||
|     [InlineData(StellaOpsScopes.PolicyRun)] | ||||
|     [InlineData(StellaOpsScopes.FindingsRead)] | ||||
|     [InlineData(StellaOpsScopes.EffectiveWrite)] | ||||
|     [InlineData(StellaOpsScopes.GraphRead)] | ||||
|     [InlineData(StellaOpsScopes.VulnRead)] | ||||
|     [InlineData(StellaOpsScopes.GraphWrite)] | ||||
|     [InlineData(StellaOpsScopes.GraphExport)] | ||||
|     [InlineData(StellaOpsScopes.GraphSimulate)] | ||||
|     public void All_IncludesNewScopes(string scope) | ||||
|     { | ||||
|         Assert.Contains(scope, StellaOpsScopes.All); | ||||
|     } | ||||
|  | ||||
|     [Theory] | ||||
|     [InlineData("Advisory:Read", StellaOpsScopes.AdvisoryRead)] | ||||
|     [InlineData("  VEX:Ingest ", StellaOpsScopes.VexIngest)] | ||||
|     [InlineData("AOC:VERIFY", StellaOpsScopes.AocVerify)] | ||||
|     public void Normalize_NormalizesToLowerCase(string input, string expected) | ||||
|     { | ||||
|         Assert.Equal(expected, StellaOpsScopes.Normalize(input)); | ||||
|     } | ||||
| } | ||||
| @@ -24,29 +24,125 @@ public static class StellaOpsScopes | ||||
|     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"; | ||||
|  | ||||
|     private static readonly HashSet<string> KnownScopes = new(StringComparer.OrdinalIgnoreCase) | ||||
|     { | ||||
|         ConcelierJobsTrigger, | ||||
|         ConcelierMerge, | ||||
|         AuthorityUsersManage, | ||||
|         AuthorityClientsManage, | ||||
|         AuthorityAuditRead, | ||||
|         Bypass | ||||
|     }; | ||||
|     /// 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 raw advisory ingestion data. | ||||
|     /// </summary> | ||||
|     public const string AdvisoryRead = "advisory:read"; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Scope granting write access for raw advisory ingestion. | ||||
|     /// </summary> | ||||
|     public const string AdvisoryIngest = "advisory:ingest"; | ||||
|  | ||||
|     /// <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 permission to create or edit policy drafts. | ||||
|     /// </summary> | ||||
|     public const string PolicyWrite = "policy:write"; | ||||
|  | ||||
|     /// <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 trigger policy runs and activation workflows. | ||||
|     /// </summary> | ||||
|     public const string PolicyRun = "policy:run"; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Scope granting read-only access to effective findings materialised by Policy Engine. | ||||
|     /// </summary> | ||||
|     public const string FindingsRead = "findings:read"; | ||||
|  | ||||
|     /// <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> | ||||
|     public const string VulnRead = "vuln:read"; | ||||
|  | ||||
|     /// <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"; | ||||
|  | ||||
|     private static readonly HashSet<string> KnownScopes = new(StringComparer.OrdinalIgnoreCase) | ||||
|     { | ||||
|         ConcelierJobsTrigger, | ||||
|         ConcelierMerge, | ||||
|         AuthorityUsersManage, | ||||
|         AuthorityClientsManage, | ||||
|         AuthorityAuditRead, | ||||
|         Bypass, | ||||
|         AdvisoryRead, | ||||
|         AdvisoryIngest, | ||||
|         VexRead, | ||||
|         VexIngest, | ||||
|         AocVerify, | ||||
|         PolicyWrite, | ||||
|         PolicySubmit, | ||||
|         PolicyApprove, | ||||
|         PolicyRun, | ||||
|         FindingsRead, | ||||
|         EffectiveWrite, | ||||
|         GraphRead, | ||||
|         VulnRead, | ||||
|         GraphWrite, | ||||
|         GraphExport, | ||||
|         GraphSimulate | ||||
|     }; | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Normalises a scope string (trim/convert to lower case). | ||||
|   | ||||
| @@ -0,0 +1,17 @@ | ||||
| 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"; | ||||
| } | ||||
| @@ -9,7 +9,7 @@ | ||||
|     <ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.5" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0-rc.2.25502.107" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0-rc.2.25502.107" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
|   | ||||
| @@ -30,10 +30,10 @@ | ||||
|     <ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="8.0.5" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="10.0.0-rc.2.25502.107" /> | ||||
|     <PackageReference Include="Microsoft.SourceLink.GitLab" Version="8.0.0" PrivateAssets="All" /> | ||||
|     <FrameworkReference Include="Microsoft.AspNetCore.App" /> | ||||
|     <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.0.1" /> | ||||
|     <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.14.0" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <None Include="README.NuGet.md" Pack="true" PackagePath="" /> | ||||
|   | ||||
| @@ -1,50 +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.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.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); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,123 +1,199 @@ | ||||
| using System; | ||||
| using System.Net; | ||||
| using System.Security.Claims; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Http; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Auth.Abstractions; | ||||
| using StellaOps.Auth.ServerIntegration; | ||||
| 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.Validate(); | ||||
|         }); | ||||
|  | ||||
|         var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1")); | ||||
|         var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger }); | ||||
|         var principal = new StellaOpsPrincipalBuilder() | ||||
|             .WithSubject("user-1") | ||||
|             .WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger }) | ||||
|             .Build(); | ||||
|  | ||||
|         var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext); | ||||
|  | ||||
|         await handler.HandleAsync(context); | ||||
|  | ||||
|         Assert.True(context.HasSucceeded); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleRequirement_Succeeds_WhenBypassNetworkMatches() | ||||
|     { | ||||
|         var optionsMonitor = CreateOptionsMonitor(options => | ||||
|         { | ||||
|             options.Authority = "https://authority.example"; | ||||
|             options.BypassNetworks.Add("127.0.0.1/32"); | ||||
|             options.Validate(); | ||||
|         }); | ||||
|  | ||||
|         var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("127.0.0.1")); | ||||
|         var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger }); | ||||
|         var principal = new ClaimsPrincipal(new ClaimsIdentity()); | ||||
|         var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext); | ||||
|  | ||||
|         await handler.HandleAsync(context); | ||||
|  | ||||
|         Assert.True(context.HasSucceeded); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleRequirement_Fails_WhenScopeMissingAndNoBypass() | ||||
|     { | ||||
|         var optionsMonitor = CreateOptionsMonitor(options => | ||||
|         { | ||||
|             options.Authority = "https://authority.example"; | ||||
|             options.Validate(); | ||||
|         }); | ||||
|  | ||||
|         var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("203.0.113.10")); | ||||
|         var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger }); | ||||
|         var principal = new ClaimsPrincipal(new ClaimsIdentity()); | ||||
|         var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext); | ||||
|  | ||||
|         await handler.HandleAsync(context); | ||||
|  | ||||
|         Assert.False(context.HasSucceeded); | ||||
|     } | ||||
|  | ||||
|     private static (StellaOpsScopeAuthorizationHandler Handler, IHttpContextAccessor Accessor) CreateHandler(IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor, IPAddress remoteAddress) | ||||
|     { | ||||
|         var accessor = new HttpContextAccessor(); | ||||
|         var httpContext = new DefaultHttpContext(); | ||||
|         httpContext.Connection.RemoteIpAddress = remoteAddress; | ||||
|         accessor.HttpContext = httpContext; | ||||
|  | ||||
|         var bypassEvaluator = new StellaOpsBypassEvaluator(optionsMonitor, NullLogger<StellaOpsBypassEvaluator>.Instance); | ||||
|  | ||||
|         var handler = new StellaOpsScopeAuthorizationHandler( | ||||
|             accessor, | ||||
|             bypassEvaluator, | ||||
|             NullLogger<StellaOpsScopeAuthorizationHandler>.Instance); | ||||
|         return (handler, accessor); | ||||
|     } | ||||
|  | ||||
|     private static IOptionsMonitor<StellaOpsResourceServerOptions> CreateOptionsMonitor(Action<StellaOpsResourceServerOptions> configure) | ||||
|         => new TestOptionsMonitor<StellaOpsResourceServerOptions>(configure); | ||||
|  | ||||
|     private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions> | ||||
|         where TOptions : class, new() | ||||
|     { | ||||
|         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() | ||||
|             { | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| using System; | ||||
| using System.Net; | ||||
| using System.Security.Claims; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Http; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Auth.Abstractions; | ||||
| using StellaOps.Auth.ServerIntegration; | ||||
| 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(); | ||||
|         }); | ||||
|  | ||||
|         var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1")); | ||||
|         var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger }); | ||||
|         var principal = new StellaOpsPrincipalBuilder() | ||||
|             .WithSubject("user-1") | ||||
|             .WithTenant("tenant-alpha") | ||||
|             .WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger }) | ||||
|             .Build(); | ||||
|  | ||||
|         var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext); | ||||
|  | ||||
|         await handler.HandleAsync(context); | ||||
|  | ||||
|         Assert.True(context.HasSucceeded); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     [Fact] | ||||
|     public async Task HandleRequirement_Fails_WhenTenantMismatch() | ||||
|     { | ||||
|         var optionsMonitor = CreateOptionsMonitor(options => | ||||
|         { | ||||
|             options.Authority = "https://authority.example"; | ||||
|             options.RequiredTenants.Add("tenant-alpha"); | ||||
|             options.Validate(); | ||||
|         }); | ||||
|  | ||||
|         var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("10.0.0.1")); | ||||
|         var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger }); | ||||
|         var principal = new StellaOpsPrincipalBuilder() | ||||
|             .WithSubject("user-1") | ||||
|             .WithTenant("tenant-beta") | ||||
|             .WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger }) | ||||
|             .Build(); | ||||
|  | ||||
|         var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext); | ||||
|  | ||||
|         await handler.HandleAsync(context); | ||||
|  | ||||
|         Assert.False(context.HasSucceeded); | ||||
|     } | ||||
|  | ||||
|     public async Task HandleRequirement_Succeeds_WhenBypassNetworkMatches() | ||||
|     { | ||||
|         var optionsMonitor = CreateOptionsMonitor(options => | ||||
|         { | ||||
|             options.Authority = "https://authority.example"; | ||||
|             options.BypassNetworks.Add("127.0.0.1/32"); | ||||
|             options.Validate(); | ||||
|         }); | ||||
|  | ||||
|         var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("127.0.0.1")); | ||||
|         var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger }); | ||||
|         var principal = new ClaimsPrincipal(new ClaimsIdentity()); | ||||
|         var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext); | ||||
|  | ||||
|         await handler.HandleAsync(context); | ||||
|  | ||||
|         Assert.True(context.HasSucceeded); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleRequirement_Fails_WhenScopeMissingAndNoBypass() | ||||
|     { | ||||
|         var optionsMonitor = CreateOptionsMonitor(options => | ||||
|         { | ||||
|             options.Authority = "https://authority.example"; | ||||
|             options.Validate(); | ||||
|         }); | ||||
|  | ||||
|         var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("203.0.113.10")); | ||||
|         var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger }); | ||||
|         var principal = new ClaimsPrincipal(new ClaimsIdentity()); | ||||
|         var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext); | ||||
|  | ||||
|         await handler.HandleAsync(context); | ||||
|  | ||||
|         Assert.False(context.HasSucceeded); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleRequirement_Fails_WhenDefaultScopeMissing() | ||||
|     { | ||||
|         var optionsMonitor = CreateOptionsMonitor(options => | ||||
|         { | ||||
|             options.Authority = "https://authority.example"; | ||||
|             options.RequiredScopes.Add(StellaOpsScopes.PolicyRun); | ||||
|             options.Validate(); | ||||
|         }); | ||||
|  | ||||
|         var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("198.51.100.5")); | ||||
|         var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger }); | ||||
|         var principal = new StellaOpsPrincipalBuilder() | ||||
|             .WithSubject("user-tenant") | ||||
|             .WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger }) | ||||
|             .Build(); | ||||
|  | ||||
|         var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext); | ||||
|  | ||||
|         await handler.HandleAsync(context); | ||||
|  | ||||
|         Assert.False(context.HasSucceeded); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task HandleRequirement_Succeeds_WhenDefaultScopePresent() | ||||
|     { | ||||
|         var optionsMonitor = CreateOptionsMonitor(options => | ||||
|         { | ||||
|             options.Authority = "https://authority.example"; | ||||
|             options.RequiredScopes.Add(StellaOpsScopes.PolicyRun); | ||||
|             options.Validate(); | ||||
|         }); | ||||
|  | ||||
|         var (handler, accessor) = CreateHandler(optionsMonitor, remoteAddress: IPAddress.Parse("198.51.100.5")); | ||||
|         var requirement = new StellaOpsScopeRequirement(new[] { StellaOpsScopes.ConcelierJobsTrigger }); | ||||
|         var principal = new StellaOpsPrincipalBuilder() | ||||
|             .WithSubject("user-tenant") | ||||
|             .WithScopes(new[] { StellaOpsScopes.ConcelierJobsTrigger, StellaOpsScopes.PolicyRun }) | ||||
|             .Build(); | ||||
|  | ||||
|         var context = new AuthorizationHandlerContext(new[] { requirement }, principal, accessor.HttpContext); | ||||
|  | ||||
|         await handler.HandleAsync(context); | ||||
|  | ||||
|         Assert.True(context.HasSucceeded); | ||||
|     } | ||||
|  | ||||
|     private static (StellaOpsScopeAuthorizationHandler Handler, IHttpContextAccessor Accessor) CreateHandler(IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor, IPAddress remoteAddress) | ||||
|     { | ||||
|         var accessor = new HttpContextAccessor(); | ||||
|         var httpContext = new DefaultHttpContext(); | ||||
|         httpContext.Connection.RemoteIpAddress = remoteAddress; | ||||
|         accessor.HttpContext = httpContext; | ||||
|  | ||||
|         var bypassEvaluator = new StellaOpsBypassEvaluator(optionsMonitor, NullLogger<StellaOpsBypassEvaluator>.Instance); | ||||
|  | ||||
|         var handler = new StellaOpsScopeAuthorizationHandler( | ||||
|             accessor, | ||||
|             bypassEvaluator, | ||||
|             optionsMonitor, | ||||
|             NullLogger<StellaOpsScopeAuthorizationHandler>.Instance); | ||||
|         return (handler, accessor); | ||||
|     } | ||||
|  | ||||
|     private static IOptionsMonitor<StellaOpsResourceServerOptions> CreateOptionsMonitor(Action<StellaOpsResourceServerOptions> configure) | ||||
|         => new TestOptionsMonitor<StellaOpsResourceServerOptions>(configure); | ||||
|  | ||||
|     private sealed class TestOptionsMonitor<TOptions> : IOptionsMonitor<TOptions> | ||||
|         where TOptions : class, new() | ||||
|     { | ||||
|         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() | ||||
|             { | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -12,6 +12,7 @@ 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> | ||||
| @@ -34,6 +35,11 @@ public sealed class StellaOpsResourceServerOptions | ||||
|     /// </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> | ||||
| @@ -64,6 +70,11 @@ public sealed class StellaOpsResourceServerOptions | ||||
|     /// </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> | ||||
| @@ -105,12 +116,17 @@ public sealed class StellaOpsResourceServerOptions | ||||
|  | ||||
|         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); | ||||
|   | ||||
| @@ -1,10 +1,12 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Linq; | ||||
| using System.Security.Claims; | ||||
| using System.Threading.Tasks; | ||||
| using Microsoft.AspNetCore.Authorization; | ||||
| using Microsoft.AspNetCore.Http; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using StellaOps.Auth.Abstractions; | ||||
|  | ||||
| namespace StellaOps.Auth.ServerIntegration; | ||||
| @@ -16,15 +18,18 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler< | ||||
| { | ||||
|     private readonly IHttpContextAccessor httpContextAccessor; | ||||
|     private readonly StellaOpsBypassEvaluator bypassEvaluator; | ||||
|     private readonly IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor; | ||||
|     private readonly ILogger<StellaOpsScopeAuthorizationHandler> logger; | ||||
|  | ||||
|     public StellaOpsScopeAuthorizationHandler( | ||||
|         IHttpContextAccessor httpContextAccessor, | ||||
|         StellaOpsBypassEvaluator bypassEvaluator, | ||||
|         IOptionsMonitor<StellaOpsResourceServerOptions> optionsMonitor, | ||||
|         ILogger<StellaOpsScopeAuthorizationHandler> logger) | ||||
|     { | ||||
|         this.httpContextAccessor = httpContextAccessor; | ||||
|         this.bypassEvaluator = bypassEvaluator; | ||||
|         this.optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); | ||||
|         this.logger = logger; | ||||
|     } | ||||
|  | ||||
| @@ -32,25 +37,47 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler< | ||||
|         AuthorizationHandlerContext context, | ||||
|         StellaOpsScopeRequirement requirement) | ||||
|     { | ||||
|         var resourceOptions = optionsMonitor.CurrentValue; | ||||
|         var httpContext = httpContextAccessor.HttpContext; | ||||
|         var combinedScopes = CombineRequiredScopes(resourceOptions.NormalizedScopes, requirement.RequiredScopes); | ||||
|         HashSet<string>? userScopes = null; | ||||
|  | ||||
|         if (context.User?.Identity?.IsAuthenticated == true) | ||||
|         { | ||||
|             userScopes = ExtractScopes(context.User); | ||||
|  | ||||
|             foreach (var scope in requirement.RequiredScopes) | ||||
|             foreach (var scope in combinedScopes) | ||||
|             { | ||||
|                 if (userScopes.Contains(scope)) | ||||
|                 if (!userScopes.Contains(scope)) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 if (TenantAllowed(context.User, resourceOptions, out var normalizedTenant)) | ||||
|                 { | ||||
|                     context.Succeed(requirement); | ||||
|                     return Task.CompletedTask; | ||||
|                 } | ||||
|  | ||||
|                 if (logger.IsEnabled(LogLevel.Debug)) | ||||
|                 { | ||||
|                     var allowedTenants = resourceOptions.NormalizedTenants.Count == 0 | ||||
|                         ? "(none)" | ||||
|                         : string.Join(", ", resourceOptions.NormalizedTenants); | ||||
|  | ||||
|                     logger.LogDebug( | ||||
|                         "Tenant requirement not satisfied. RequiredTenants={RequiredTenants}; PrincipalTenant={PrincipalTenant}; Remote={Remote}", | ||||
|                         allowedTenants, | ||||
|                         normalizedTenant ?? "(none)", | ||||
|                         httpContext?.Connection.RemoteIpAddress); | ||||
|                 } | ||||
|  | ||||
|                 // tenant mismatch cannot be resolved by checking additional scopes for this principal | ||||
|                 break; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var httpContext = httpContextAccessor.HttpContext; | ||||
|  | ||||
|         if (httpContext is not null && bypassEvaluator.ShouldBypass(httpContext, requirement.RequiredScopes)) | ||||
|         if (httpContext is not null && bypassEvaluator.ShouldBypass(httpContext, combinedScopes)) | ||||
|         { | ||||
|             context.Succeed(requirement); | ||||
|             return Task.CompletedTask; | ||||
| @@ -58,21 +85,51 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler< | ||||
|  | ||||
|         if (logger.IsEnabled(LogLevel.Debug)) | ||||
|         { | ||||
|             var required = string.Join(", ", requirement.RequiredScopes); | ||||
|             var required = string.Join(", ", combinedScopes); | ||||
|             var principalScopes = userScopes is null || userScopes.Count == 0 | ||||
|                 ? "(none)" | ||||
|                 : string.Join(", ", userScopes); | ||||
|             var tenantValue = context.User?.FindFirstValue(StellaOpsClaimTypes.Tenant) ?? "(none)"; | ||||
|  | ||||
|             logger.LogDebug( | ||||
|                 "Scope requirement not satisfied. Required={RequiredScopes}; PrincipalScopes={PrincipalScopes}; Remote={Remote}", | ||||
|                 "Scope requirement not satisfied. Required={RequiredScopes}; PrincipalScopes={PrincipalScopes}; Tenant={Tenant}; Remote={Remote}", | ||||
|                 required, | ||||
|                 principalScopes, | ||||
|                 tenantValue, | ||||
|                 httpContext?.Connection.RemoteIpAddress); | ||||
|         } | ||||
|  | ||||
|         return Task.CompletedTask; | ||||
|     } | ||||
|  | ||||
|     private static bool TenantAllowed(ClaimsPrincipal principal, StellaOpsResourceServerOptions options, out string? normalizedTenant) | ||||
|     { | ||||
|         normalizedTenant = null; | ||||
|  | ||||
|         if (options.NormalizedTenants.Count == 0) | ||||
|         { | ||||
|             return true; | ||||
|         } | ||||
|  | ||||
|         var rawTenant = principal.FindFirstValue(StellaOpsClaimTypes.Tenant); | ||||
|         if (string.IsNullOrWhiteSpace(rawTenant)) | ||||
|         { | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         normalizedTenant = rawTenant.Trim().ToLowerInvariant(); | ||||
|  | ||||
|         foreach (var allowed in options.NormalizedTenants) | ||||
|         { | ||||
|             if (string.Equals(allowed, normalizedTenant, StringComparison.Ordinal)) | ||||
|             { | ||||
|                 return true; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     private static HashSet<string> ExtractScopes(ClaimsPrincipal principal) | ||||
|     { | ||||
|         var scopes = new HashSet<string>(StringComparer.Ordinal); | ||||
| @@ -108,4 +165,38 @@ internal sealed class StellaOpsScopeAuthorizationHandler : AuthorizationHandler< | ||||
|  | ||||
|         return scopes; | ||||
|     } | ||||
|  | ||||
|     private static IReadOnlyList<string> CombineRequiredScopes( | ||||
|         IReadOnlyList<string> defaultScopes, | ||||
|         IReadOnlyCollection<string> requirementScopes) | ||||
|     { | ||||
|         if ((defaultScopes is null || defaultScopes.Count == 0) && (requirementScopes is null || requirementScopes.Count == 0)) | ||||
|         { | ||||
|             return Array.Empty<string>(); | ||||
|         } | ||||
|  | ||||
|         if (defaultScopes is null || defaultScopes.Count == 0) | ||||
|         { | ||||
|             return requirementScopes is string[] requirementArray | ||||
|                 ? requirementArray | ||||
|                 : requirementScopes.ToArray(); | ||||
|         } | ||||
|  | ||||
|         var combined = new HashSet<string>(defaultScopes, StringComparer.Ordinal); | ||||
|  | ||||
|         if (requirementScopes is not null) | ||||
|         { | ||||
|             foreach (var scope in requirementScopes) | ||||
|             { | ||||
|                 if (!string.IsNullOrWhiteSpace(scope)) | ||||
|                 { | ||||
|                     combined.Add(scope); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return combined.Count == defaultScopes.Count && requirementScopes is null | ||||
|             ? defaultScopes | ||||
|             : combined.OrderBy(static scope => scope, StringComparer.Ordinal).ToArray(); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -42,10 +42,38 @@ public class StandardClientProvisioningStoreTests | ||||
|         Assert.Equal("bootstrap-client", descriptor!.ClientId); | ||||
|         Assert.True(descriptor.Confidential); | ||||
|         Assert.Contains("client_credentials", descriptor.AllowedGrantTypes); | ||||
|         Assert.Contains("scopeA", descriptor.AllowedScopes); | ||||
|         Assert.Contains("scopea", descriptor.AllowedScopes); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     [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); | ||||
|     } | ||||
|  | ||||
|  | ||||
|     public async Task CreateOrUpdateAsync_StoresAudiences() | ||||
|     { | ||||
|         var store = new TrackingClientStore(); | ||||
|   | ||||
| @@ -1,24 +1,24 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|     <IsAuthorityPlugin>true</IsAuthorityPlugin> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" /> | ||||
|     <PackageReference Include="MongoDB.Driver" Version="3.5.0" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Plugin\StellaOps.Plugin.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|     <IsAuthorityPlugin>true</IsAuthorityPlugin> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.0-rc.2.25502.107" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.0-rc.2.25502.107" /> | ||||
|     <PackageReference Include="MongoDB.Driver" Version="3.5.0" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Authority.Plugins.Abstractions\StellaOps.Authority.Plugins.Abstractions.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Plugin\StellaOps.Plugin.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Authority.Storage.Mongo\StellaOps.Authority.Storage.Mongo.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" /> | ||||
|     <ProjectReference Include="..\..\StellaOps.Cryptography.DependencyInjection\StellaOps.Cryptography.DependencyInjection.csproj" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
|   | ||||
| @@ -65,10 +65,20 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore | ||||
|                 .ToList(); | ||||
|         } | ||||
|  | ||||
|         foreach (var (key, value) in registration.Properties) | ||||
|         { | ||||
|             document.Properties[key] = value; | ||||
|         } | ||||
|         foreach (var (key, value) in registration.Properties) | ||||
|         { | ||||
|             document.Properties[key] = value; | ||||
|         } | ||||
|  | ||||
|         var normalizedTenant = NormalizeTenant(registration.Tenant); | ||||
|         if (normalizedTenant is not null) | ||||
|         { | ||||
|             document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenant; | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             document.Properties.Remove(AuthorityClientMetadataKeys.Tenant); | ||||
|         } | ||||
|  | ||||
|         if (registration.Properties.TryGetValue(AuthorityClientMetadataKeys.SenderConstraint, out var senderConstraintRaw)) | ||||
|         { | ||||
| @@ -176,24 +186,27 @@ internal sealed class StandardClientProvisioningStore : IClientProvisioningStore | ||||
|         return value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); | ||||
|     } | ||||
|  | ||||
|     private static string JoinValues(IReadOnlyCollection<string> values) | ||||
|     { | ||||
|         if (values is null || values.Count == 0) | ||||
|         { | ||||
|             return string.Empty; | ||||
|         } | ||||
|  | ||||
|         return string.Join( | ||||
|             " ", | ||||
|             values | ||||
|                 .Where(static value => !string.IsNullOrWhiteSpace(value)) | ||||
|                 .Select(static value => value.Trim()) | ||||
|                 .OrderBy(static value => value, StringComparer.Ordinal)); | ||||
|     } | ||||
|  | ||||
|     private static AuthorityClientCertificateBinding MapCertificateBinding( | ||||
|         AuthorityClientCertificateBindingRegistration registration, | ||||
|         DateTimeOffset now) | ||||
|     private static string JoinValues(IReadOnlyCollection<string> values) | ||||
|     { | ||||
|         if (values is null || values.Count == 0) | ||||
|         { | ||||
|             return string.Empty; | ||||
|         } | ||||
|  | ||||
|         return string.Join( | ||||
|             " ", | ||||
|             values | ||||
|                 .Where(static value => !string.IsNullOrWhiteSpace(value)) | ||||
|                 .Select(static value => value.Trim()) | ||||
|                 .OrderBy(static value => value, StringComparer.Ordinal)); | ||||
|     } | ||||
|  | ||||
|     private static string? NormalizeTenant(string? value) | ||||
|         => string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); | ||||
|  | ||||
|     private static AuthorityClientCertificateBinding MapCertificateBinding( | ||||
|         AuthorityClientCertificateBindingRegistration registration, | ||||
|         DateTimeOffset now) | ||||
|     { | ||||
|         var subjectAlternativeNames = registration.SubjectAlternativeNames.Count == 0 | ||||
|             ? new List<string>() | ||||
|   | ||||
| @@ -20,12 +20,13 @@ public class AuthorityClientRegistrationTests | ||||
|     [Fact] | ||||
|     public void WithClientSecret_ReturnsCopy() | ||||
|     { | ||||
|         var registration = new AuthorityClientRegistration("cli", false, null, null); | ||||
|         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); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -6,9 +6,11 @@ namespace StellaOps.Authority.Plugins.Abstractions; | ||||
| public static class AuthorityClientMetadataKeys | ||||
| { | ||||
|     public const string AllowedGrantTypes = "allowedGrantTypes"; | ||||
|     public const string AllowedScopes = "allowedScopes"; | ||||
|     public const string Audiences = "audiences"; | ||||
|     public const string RedirectUris = "redirectUris"; | ||||
|     public const string PostLogoutRedirectUris = "postLogoutRedirectUris"; | ||||
|     public const string SenderConstraint = "senderConstraint"; | ||||
| } | ||||
|     public const string AllowedScopes = "allowedScopes"; | ||||
|     public const string Audiences = "audiences"; | ||||
|     public const string RedirectUris = "redirectUris"; | ||||
|     public const string PostLogoutRedirectUris = "postLogoutRedirectUris"; | ||||
|     public const string SenderConstraint = "senderConstraint"; | ||||
|     public const string Tenant = "tenant"; | ||||
|     public const string ServiceIdentity = "serviceIdentity"; | ||||
| } | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -7,11 +7,12 @@ | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0-rc.2.25502.107" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\..\StellaOps.Cryptography\StellaOps.Cryptography.csproj" /> | ||||
|     <ProjectReference Include="..\StellaOps.Auth.Abstractions\StellaOps.Auth.Abstractions.csproj" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
|   | ||||
| @@ -35,6 +35,10 @@ public sealed class AuthorityLoginAttemptDocument | ||||
|     [BsonIgnoreIfNull] | ||||
|     public string? ClientId { get; set; } | ||||
|  | ||||
|     [BsonElement("tenant")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public string? Tenant { get; set; } | ||||
|  | ||||
|     [BsonElement("plugin")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public string? Plugin { get; set; } | ||||
|   | ||||
| @@ -74,6 +74,9 @@ public sealed class AuthorityTokenDocument | ||||
|     [BsonIgnoreIfNull] | ||||
|     public string? SenderNonce { get; set; } | ||||
|  | ||||
|     [BsonElement("tenant")] | ||||
|     [BsonIgnoreIfNull] | ||||
|     public string? Tenant { get; set; } | ||||
|  | ||||
|     [BsonElement("devices")] | ||||
|     [BsonIgnoreIfNull] | ||||
|   | ||||
| @@ -22,7 +22,12 @@ internal sealed class AuthorityLoginAttemptCollectionInitializer : IAuthorityCol | ||||
|             new CreateIndexModel<AuthorityLoginAttemptDocument>( | ||||
|                 Builders<AuthorityLoginAttemptDocument>.IndexKeys | ||||
|                     .Ascending(a => a.CorrelationId), | ||||
|                 new CreateIndexOptions { Name = "login_attempt_correlation", Sparse = true }) | ||||
|                 new CreateIndexOptions { Name = "login_attempt_correlation", Sparse = true }), | ||||
|             new CreateIndexModel<AuthorityLoginAttemptDocument>( | ||||
|                 Builders<AuthorityLoginAttemptDocument>.IndexKeys | ||||
|                     .Ascending(a => a.Tenant) | ||||
|                     .Descending(a => a.OccurredAt), | ||||
|                 new CreateIndexOptions { Name = "login_attempt_tenant_time", Sparse = true }) | ||||
|         }; | ||||
|  | ||||
|         await collection.Indexes.CreateManyAsync(indexModels, cancellationToken).ConfigureAwait(false); | ||||
|   | ||||
| @@ -1,18 +1,18 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="MongoDB.Driver" Version="3.5.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="MongoDB.Driver" Version="3.5.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0-rc.2.25502.107" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Options" Version="10.0.0-rc.2.25502.107" /> | ||||
|   </ItemGroup> | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\..\StellaOps.Configuration\StellaOps.Configuration.csproj" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
|   | ||||
| @@ -101,6 +101,7 @@ public class ClientCredentialsHandlersTests | ||||
|         await handler.HandleAsync(context); | ||||
|  | ||||
|         Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); | ||||
|         Assert.False(context.Transaction.Properties.ContainsKey(AuthorityOpenIddictConstants.ClientTenantProperty)); | ||||
|         Assert.Same(clientDocument, context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty]); | ||||
|  | ||||
|         var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); | ||||
| @@ -108,6 +109,323 @@ public class ClientCredentialsHandlersTests | ||||
|         Assert.Equal(clientDocument.Plugin, context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProviderTransactionProperty]); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ValidateClientCredentials_Allows_NewIngestionScopes() | ||||
|     { | ||||
|         var clientDocument = CreateClient( | ||||
|             secret: "s3cr3t!", | ||||
|             allowedGrantTypes: "client_credentials", | ||||
|             allowedScopes: "advisory:ingest advisory:read", | ||||
|             tenant: "tenant-alpha"); | ||||
|  | ||||
|         var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); | ||||
|         var options = TestHelpers.CreateAuthorityOptions(); | ||||
|         var handler = new ValidateClientCredentialsHandler( | ||||
|             new TestClientStore(clientDocument), | ||||
|             registry, | ||||
|             TestActivitySource, | ||||
|             new TestAuthEventSink(), | ||||
|             new TestRateLimiterMetadataAccessor(), | ||||
|             TimeProvider.System, | ||||
|             new NoopCertificateValidator(), | ||||
|             new HttpContextAccessor(), | ||||
|             options, | ||||
|             NullLogger<ValidateClientCredentialsHandler>.Instance); | ||||
|  | ||||
|         var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "advisory:ingest"); | ||||
|         var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); | ||||
|  | ||||
|         await handler.HandleAsync(context); | ||||
|  | ||||
|         Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); | ||||
|         var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); | ||||
|         Assert.Equal(new[] { "advisory:ingest" }, grantedScopes); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ValidateClientCredentials_RejectsEffectiveWrite_WhenServiceIdentityMissing() | ||||
|     { | ||||
|         var clientDocument = CreateClient( | ||||
|             clientId: "policy-engine", | ||||
|             secret: "s3cr3t!", | ||||
|             allowedGrantTypes: "client_credentials", | ||||
|             allowedScopes: "effective:write findings:read policy:run", | ||||
|             tenant: "tenant-default"); | ||||
|  | ||||
|         var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); | ||||
|         var options = TestHelpers.CreateAuthorityOptions(); | ||||
|         var handler = new ValidateClientCredentialsHandler( | ||||
|             new TestClientStore(clientDocument), | ||||
|             registry, | ||||
|             TestActivitySource, | ||||
|             new TestAuthEventSink(), | ||||
|             new TestRateLimiterMetadataAccessor(), | ||||
|             TimeProvider.System, | ||||
|             new NoopCertificateValidator(), | ||||
|             new HttpContextAccessor(), | ||||
|             options, | ||||
|             NullLogger<ValidateClientCredentialsHandler>.Instance); | ||||
|  | ||||
|         Assert.True(clientDocument.Properties.ContainsKey(AuthorityClientMetadataKeys.Tenant)); | ||||
|         Assert.Equal("tenant-default", clientDocument.Properties[AuthorityClientMetadataKeys.Tenant]); | ||||
|  | ||||
|         var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "effective:write"); | ||||
|         var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); | ||||
|  | ||||
|         await handler.HandleAsync(context); | ||||
|  | ||||
|         Assert.True(context.IsRejected); | ||||
|         Assert.Equal(OpenIddictConstants.Errors.UnauthorizedClient, context.Error); | ||||
|         Assert.Equal("Scope 'effective:write' is reserved for the Policy Engine service identity.", context.ErrorDescription); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ValidateClientCredentials_RejectsEffectiveWrite_WhenTenantMissing() | ||||
|     { | ||||
|         var clientDocument = CreateClient( | ||||
|             clientId: "policy-engine", | ||||
|             secret: "s3cr3t!", | ||||
|             allowedGrantTypes: "client_credentials", | ||||
|             allowedScopes: "effective:write findings:read policy:run"); | ||||
|         clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.PolicyEngine; | ||||
|  | ||||
|         var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); | ||||
|         var options = TestHelpers.CreateAuthorityOptions(); | ||||
|         var handler = new ValidateClientCredentialsHandler( | ||||
|             new TestClientStore(clientDocument), | ||||
|             registry, | ||||
|             TestActivitySource, | ||||
|             new TestAuthEventSink(), | ||||
|             new TestRateLimiterMetadataAccessor(), | ||||
|             TimeProvider.System, | ||||
|             new NoopCertificateValidator(), | ||||
|             new HttpContextAccessor(), | ||||
|             options, | ||||
|             NullLogger<ValidateClientCredentialsHandler>.Instance); | ||||
|  | ||||
|         var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "effective:write"); | ||||
|         var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); | ||||
|  | ||||
|         await handler.HandleAsync(context); | ||||
|  | ||||
|         Assert.True(context.IsRejected); | ||||
|         Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); | ||||
|         Assert.Equal("Policy Engine service identity requires a tenant assignment.", context.ErrorDescription); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ValidateClientCredentials_AllowsEffectiveWrite_ForPolicyEngineServiceIdentity() | ||||
|     { | ||||
|         var clientDocument = CreateClient( | ||||
|             clientId: "policy-engine", | ||||
|             secret: "s3cr3t!", | ||||
|             allowedGrantTypes: "client_credentials", | ||||
|             allowedScopes: "effective:write findings:read policy:run", | ||||
|             tenant: "tenant-default"); | ||||
|         clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.PolicyEngine; | ||||
|  | ||||
|         var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); | ||||
|         var options = TestHelpers.CreateAuthorityOptions(); | ||||
|         var handler = new ValidateClientCredentialsHandler( | ||||
|             new TestClientStore(clientDocument), | ||||
|             registry, | ||||
|             TestActivitySource, | ||||
|             new TestAuthEventSink(), | ||||
|             new TestRateLimiterMetadataAccessor(), | ||||
|             TimeProvider.System, | ||||
|             new NoopCertificateValidator(), | ||||
|             new HttpContextAccessor(), | ||||
|             options, | ||||
|             NullLogger<ValidateClientCredentialsHandler>.Instance); | ||||
|  | ||||
|         var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "effective:write"); | ||||
|         var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); | ||||
|  | ||||
|         await handler.HandleAsync(context); | ||||
|  | ||||
|         Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); | ||||
|         var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); | ||||
|         Assert.Equal(new[] { "effective:write" }, grantedScopes); | ||||
|  | ||||
|         var tenant = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); | ||||
|         Assert.Equal("tenant-default", tenant); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ValidateClientCredentials_RejectsGraphWrite_WhenServiceIdentityMissing() | ||||
|     { | ||||
|         var clientDocument = CreateClient( | ||||
|             clientId: "cartographer-service", | ||||
|             secret: "s3cr3t!", | ||||
|             allowedGrantTypes: "client_credentials", | ||||
|             allowedScopes: "graph:write graph:read", | ||||
|             tenant: "tenant-default"); | ||||
|  | ||||
|         var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); | ||||
|         var options = TestHelpers.CreateAuthorityOptions(); | ||||
|         var handler = new ValidateClientCredentialsHandler( | ||||
|             new TestClientStore(clientDocument), | ||||
|             registry, | ||||
|             TestActivitySource, | ||||
|             new TestAuthEventSink(), | ||||
|             new TestRateLimiterMetadataAccessor(), | ||||
|             TimeProvider.System, | ||||
|             new NoopCertificateValidator(), | ||||
|             new HttpContextAccessor(), | ||||
|             options, | ||||
|             NullLogger<ValidateClientCredentialsHandler>.Instance); | ||||
|  | ||||
|         var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:write"); | ||||
|         var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); | ||||
|  | ||||
|         await handler.HandleAsync(context); | ||||
|  | ||||
|         Assert.True(context.IsRejected); | ||||
|         Assert.Equal(OpenIddictConstants.Errors.UnauthorizedClient, context.Error); | ||||
|         Assert.Equal("Scope 'graph:write' is reserved for the Cartographer service identity.", context.ErrorDescription); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ValidateClientCredentials_RejectsGraphWrite_WhenServiceIdentityMismatch() | ||||
|     { | ||||
|         var clientDocument = CreateClient( | ||||
|             clientId: "cartographer-service", | ||||
|             secret: "s3cr3t!", | ||||
|             allowedGrantTypes: "client_credentials", | ||||
|             allowedScopes: "graph:write graph:read", | ||||
|             tenant: "tenant-default"); | ||||
|         clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.PolicyEngine; | ||||
|  | ||||
|         var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); | ||||
|         var options = TestHelpers.CreateAuthorityOptions(); | ||||
|         var handler = new ValidateClientCredentialsHandler( | ||||
|             new TestClientStore(clientDocument), | ||||
|             registry, | ||||
|             TestActivitySource, | ||||
|             new TestAuthEventSink(), | ||||
|             new TestRateLimiterMetadataAccessor(), | ||||
|             TimeProvider.System, | ||||
|             new NoopCertificateValidator(), | ||||
|             new HttpContextAccessor(), | ||||
|             options, | ||||
|             NullLogger<ValidateClientCredentialsHandler>.Instance); | ||||
|  | ||||
|         var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:write"); | ||||
|         var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); | ||||
|  | ||||
|         await handler.HandleAsync(context); | ||||
|  | ||||
|         Assert.True(context.IsRejected); | ||||
|         Assert.Equal(OpenIddictConstants.Errors.UnauthorizedClient, context.Error); | ||||
|         Assert.Equal("Scope 'graph:write' is reserved for the Cartographer service identity.", context.ErrorDescription); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ValidateClientCredentials_RejectsGraphScopes_WhenTenantMissing() | ||||
|     { | ||||
|         var clientDocument = CreateClient( | ||||
|             clientId: "graph-api", | ||||
|             secret: "s3cr3t!", | ||||
|             allowedGrantTypes: "client_credentials", | ||||
|             allowedScopes: "graph:read graph:export"); | ||||
|  | ||||
|         var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); | ||||
|         var options = TestHelpers.CreateAuthorityOptions(); | ||||
|         var handler = new ValidateClientCredentialsHandler( | ||||
|             new TestClientStore(clientDocument), | ||||
|             registry, | ||||
|             TestActivitySource, | ||||
|             new TestAuthEventSink(), | ||||
|             new TestRateLimiterMetadataAccessor(), | ||||
|             TimeProvider.System, | ||||
|             new NoopCertificateValidator(), | ||||
|             new HttpContextAccessor(), | ||||
|             options, | ||||
|             NullLogger<ValidateClientCredentialsHandler>.Instance); | ||||
|  | ||||
|         var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:read"); | ||||
|         var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); | ||||
|  | ||||
|         await handler.HandleAsync(context); | ||||
|  | ||||
|         Assert.True(context.IsRejected); | ||||
|         Assert.Equal(OpenIddictConstants.Errors.InvalidClient, context.Error); | ||||
|         Assert.Equal("Graph scopes require a tenant assignment.", context.ErrorDescription); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ValidateClientCredentials_AllowsGraphRead_WithTenant() | ||||
|     { | ||||
|         var clientDocument = CreateClient( | ||||
|             clientId: "graph-api", | ||||
|             secret: "s3cr3t!", | ||||
|             allowedGrantTypes: "client_credentials", | ||||
|             allowedScopes: "graph:read graph:export", | ||||
|             tenant: "tenant-default"); | ||||
|  | ||||
|         var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); | ||||
|         var options = TestHelpers.CreateAuthorityOptions(); | ||||
|         var handler = new ValidateClientCredentialsHandler( | ||||
|             new TestClientStore(clientDocument), | ||||
|             registry, | ||||
|             TestActivitySource, | ||||
|             new TestAuthEventSink(), | ||||
|             new TestRateLimiterMetadataAccessor(), | ||||
|             TimeProvider.System, | ||||
|             new NoopCertificateValidator(), | ||||
|             new HttpContextAccessor(), | ||||
|             options, | ||||
|             NullLogger<ValidateClientCredentialsHandler>.Instance); | ||||
|  | ||||
|         var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:read"); | ||||
|         var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); | ||||
|  | ||||
|         await handler.HandleAsync(context); | ||||
|  | ||||
|         Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); | ||||
|         var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); | ||||
|         Assert.Equal(new[] { "graph:read" }, grantedScopes); | ||||
|         var tenant = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); | ||||
|         Assert.Equal("tenant-default", tenant); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ValidateClientCredentials_AllowsGraphWrite_ForCartographerServiceIdentity() | ||||
|     { | ||||
|         var clientDocument = CreateClient( | ||||
|             clientId: "cartographer-service", | ||||
|             secret: "s3cr3t!", | ||||
|             allowedGrantTypes: "client_credentials", | ||||
|             allowedScopes: "graph:write graph:read", | ||||
|             tenant: "tenant-default"); | ||||
|         clientDocument.Properties[AuthorityClientMetadataKeys.ServiceIdentity] = StellaOpsServiceIdentities.Cartographer; | ||||
|  | ||||
|         var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: CreateDescriptor(clientDocument)); | ||||
|         var options = TestHelpers.CreateAuthorityOptions(); | ||||
|         var handler = new ValidateClientCredentialsHandler( | ||||
|             new TestClientStore(clientDocument), | ||||
|             registry, | ||||
|             TestActivitySource, | ||||
|             new TestAuthEventSink(), | ||||
|             new TestRateLimiterMetadataAccessor(), | ||||
|             TimeProvider.System, | ||||
|             new NoopCertificateValidator(), | ||||
|             new HttpContextAccessor(), | ||||
|             options, | ||||
|             NullLogger<ValidateClientCredentialsHandler>.Instance); | ||||
|  | ||||
|         var transaction = CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "graph:write"); | ||||
|         var context = new OpenIddictServerEvents.ValidateTokenRequestContext(transaction); | ||||
|  | ||||
|         await handler.HandleAsync(context); | ||||
|  | ||||
|         Assert.False(context.IsRejected, $"Rejected: {context.Error} - {context.ErrorDescription}"); | ||||
|         var grantedScopes = Assert.IsType<string[]>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty]); | ||||
|         Assert.Equal(new[] { "graph:write" }, grantedScopes); | ||||
|         var tenant = Assert.IsType<string>(context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty]); | ||||
|         Assert.Equal("tenant-default", tenant); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public async Task ValidateClientCredentials_EmitsTamperAuditEvent_WhenUnexpectedParametersPresent() | ||||
|     { | ||||
| @@ -231,6 +549,7 @@ public class ClientCredentialsHandlersTests | ||||
|             registry, | ||||
|             tokenStore, | ||||
|             sessionAccessor, | ||||
|             rateMetadata, | ||||
|             TimeProvider.System, | ||||
|             TestActivitySource, | ||||
|             NullLogger<HandleClientCredentialsHandler>.Instance); | ||||
| @@ -550,7 +869,8 @@ public class ClientCredentialsHandlersTests | ||||
|             clientType: "public", | ||||
|             allowedGrantTypes: "client_credentials", | ||||
|             allowedScopes: "jobs:trigger", | ||||
|             allowedAudiences: "signer"); | ||||
|             allowedAudiences: "signer", | ||||
|             tenant: "Tenant-Alpha"); | ||||
|  | ||||
|         var descriptor = CreateDescriptor(clientDocument); | ||||
|         var registry = CreateRegistry(withClientProvisioning: true, clientDescriptor: descriptor); | ||||
| @@ -582,6 +902,7 @@ public class ClientCredentialsHandlersTests | ||||
|             registry, | ||||
|             tokenStore, | ||||
|             sessionAccessor, | ||||
|             metadataAccessor, | ||||
|             TimeProvider.System, | ||||
|             TestActivitySource, | ||||
|             NullLogger<HandleClientCredentialsHandler>.Instance); | ||||
| @@ -601,6 +922,7 @@ public class ClientCredentialsHandlersTests | ||||
|         Assert.Equal(clientDocument.Plugin, identityProviderClaim); | ||||
|  | ||||
|         var principal = context.Principal ?? throw new InvalidOperationException("Principal missing"); | ||||
|         Assert.Equal("tenant-alpha", principal.FindFirstValue(StellaOpsClaimTypes.Tenant)); | ||||
|         var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId); | ||||
|         Assert.False(string.IsNullOrWhiteSpace(tokenId)); | ||||
|  | ||||
| @@ -616,6 +938,7 @@ public class ClientCredentialsHandlersTests | ||||
|         Assert.Equal(tokenId, persisted.TokenId); | ||||
|         Assert.Equal(clientDocument.ClientId, persisted.ClientId); | ||||
|         Assert.Equal("valid", persisted.Status); | ||||
|         Assert.Equal("tenant-alpha", persisted.Tenant); | ||||
|         Assert.Equal(new[] { "jobs:trigger" }, persisted.Scope); | ||||
|     } | ||||
| } | ||||
| @@ -1169,6 +1492,12 @@ internal sealed class TestRateLimiterMetadataAccessor : IAuthorityRateLimiterMet | ||||
|  | ||||
|     public void SetSubjectId(string? subjectId) => metadata.SubjectId = subjectId; | ||||
|  | ||||
|     public void SetTenant(string? tenant) | ||||
|     { | ||||
|         metadata.Tenant = string.IsNullOrWhiteSpace(tenant) ? null : tenant.Trim().ToLowerInvariant(); | ||||
|         metadata.SetTag("authority.tenant", metadata.Tenant); | ||||
|     } | ||||
|  | ||||
|     public void SetTag(string name, string? value) => metadata.SetTag(name, value); | ||||
| } | ||||
|  | ||||
| @@ -1238,15 +1567,17 @@ internal static class TestHelpers | ||||
|     } | ||||
|  | ||||
|     public static AuthorityClientDocument CreateClient( | ||||
|         string clientId = "concelier", | ||||
|         string? secret = "s3cr3t!", | ||||
|         string clientType = "confidential", | ||||
|         string allowedGrantTypes = "client_credentials", | ||||
|         string allowedScopes = "jobs:read", | ||||
|         string allowedAudiences = "") | ||||
|         string allowedAudiences = "", | ||||
|         string? tenant = null) | ||||
|     { | ||||
|         var document = new AuthorityClientDocument | ||||
|         { | ||||
|             ClientId = "concelier", | ||||
|             ClientId = clientId, | ||||
|             ClientType = clientType, | ||||
|             SecretHash = secret is null ? null : AuthoritySecretHasher.ComputeHash(secret), | ||||
|             Plugin = "standard", | ||||
| @@ -1262,9 +1593,18 @@ internal static class TestHelpers | ||||
|             document.Properties[AuthorityClientMetadataKeys.Audiences] = allowedAudiences; | ||||
|         } | ||||
|  | ||||
|         var normalizedTenant = NormalizeTenant(tenant); | ||||
|         if (normalizedTenant is not null) | ||||
|         { | ||||
|             document.Properties[AuthorityClientMetadataKeys.Tenant] = normalizedTenant; | ||||
|         } | ||||
|  | ||||
|         return document; | ||||
|     } | ||||
|  | ||||
|     private static string? NormalizeTenant(string? value) | ||||
|         => string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); | ||||
|  | ||||
|     public static AuthorityClientDescriptor CreateDescriptor(AuthorityClientDocument document) | ||||
|     { | ||||
|         var allowedGrantTypes = document.Properties.TryGetValue(AuthorityClientMetadataKeys.AllowedGrantTypes, out var grants) ? grants?.Split(' ', StringSplitOptions.RemoveEmptyEntries) : Array.Empty<string>(); | ||||
|   | ||||
| @@ -8,6 +8,7 @@ using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Configuration; | ||||
| using Microsoft.Extensions.Logging.Abstractions; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using MongoDB.Driver; | ||||
| using OpenIddict.Abstractions; | ||||
| using OpenIddict.Server; | ||||
| using OpenIddict.Server.AspNetCore; | ||||
| @@ -15,6 +16,8 @@ using StellaOps.Authority.OpenIddict; | ||||
| using StellaOps.Authority.OpenIddict.Handlers; | ||||
| using StellaOps.Authority.Plugins.Abstractions; | ||||
| using StellaOps.Authority.RateLimiting; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
| using StellaOps.Authority.Storage.Mongo.Stores; | ||||
| using StellaOps.Cryptography.Audit; | ||||
| using Xunit; | ||||
|  | ||||
| @@ -30,15 +33,20 @@ public class PasswordGrantHandlersTests | ||||
|         var sink = new TestAuthEventSink(); | ||||
|         var metadataAccessor = new TestRateLimiterMetadataAccessor(); | ||||
|         var registry = CreateRegistry(new SuccessCredentialStore()); | ||||
|         var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance); | ||||
|         var handle = new HandlePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance); | ||||
|         var clientStore = new StubClientStore(CreateClientDocument()); | ||||
|         var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance); | ||||
|         var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance); | ||||
|  | ||||
|         var transaction = CreatePasswordTransaction("alice", "Password1!"); | ||||
|  | ||||
|         await validate.HandleAsync(new OpenIddictServerEvents.ValidateTokenRequestContext(transaction)); | ||||
|         await handle.HandleAsync(new OpenIddictServerEvents.HandleTokenRequestContext(transaction)); | ||||
|  | ||||
|         Assert.Contains(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Success); | ||||
|         var successEvent = Assert.Single(sink.Events, record => record.EventType == "authority.password.grant" && record.Outcome == AuthEventOutcome.Success); | ||||
|         Assert.Equal("tenant-alpha", successEvent.Tenant.Value); | ||||
|  | ||||
|         var metadata = metadataAccessor.GetMetadata(); | ||||
|         Assert.Equal("tenant-alpha", metadata?.Tenant); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
| @@ -47,8 +55,9 @@ public class PasswordGrantHandlersTests | ||||
|         var sink = new TestAuthEventSink(); | ||||
|         var metadataAccessor = new TestRateLimiterMetadataAccessor(); | ||||
|         var registry = CreateRegistry(new FailureCredentialStore()); | ||||
|         var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance); | ||||
|         var handle = new HandlePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance); | ||||
|         var clientStore = new StubClientStore(CreateClientDocument()); | ||||
|         var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance); | ||||
|         var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance); | ||||
|  | ||||
|         var transaction = CreatePasswordTransaction("alice", "BadPassword!"); | ||||
|  | ||||
| @@ -64,8 +73,9 @@ public class PasswordGrantHandlersTests | ||||
|         var sink = new TestAuthEventSink(); | ||||
|         var metadataAccessor = new TestRateLimiterMetadataAccessor(); | ||||
|         var registry = CreateRegistry(new LockoutCredentialStore()); | ||||
|         var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance); | ||||
|         var handle = new HandlePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance); | ||||
|         var clientStore = new StubClientStore(CreateClientDocument()); | ||||
|         var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance); | ||||
|         var handle = new HandlePasswordGrantHandler(registry, clientStore, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<HandlePasswordGrantHandler>.Instance); | ||||
|  | ||||
|         var transaction = CreatePasswordTransaction("alice", "Locked!"); | ||||
|  | ||||
| @@ -81,7 +91,8 @@ public class PasswordGrantHandlersTests | ||||
|         var sink = new TestAuthEventSink(); | ||||
|         var metadataAccessor = new TestRateLimiterMetadataAccessor(); | ||||
|         var registry = CreateRegistry(new SuccessCredentialStore()); | ||||
|         var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance); | ||||
|         var clientStore = new StubClientStore(CreateClientDocument()); | ||||
|         var validate = new ValidatePasswordGrantHandler(registry, TestActivitySource, sink, metadataAccessor, clientStore, TimeProvider.System, NullLogger<ValidatePasswordGrantHandler>.Instance); | ||||
|  | ||||
|         var transaction = CreatePasswordTransaction("alice", "Password1!"); | ||||
|         transaction.Request?.SetParameter("unexpected_param", "value"); | ||||
| @@ -113,7 +124,9 @@ public class PasswordGrantHandlersTests | ||||
|         { | ||||
|             GrantType = OpenIddictConstants.GrantTypes.Password, | ||||
|             Username = username, | ||||
|             Password = password | ||||
|             Password = password, | ||||
|             ClientId = "cli-app", | ||||
|             Scope = "jobs:trigger" | ||||
|         }; | ||||
|  | ||||
|         return new OpenIddictServerTransaction | ||||
| @@ -124,6 +137,21 @@ public class PasswordGrantHandlersTests | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     private static AuthorityClientDocument CreateClientDocument() | ||||
|     { | ||||
|         var document = new AuthorityClientDocument | ||||
|         { | ||||
|             ClientId = "cli-app", | ||||
|             ClientType = "public" | ||||
|         }; | ||||
|  | ||||
|         document.Properties[AuthorityClientMetadataKeys.AllowedGrantTypes] = "password"; | ||||
|         document.Properties[AuthorityClientMetadataKeys.AllowedScopes] = "jobs:trigger"; | ||||
|         document.Properties[AuthorityClientMetadataKeys.Tenant] = "tenant-alpha"; | ||||
|  | ||||
|         return document; | ||||
|     } | ||||
|  | ||||
|     private sealed class StubIdentityProviderPlugin : IIdentityProviderPlugin | ||||
|     { | ||||
|         public StubIdentityProviderPlugin(string name, IUserCredentialStore store) | ||||
| @@ -220,4 +248,26 @@ public class PasswordGrantHandlersTests | ||||
|             => ValueTask.FromResult<AuthorityUserDescriptor?>(null); | ||||
|     } | ||||
|  | ||||
|     private sealed class StubClientStore : IAuthorityClientStore | ||||
|     { | ||||
|         private readonly Dictionary<string, AuthorityClientDocument> clients; | ||||
|  | ||||
|         public StubClientStore(params AuthorityClientDocument[] documents) | ||||
|         { | ||||
|             clients = documents.ToDictionary(static doc => doc.ClientId, doc => doc, StringComparer.OrdinalIgnoreCase); | ||||
|         } | ||||
|  | ||||
|         public ValueTask<AuthorityClientDocument?> FindByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|         { | ||||
|             clients.TryGetValue(clientId, out var document); | ||||
|             return ValueTask.FromResult(document); | ||||
|         } | ||||
|  | ||||
|         public ValueTask UpsertAsync(AuthorityClientDocument document, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|             => throw new NotImplementedException(); | ||||
|  | ||||
|         public ValueTask<bool> DeleteByClientIdAsync(string clientId, CancellationToken cancellationToken, IClientSessionHandle? session = null) | ||||
|             => throw new NotImplementedException(); | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
| @@ -52,7 +52,8 @@ public sealed class TokenPersistenceIntegrationTests | ||||
|         var clientDocument = TestHelpers.CreateClient( | ||||
|             secret: "s3cr3t!", | ||||
|             allowedGrantTypes: "client_credentials", | ||||
|             allowedScopes: "jobs:trigger jobs:read"); | ||||
|             allowedScopes: "jobs:trigger jobs:read", | ||||
|             tenant: "tenant-alpha"); | ||||
|  | ||||
|         await clientStore.UpsertAsync(clientDocument, CancellationToken.None); | ||||
|  | ||||
| @@ -66,7 +67,7 @@ public sealed class TokenPersistenceIntegrationTests | ||||
|         var sessionAccessor = scope.ServiceProvider.GetRequiredService<IAuthorityMongoSessionAccessor>(); | ||||
|         var options = TestHelpers.CreateAuthorityOptions(); | ||||
|         var validateHandler = new ValidateClientCredentialsHandler(clientStore, registry, TestActivitySource, authSink, metadataAccessor, clock, new NoopCertificateValidator(), new HttpContextAccessor(), options, NullLogger<ValidateClientCredentialsHandler>.Instance); | ||||
|         var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, sessionAccessor, clock, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance); | ||||
|         var handleHandler = new HandleClientCredentialsHandler(registry, tokenStore, sessionAccessor, metadataAccessor, clock, TestActivitySource, NullLogger<HandleClientCredentialsHandler>.Instance); | ||||
|         var persistHandler = new PersistTokensHandler(tokenStore, sessionAccessor, clock, TestActivitySource, NullLogger<PersistTokensHandler>.Instance); | ||||
|  | ||||
|         var transaction = TestHelpers.CreateTokenTransaction(clientDocument.ClientId, "s3cr3t!", scope: "jobs:trigger"); | ||||
| @@ -83,6 +84,7 @@ public sealed class TokenPersistenceIntegrationTests | ||||
|         var principal = Assert.IsType<ClaimsPrincipal>(handleContext.Principal); | ||||
|         var tokenId = principal.GetClaim(OpenIddictConstants.Claims.JwtId); | ||||
|         Assert.False(string.IsNullOrWhiteSpace(tokenId)); | ||||
|         Assert.Equal("tenant-alpha", metadataAccessor.GetMetadata()?.Tenant); | ||||
|  | ||||
|         var signInContext = new OpenIddictServerEvents.ProcessSignInContext(transaction) | ||||
|         { | ||||
| @@ -100,6 +102,7 @@ public sealed class TokenPersistenceIntegrationTests | ||||
|         Assert.Equal(issuedAt, stored.CreatedAt); | ||||
|         Assert.Equal(issuedAt.AddMinutes(15), stored.ExpiresAt); | ||||
|         Assert.Equal(new[] { "jobs:trigger" }, stored.Scope); | ||||
|         Assert.Equal("tenant-alpha", stored.Tenant); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
| @@ -119,7 +122,8 @@ public sealed class TokenPersistenceIntegrationTests | ||||
|             secret: null, | ||||
|             clientType: "public", | ||||
|             allowedGrantTypes: "password refresh_token", | ||||
|             allowedScopes: "openid profile jobs:read"); | ||||
|             allowedScopes: "openid profile jobs:read", | ||||
|             tenant: "tenant-alpha"); | ||||
|  | ||||
|         await clientStore.UpsertAsync(clientDocument, CancellationToken.None); | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,150 @@ | ||||
| using System; | ||||
| using System.IO; | ||||
| using System.Linq; | ||||
| using System.Text; | ||||
| using System.Text.Json; | ||||
| using System.Threading; | ||||
| using Microsoft.Extensions.DependencyInjection; | ||||
| using Microsoft.Extensions.DependencyInjection.Extensions; | ||||
| using Microsoft.Extensions.FileProviders; | ||||
| using Microsoft.Extensions.Hosting; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using Microsoft.Extensions.Options; | ||||
| using Microsoft.IdentityModel.Tokens; | ||||
| using StellaOps.Authority.Permalinks; | ||||
| using StellaOps.Authority.Signing; | ||||
| using StellaOps.Configuration; | ||||
| using StellaOps.Cryptography; | ||||
| using StellaOps.Cryptography.DependencyInjection; | ||||
| using StellaOps.Auth.Abstractions; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Authority.Tests.Permalinks; | ||||
|  | ||||
| public sealed class VulnPermalinkServiceTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task CreateAsync_IssuesSignedTokenWithExpectedClaims() | ||||
|     { | ||||
|         var tempDir = Directory.CreateTempSubdirectory("authority-permalink-tests").FullName; | ||||
|         var keyRelative = "permalink.pem"; | ||||
|         try | ||||
|         { | ||||
|             CreateEcPrivateKey(Path.Combine(tempDir, keyRelative)); | ||||
|  | ||||
|             var options = new StellaOpsAuthorityOptions | ||||
|             { | ||||
|                 Issuer = new Uri("https://authority.test"), | ||||
|                 Storage = { ConnectionString = "mongodb://localhost/test" }, | ||||
|                 Signing = | ||||
|                 { | ||||
|                     Enabled = true, | ||||
|                     ActiveKeyId = "permalink-key", | ||||
|                     KeyPath = keyRelative, | ||||
|                     Algorithm = SignatureAlgorithms.Es256, | ||||
|                     KeySource = "file", | ||||
|                     Provider = "default" | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             var fakeTime = new FakeTimeProvider(DateTimeOffset.Parse("2025-10-26T12:00:00Z")); | ||||
|  | ||||
|             using var provider = BuildProvider(tempDir, options, fakeTime); | ||||
|             // Ensure signing keys are loaded | ||||
|             provider.GetRequiredService<AuthoritySigningKeyManager>(); | ||||
|  | ||||
|             var service = provider.GetRequiredService<VulnPermalinkService>(); | ||||
|             var state = JsonDocument.Parse("{\"vulnId\":\"CVE-2025-1234\"}").RootElement; | ||||
|             var request = new VulnPermalinkRequest( | ||||
|                 Tenant: "tenant-a", | ||||
|                 ResourceKind: "vulnerability", | ||||
|                 State: state, | ||||
|                 ExpiresInSeconds: null, | ||||
|                 Environment: "prod"); | ||||
|  | ||||
|             var expectedNow = fakeTime.GetUtcNow(); | ||||
|  | ||||
|             var response = await service.CreateAsync(request, default); | ||||
|  | ||||
|             Assert.NotNull(response.Token); | ||||
|             Assert.Equal(expectedNow, response.IssuedAt); | ||||
|             Assert.Equal(expectedNow.AddHours(24), response.ExpiresAt); | ||||
|             Assert.Contains(StellaOpsScopes.VulnRead, response.Scopes); | ||||
|  | ||||
|             var parts = response.Token.Split('.'); | ||||
|             Assert.Equal(3, parts.Length); | ||||
|  | ||||
|             var payloadBytes = Base64UrlEncoder.DecodeBytes(parts[1]); | ||||
|             using var payloadDocument = JsonDocument.Parse(payloadBytes); | ||||
|             var payload = payloadDocument.RootElement; | ||||
|  | ||||
|             Assert.Equal("vulnerability", payload.GetProperty("type").GetString()); | ||||
|             Assert.Equal("tenant-a", payload.GetProperty("tenant").GetString()); | ||||
|             Assert.Equal("prod", payload.GetProperty("environment").GetString()); | ||||
|             Assert.Equal(expectedNow.ToUnixTimeSeconds(), payload.GetProperty("iat").GetInt64()); | ||||
|             Assert.Equal(expectedNow.ToUnixTimeSeconds(), payload.GetProperty("nbf").GetInt64()); | ||||
|             Assert.Equal(expectedNow.AddHours(24).ToUnixTimeSeconds(), payload.GetProperty("exp").GetInt64()); | ||||
|  | ||||
|             var scopes = payload.GetProperty("scopes").EnumerateArray().Select(element => element.GetString()).ToArray(); | ||||
|             Assert.Contains(StellaOpsScopes.VulnRead, scopes); | ||||
|  | ||||
|             var resource = payload.GetProperty("resource"); | ||||
|             Assert.Equal("vulnerability", resource.GetProperty("kind").GetString()); | ||||
|             Assert.Equal("CVE-2025-1234", resource.GetProperty("state").GetProperty("vulnId").GetString()); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 Directory.Delete(tempDir, recursive: true); | ||||
|             } | ||||
|             catch | ||||
|             { | ||||
|                 // ignore cleanup failures | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static ServiceProvider BuildProvider(string basePath, StellaOpsAuthorityOptions options, TimeProvider timeProvider) | ||||
|     { | ||||
|         var services = new ServiceCollection(); | ||||
|         services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.Debug)); | ||||
|         services.AddSingleton<IHostEnvironment>(new TestHostEnvironment(basePath)); | ||||
|         services.AddSingleton(options); | ||||
|         services.AddSingleton<IOptions<StellaOpsAuthorityOptions>>(Options.Create(options)); | ||||
|         services.AddSingleton(timeProvider); | ||||
|         services.AddStellaOpsCrypto(); | ||||
|         services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthoritySigningKeySource, FileAuthoritySigningKeySource>()); | ||||
|         services.AddSingleton<AuthoritySigningKeyManager>(); | ||||
|         services.AddSingleton<VulnPermalinkService>(); | ||||
|  | ||||
|         return services.BuildServiceProvider(); | ||||
|     } | ||||
|  | ||||
|     private static void CreateEcPrivateKey(string path) | ||||
|     { | ||||
|         Directory.CreateDirectory(Path.GetDirectoryName(path)!); | ||||
|         using var ecdsa = System.Security.Cryptography.ECDsa.Create(System.Security.Cryptography.ECCurve.NamedCurves.nistP256); | ||||
|         var pem = ecdsa.ExportECPrivateKeyPem(); | ||||
|         File.WriteAllText(path, pem); | ||||
|     } | ||||
|  | ||||
|     private sealed class TestHostEnvironment : IHostEnvironment | ||||
|     { | ||||
|         public TestHostEnvironment(string contentRoot) | ||||
|         { | ||||
|             ContentRootPath = contentRoot; | ||||
|             ContentRootFileProvider = new PhysicalFileProvider(contentRoot); | ||||
|             EnvironmentName = Environments.Development; | ||||
|             ApplicationName = "StellaOps.Authority.Tests"; | ||||
|         } | ||||
|  | ||||
|         public string EnvironmentName { get; set; } | ||||
|  | ||||
|         public string ApplicationName { get; set; } | ||||
|  | ||||
|         public string ContentRootPath { get; set; } | ||||
|  | ||||
|         public IFileProvider ContentRootFileProvider { get; set; } | ||||
|     } | ||||
| } | ||||
| @@ -18,6 +18,7 @@ public class AuthorityRateLimiterMetadataAccessorTests | ||||
|         accessor.SetClientId("client-123"); | ||||
|         accessor.SetTag("custom", "tag"); | ||||
|         accessor.SetSubjectId("subject-1"); | ||||
|         accessor.SetTenant("Tenant-Alpha"); | ||||
|  | ||||
|         var metadata = accessor.GetMetadata(); | ||||
|         Assert.NotNull(metadata); | ||||
| @@ -25,6 +26,8 @@ public class AuthorityRateLimiterMetadataAccessorTests | ||||
|         Assert.Equal("subject-1", metadata.SubjectId); | ||||
|         Assert.Equal("client-123", metadata.Tags["authority.client_id"]); | ||||
|         Assert.Equal("subject-1", metadata.Tags["authority.subject_id"]); | ||||
|         Assert.Equal("tenant-alpha", metadata.Tenant); | ||||
|         Assert.Equal("tenant-alpha", metadata.Tags["authority.tenant"]); | ||||
|         Assert.Equal("tag", metadata.Tags["custom"]); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -60,6 +60,11 @@ internal sealed class AuthorityAuditSink : IAuthEventSink | ||||
|             OccurredAt = record.OccurredAt | ||||
|         }; | ||||
|  | ||||
|         if (record.Tenant.HasValue) | ||||
|         { | ||||
|             document.Tenant = record.Tenant.Value; | ||||
|         } | ||||
|  | ||||
|         if (record.Scopes is { Count: > 0 }) | ||||
|         { | ||||
|             document.Scopes = record.Scopes | ||||
| @@ -142,6 +147,8 @@ internal sealed class AuthorityAuditSink : IAuthEventSink | ||||
|             AddClassified(entries, "audit.client.provider", client.Provider); | ||||
|         } | ||||
|  | ||||
|         AddClassified(entries, "audit.tenant", record.Tenant); | ||||
|  | ||||
|         if (record.Network is { } network) | ||||
|         { | ||||
|             AddClassified(entries, "audit.network.remote", network.RemoteAddress); | ||||
|   | ||||
| @@ -14,15 +14,16 @@ internal static class AuthorityOpenIddictConstants | ||||
|     internal const string AuditConfidentialProperty = "authority:audit_confidential"; | ||||
|     internal const string AuditRequestedScopesProperty = "authority:audit_requested_scopes"; | ||||
|     internal const string AuditGrantedScopesProperty = "authority:audit_granted_scopes"; | ||||
|     internal const string AuditInvalidScopeProperty = "authority:audit_invalid_scope"; | ||||
|     internal const string ClientSenderConstraintProperty = "authority:client_sender_constraint"; | ||||
|     internal const string SenderConstraintProperty = "authority:sender_constraint"; | ||||
|     internal const string DpopKeyThumbprintProperty = "authority:dpop_thumbprint"; | ||||
|     internal const string DpopProofJwtIdProperty = "authority:dpop_jti"; | ||||
|     internal const string DpopIssuedAtProperty = "authority:dpop_iat"; | ||||
|     internal const string DpopConsumedNonceProperty = "authority:dpop_nonce"; | ||||
|     internal const string ConfirmationClaimType = "cnf"; | ||||
|     internal const string SenderConstraintClaimType = "authority_sender_constraint"; | ||||
|     internal const string MtlsCertificateThumbprintProperty = "authority:mtls_thumbprint"; | ||||
|     internal const string MtlsCertificateHexProperty = "authority:mtls_thumbprint_hex"; | ||||
| } | ||||
|     internal const string AuditInvalidScopeProperty = "authority:audit_invalid_scope"; | ||||
|     internal const string ClientSenderConstraintProperty = "authority:client_sender_constraint"; | ||||
|     internal const string SenderConstraintProperty = "authority:sender_constraint"; | ||||
|     internal const string DpopKeyThumbprintProperty = "authority:dpop_thumbprint"; | ||||
|     internal const string DpopProofJwtIdProperty = "authority:dpop_jti"; | ||||
|     internal const string DpopIssuedAtProperty = "authority:dpop_iat"; | ||||
|     internal const string DpopConsumedNonceProperty = "authority:dpop_nonce"; | ||||
|     internal const string ConfirmationClaimType = "cnf"; | ||||
|     internal const string SenderConstraintClaimType = "authority_sender_constraint"; | ||||
|     internal const string MtlsCertificateThumbprintProperty = "authority:mtls_thumbprint"; | ||||
|     internal const string MtlsCertificateHexProperty = "authority:mtls_thumbprint_hex"; | ||||
|     internal const string ClientTenantProperty = "authority:client_tenant"; | ||||
| } | ||||
|   | ||||
| @@ -39,6 +39,7 @@ internal static class ClientCredentialsAuditHelper | ||||
|         string? reason, | ||||
|         string? clientId, | ||||
|         string? providerName, | ||||
|         string? tenant, | ||||
|         bool? confidential, | ||||
|         IReadOnlyList<string> requestedScopes, | ||||
|         IReadOnlyList<string> grantedScopes, | ||||
| @@ -54,6 +55,7 @@ internal static class ClientCredentialsAuditHelper | ||||
|         var network = BuildNetwork(metadata); | ||||
|         var normalizedGranted = NormalizeScopes(grantedScopes); | ||||
|         var properties = BuildProperties(confidential, requestedScopes, invalidScope, extraProperties); | ||||
|         var normalizedTenant = NormalizeTenant(tenant); | ||||
|  | ||||
|         return new AuthEventRecord | ||||
|         { | ||||
| @@ -66,6 +68,7 @@ internal static class ClientCredentialsAuditHelper | ||||
|             Client = client, | ||||
|             Scopes = normalizedGranted, | ||||
|             Network = network, | ||||
|             Tenant = ClassifiedString.Public(normalizedTenant), | ||||
|             Properties = properties | ||||
|         }; | ||||
|     } | ||||
| @@ -76,6 +79,7 @@ internal static class ClientCredentialsAuditHelper | ||||
|         AuthorityRateLimiterMetadata? metadata, | ||||
|         string? clientId, | ||||
|         string? providerName, | ||||
|         string? tenant, | ||||
|         bool? confidential, | ||||
|         IEnumerable<string> unexpectedParameters) | ||||
|     { | ||||
| @@ -127,6 +131,7 @@ internal static class ClientCredentialsAuditHelper | ||||
|             reason: reason, | ||||
|             clientId: clientId, | ||||
|             providerName: providerName, | ||||
|             tenant: tenant, | ||||
|             confidential: confidential, | ||||
|             requestedScopes: Array.Empty<string>(), | ||||
|             grantedScopes: Array.Empty<string>(), | ||||
| @@ -249,4 +254,7 @@ internal static class ClientCredentialsAuditHelper | ||||
|  | ||||
|     private static string? Normalize(string? value) | ||||
|         => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); | ||||
|  | ||||
|     private static string? NormalizeTenant(string? value) | ||||
|         => string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); | ||||
| } | ||||
|   | ||||
| @@ -95,14 +95,15 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | ||||
|         if (unexpectedParameters.Count > 0) | ||||
|         { | ||||
|             var providerHint = context.Request.GetParameter(AuthorityOpenIddictConstants.ProviderParameterName)?.Value?.ToString(); | ||||
|             var tamperRecord = ClientCredentialsAuditHelper.CreateTamperRecord( | ||||
|                 timeProvider, | ||||
|                 context.Transaction, | ||||
|                 metadata, | ||||
|                 clientId, | ||||
|                 providerHint, | ||||
|                 confidential: null, | ||||
|                 unexpectedParameters); | ||||
|             var tamperRecord = ClientCredentialsAuditHelper.CreateTamperRecord( | ||||
|                 timeProvider, | ||||
|                 context.Transaction, | ||||
|                 metadata, | ||||
|                 clientId, | ||||
|                 providerHint, | ||||
|                 tenant: metadata?.Tenant, | ||||
|                 confidential: null, | ||||
|                 unexpectedParameters); | ||||
|  | ||||
|             await auditSink.WriteAsync(tamperRecord, context.CancellationToken).ConfigureAwait(false); | ||||
|         } | ||||
| @@ -250,20 +251,111 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.AuditGrantedScopesProperty] = resolvedScopes.Scopes; | ||||
|  | ||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty] = document; | ||||
|         var grantedScopes = resolvedScopes.Scopes; | ||||
|  | ||||
|         bool EnsureTenantAssigned() | ||||
|         { | ||||
|             if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTenantProperty, out var tenantObj) && | ||||
|                 tenantObj is string existingTenant && | ||||
|                 !string.IsNullOrWhiteSpace(existingTenant)) | ||||
|             { | ||||
|                 return true; | ||||
|             } | ||||
|  | ||||
|             if (document.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantProperty)) | ||||
|             { | ||||
|                 var normalizedTenant = ClientCredentialHandlerHelpers.NormalizeTenant(tenantProperty); | ||||
|                 if (normalizedTenant is not null) | ||||
|                 { | ||||
|                     context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty] = normalizedTenant; | ||||
|                     metadataAccessor.SetTenant(normalizedTenant); | ||||
|                     activity?.SetTag("authority.tenant", normalizedTenant); | ||||
|                     return true; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             context.Transaction.Properties.Remove(AuthorityOpenIddictConstants.ClientTenantProperty); | ||||
|             return false; | ||||
|         } | ||||
|  | ||||
|         var hasGraphRead = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.GraphRead) >= 0; | ||||
|         var hasGraphWrite = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.GraphWrite) >= 0; | ||||
|         var hasGraphExport = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.GraphExport) >= 0; | ||||
|         var hasGraphSimulate = grantedScopes.Length > 0 && Array.IndexOf(grantedScopes, StellaOpsScopes.GraphSimulate) >= 0; | ||||
|         var graphScopesRequested = hasGraphRead || hasGraphWrite || hasGraphExport || hasGraphSimulate; | ||||
|  | ||||
|         var tenantScopeForAudit = hasGraphWrite | ||||
|             ? StellaOpsScopes.GraphWrite | ||||
|             : hasGraphExport | ||||
|                 ? StellaOpsScopes.GraphExport | ||||
|                 : hasGraphSimulate | ||||
|                     ? StellaOpsScopes.GraphSimulate | ||||
|                     : StellaOpsScopes.GraphRead; | ||||
|  | ||||
|         if (graphScopesRequested && !EnsureTenantAssigned()) | ||||
|         { | ||||
|             context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = tenantScopeForAudit; | ||||
|             context.Reject(OpenIddictConstants.Errors.InvalidClient, "Graph scopes require a tenant assignment."); | ||||
|             logger.LogWarning( | ||||
|                 "Client credentials validation failed for {ClientId}: graph scopes require tenant assignment.", | ||||
|                 document.ClientId); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         if (grantedScopes.Length > 0 && | ||||
|             Array.IndexOf(grantedScopes, StellaOpsScopes.EffectiveWrite) >= 0) | ||||
|         { | ||||
|             if (!document.Properties.TryGetValue(AuthorityClientMetadataKeys.ServiceIdentity, out var serviceIdentity) || | ||||
|                 string.IsNullOrWhiteSpace(serviceIdentity) || | ||||
|                 !string.Equals(serviceIdentity.Trim(), StellaOpsServiceIdentities.PolicyEngine, StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.EffectiveWrite; | ||||
|                 context.Reject(OpenIddictConstants.Errors.UnauthorizedClient, "Scope 'effective:write' is reserved for the Policy Engine service identity."); | ||||
|                 logger.LogWarning( | ||||
|                     "Client credentials validation failed for {ClientId}: effective:write scope requires Policy Engine service identity marker.", | ||||
|                     document.ClientId); | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             if (!EnsureTenantAssigned()) | ||||
|             { | ||||
|                 context.Reject(OpenIddictConstants.Errors.InvalidClient, "Policy Engine service identity requires a tenant assignment."); | ||||
|                 logger.LogWarning( | ||||
|                     "Client credentials validation failed for {ClientId}: effective:write scope requires tenant assignment.", | ||||
|                     document.ClientId); | ||||
|                 return; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         if (hasGraphWrite) | ||||
|         { | ||||
|             if (!document.Properties.TryGetValue(AuthorityClientMetadataKeys.ServiceIdentity, out var serviceIdentity) || | ||||
|                 string.IsNullOrWhiteSpace(serviceIdentity) || | ||||
|                 !string.Equals(serviceIdentity.Trim(), StellaOpsServiceIdentities.Cartographer, StringComparison.OrdinalIgnoreCase)) | ||||
|             { | ||||
|                 context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = StellaOpsScopes.GraphWrite; | ||||
|                 context.Reject(OpenIddictConstants.Errors.UnauthorizedClient, "Scope 'graph:write' is reserved for the Cartographer service identity."); | ||||
|                 logger.LogWarning( | ||||
|                     "Client credentials validation failed for {ClientId}: graph:write scope requires Cartographer service identity marker.", | ||||
|                     document.ClientId); | ||||
|                 return; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.AuditGrantedScopesProperty] = grantedScopes; | ||||
|  | ||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty] = document; | ||||
|         if (providerMetadata is not null) | ||||
|         { | ||||
|             context.Transaction.Properties[AuthorityOpenIddictConstants.ClientProviderTransactionProperty] = providerMetadata.Name; | ||||
|             activity?.SetTag("authority.identity_provider", providerMetadata.Name); | ||||
|         } | ||||
|  | ||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty] = resolvedScopes.Scopes; | ||||
|         logger.LogInformation("Client credentials validated for {ClientId}.", document.ClientId); | ||||
|     } | ||||
|         finally | ||||
|         { | ||||
|  | ||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty] = resolvedScopes.Scopes; | ||||
|         logger.LogInformation("Client credentials validated for {ClientId}.", document.ClientId); | ||||
|     } | ||||
|         finally | ||||
|         { | ||||
|             var outcome = context.IsRejected ? AuthEventOutcome.Failure : AuthEventOutcome.Success; | ||||
|             var reason = context.IsRejected ? context.ErrorDescription : null; | ||||
|             var auditClientId = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditClientIdProperty, out var clientValue) | ||||
| @@ -281,23 +373,27 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | ||||
|             var granted = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditGrantedScopesProperty, out var grantedValue) && grantedValue is string[] grantedArray | ||||
|                 ? (IReadOnlyList<string>)grantedArray | ||||
|                 : Array.Empty<string>(); | ||||
|             var invalidScope = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditInvalidScopeProperty, out var invalidValue) | ||||
|                 ? invalidValue as string | ||||
|                 : null; | ||||
|  | ||||
|             var record = ClientCredentialsAuditHelper.CreateRecord( | ||||
|                 timeProvider, | ||||
|                 context.Transaction, | ||||
|                 metadata, | ||||
|             var invalidScope = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.AuditInvalidScopeProperty, out var invalidValue) | ||||
|                 ? invalidValue as string | ||||
|                 : null; | ||||
|             var tenantValueForAudit = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTenantProperty, out var tenantAuditObj) && tenantAuditObj is string tenantAudit | ||||
|                 ? tenantAudit | ||||
|                 : metadata?.Tenant; | ||||
|  | ||||
|             var record = ClientCredentialsAuditHelper.CreateRecord( | ||||
|                 timeProvider, | ||||
|                 context.Transaction, | ||||
|                 metadata, | ||||
|                 null, | ||||
|                 outcome, | ||||
|                 reason, | ||||
|                 auditClientId, | ||||
|                 providerName, | ||||
|                 confidentialValue, | ||||
|                 requested, | ||||
|                 granted, | ||||
|                 invalidScope); | ||||
|                 outcome, | ||||
|                 reason, | ||||
|                 auditClientId, | ||||
|                 providerName, | ||||
|                 tenantValueForAudit, | ||||
|                 confidentialValue, | ||||
|                 requested, | ||||
|                 granted, | ||||
|                 invalidScope); | ||||
|  | ||||
|             await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false); | ||||
|         } | ||||
| @@ -390,31 +486,34 @@ internal sealed class ValidateClientCredentialsHandler : IOpenIddictServerHandle | ||||
|         return set; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleTokenRequestContext> | ||||
| { | ||||
|     private readonly IAuthorityIdentityProviderRegistry registry; | ||||
|     private readonly IAuthorityTokenStore tokenStore; | ||||
|     private readonly IAuthorityMongoSessionAccessor sessionAccessor; | ||||
|     private readonly TimeProvider clock; | ||||
|     private readonly ActivitySource activitySource; | ||||
|     private readonly ILogger<HandleClientCredentialsHandler> logger; | ||||
|  | ||||
|     public HandleClientCredentialsHandler( | ||||
|         IAuthorityIdentityProviderRegistry registry, | ||||
|         IAuthorityTokenStore tokenStore, | ||||
|         IAuthorityMongoSessionAccessor sessionAccessor, | ||||
|         TimeProvider clock, | ||||
|         ActivitySource activitySource, | ||||
|         ILogger<HandleClientCredentialsHandler> logger) | ||||
|     { | ||||
|         this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); | ||||
|         this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore)); | ||||
|         this.sessionAccessor = sessionAccessor ?? throw new ArgumentNullException(nameof(sessionAccessor)); | ||||
|         this.clock = clock ?? throw new ArgumentNullException(nameof(clock)); | ||||
|         this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
| internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleTokenRequestContext> | ||||
| { | ||||
|     private readonly IAuthorityIdentityProviderRegistry registry; | ||||
|     private readonly IAuthorityTokenStore tokenStore; | ||||
|     private readonly IAuthorityMongoSessionAccessor sessionAccessor; | ||||
|     private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor; | ||||
|     private readonly TimeProvider clock; | ||||
|     private readonly ActivitySource activitySource; | ||||
|     private readonly ILogger<HandleClientCredentialsHandler> logger; | ||||
|  | ||||
|     public HandleClientCredentialsHandler( | ||||
|         IAuthorityIdentityProviderRegistry registry, | ||||
|         IAuthorityTokenStore tokenStore, | ||||
|         IAuthorityMongoSessionAccessor sessionAccessor, | ||||
|         IAuthorityRateLimiterMetadataAccessor metadataAccessor, | ||||
|         TimeProvider clock, | ||||
|         ActivitySource activitySource, | ||||
|         ILogger<HandleClientCredentialsHandler> logger) | ||||
|     { | ||||
|         this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); | ||||
|         this.tokenStore = tokenStore ?? throw new ArgumentNullException(nameof(tokenStore)); | ||||
|         this.sessionAccessor = sessionAccessor ?? throw new ArgumentNullException(nameof(sessionAccessor)); | ||||
|         this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor)); | ||||
|         this.clock = clock ?? throw new ArgumentNullException(nameof(clock)); | ||||
|         this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async ValueTask HandleAsync(OpenIddictServerEvents.HandleTokenRequestContext context) | ||||
|     { | ||||
| @@ -468,21 +567,41 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler< | ||||
|         activity?.SetTag("authority.grant_type", OpenIddictConstants.GrantTypes.ClientCredentials); | ||||
|  | ||||
|         var tokenId = identity.GetClaim(OpenIddictConstants.Claims.JwtId); | ||||
|         if (string.IsNullOrEmpty(tokenId)) | ||||
|         { | ||||
|             tokenId = Guid.NewGuid().ToString("N"); | ||||
|             identity.SetClaim(OpenIddictConstants.Claims.JwtId, tokenId); | ||||
|         } | ||||
|  | ||||
|         identity.SetDestinations(static claim => claim.Type switch | ||||
|         { | ||||
|             OpenIddictConstants.Claims.Subject => new[] { OpenIddictConstants.Destinations.AccessToken }, | ||||
|             OpenIddictConstants.Claims.ClientId => new[] { OpenIddictConstants.Destinations.AccessToken }, | ||||
|             OpenIddictConstants.Claims.JwtId => new[] { OpenIddictConstants.Destinations.AccessToken }, | ||||
|             StellaOpsClaimTypes.IdentityProvider => new[] { OpenIddictConstants.Destinations.AccessToken }, | ||||
|             _ => new[] { OpenIddictConstants.Destinations.AccessToken } | ||||
|         }); | ||||
|  | ||||
|         if (string.IsNullOrEmpty(tokenId)) | ||||
|         { | ||||
|             tokenId = Guid.NewGuid().ToString("N"); | ||||
|             identity.SetClaim(OpenIddictConstants.Claims.JwtId, tokenId); | ||||
|         } | ||||
|  | ||||
|         identity.SetDestinations(static claim => claim.Type switch | ||||
|         { | ||||
|             OpenIddictConstants.Claims.Subject => new[] { OpenIddictConstants.Destinations.AccessToken }, | ||||
|             OpenIddictConstants.Claims.ClientId => new[] { OpenIddictConstants.Destinations.AccessToken }, | ||||
|             OpenIddictConstants.Claims.JwtId => new[] { OpenIddictConstants.Destinations.AccessToken }, | ||||
|             StellaOpsClaimTypes.IdentityProvider => new[] { OpenIddictConstants.Destinations.AccessToken }, | ||||
|             _ => new[] { OpenIddictConstants.Destinations.AccessToken } | ||||
|         }); | ||||
|  | ||||
|         string? tenant = null; | ||||
|         if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTenantProperty, out var tenantValue) && | ||||
|             tenantValue is string storedTenant && | ||||
|             !string.IsNullOrWhiteSpace(storedTenant)) | ||||
|         { | ||||
|             tenant = storedTenant; | ||||
|         } | ||||
|         else if (document.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantProperty)) | ||||
|         { | ||||
|             tenant = ClientCredentialHandlerHelpers.NormalizeTenant(tenantProperty); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(tenant)) | ||||
|         { | ||||
|             identity.SetClaim(StellaOpsClaimTypes.Tenant, tenant); | ||||
|             context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty] = tenant; | ||||
|             metadataAccessor.SetTenant(tenant); | ||||
|             activity?.SetTag("authority.tenant", tenant); | ||||
|         } | ||||
|  | ||||
|         var (providerHandle, descriptor) = await ResolveProviderAsync(context, document).ConfigureAwait(false); | ||||
|         if (context.IsRejected) | ||||
|         { | ||||
| @@ -541,6 +660,14 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler< | ||||
|             { | ||||
|                 var enrichmentContext = new AuthorityClaimsEnrichmentContext(provider.Context, user: null, descriptor); | ||||
|                 await provider.ClaimsEnricher.EnrichAsync(identity, enrichmentContext, context.CancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|                 if (!string.IsNullOrWhiteSpace(descriptor.Tenant)) | ||||
|                 { | ||||
|                     identity.SetClaim(StellaOpsClaimTypes.Tenant, descriptor.Tenant); | ||||
|                     context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty] = descriptor.Tenant; | ||||
|                     metadataAccessor.SetTenant(descriptor.Tenant); | ||||
|                     activity?.SetTag("authority.tenant", descriptor.Tenant); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             var session = await sessionAccessor.GetSessionAsync(context.CancellationToken).ConfigureAwait(false); | ||||
| @@ -667,15 +794,22 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler< | ||||
|             senderThumbprint = mtlsThumbprint; | ||||
|         } | ||||
|  | ||||
|         if (senderThumbprint is not null) | ||||
|         { | ||||
|             record.SenderKeyThumbprint = senderThumbprint; | ||||
|         } | ||||
|  | ||||
|         if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopConsumedNonceProperty, out var nonceObj) && | ||||
|             nonceObj is string nonce && | ||||
|             !string.IsNullOrWhiteSpace(nonce)) | ||||
|         { | ||||
|         if (senderThumbprint is not null) | ||||
|         { | ||||
|             record.SenderKeyThumbprint = senderThumbprint; | ||||
|         } | ||||
|  | ||||
|         if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTenantProperty, out var tenantObj) && | ||||
|             tenantObj is string tenantValue && | ||||
|             !string.IsNullOrWhiteSpace(tenantValue)) | ||||
|         { | ||||
|             record.Tenant = tenantValue; | ||||
|         } | ||||
|  | ||||
|         if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.DpopConsumedNonceProperty, out var nonceObj) && | ||||
|             nonceObj is string nonce && | ||||
|             !string.IsNullOrWhiteSpace(nonce)) | ||||
|         { | ||||
|             record.SenderNonce = nonce; | ||||
|         } | ||||
|  | ||||
| @@ -739,15 +873,18 @@ internal sealed class HandleClientCredentialsHandler : IOpenIddictServerHandler< | ||||
|  | ||||
| internal static class ClientCredentialHandlerHelpers | ||||
| { | ||||
|     public static IReadOnlyList<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); | ||||
|     } | ||||
|     public static IReadOnlyList<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); | ||||
|     } | ||||
|  | ||||
|     public static string? NormalizeTenant(string? value) | ||||
|         => string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); | ||||
|  | ||||
|     public static (string[] Scopes, string? InvalidScope) ResolveGrantedScopes( | ||||
|         IReadOnlyCollection<string> allowedScopes, | ||||
|   | ||||
| @@ -624,31 +624,35 @@ internal sealed class ValidateDpopProofHandler : IOpenIddictServerHandler<OpenId | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         if (nonceExpiresAt is { } expiresAt) | ||||
|         { | ||||
|             properties.Add(new AuthEventProperty | ||||
|             { | ||||
|                 Name = "dpop.nonce.expires_at", | ||||
|                 Value = ClassifiedString.Public(expiresAt.ToString("O", CultureInfo.InvariantCulture)) | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         var confidential = string.Equals(clientDocument.ClientType, "confidential", StringComparison.OrdinalIgnoreCase); | ||||
|  | ||||
|         var record = ClientCredentialsAuditHelper.CreateRecord( | ||||
|             clock, | ||||
|             context.Transaction, | ||||
|             metadata, | ||||
|             clientSecret: null, | ||||
|             outcome, | ||||
|             reason, | ||||
|             clientDocument.ClientId, | ||||
|             providerName: clientDocument.Plugin, | ||||
|             confidential, | ||||
|             requestedScopes: Array.Empty<string>(), | ||||
|             grantedScopes: Array.Empty<string>(), | ||||
|             invalidScope: null, | ||||
|             extraProperties: properties, | ||||
|         if (nonceExpiresAt is { } expiresAt) | ||||
|         { | ||||
|             properties.Add(new AuthEventProperty | ||||
|             { | ||||
|                 Name = "dpop.nonce.expires_at", | ||||
|                 Value = ClassifiedString.Public(expiresAt.ToString("O", CultureInfo.InvariantCulture)) | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         var confidential = string.Equals(clientDocument.ClientType, "confidential", StringComparison.OrdinalIgnoreCase); | ||||
|         var tenant = clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantValue) | ||||
|             ? tenantValue?.Trim().ToLowerInvariant() | ||||
|             : null; | ||||
|  | ||||
|         var record = ClientCredentialsAuditHelper.CreateRecord( | ||||
|             clock, | ||||
|             context.Transaction, | ||||
|             metadata, | ||||
|             clientSecret: null, | ||||
|             outcome, | ||||
|             reason, | ||||
|             clientDocument.ClientId, | ||||
|             providerName: clientDocument.Plugin, | ||||
|             tenant, | ||||
|             confidential, | ||||
|             requestedScopes: Array.Empty<string>(), | ||||
|             grantedScopes: Array.Empty<string>(), | ||||
|             invalidScope: null, | ||||
|             extraProperties: properties, | ||||
|             eventType: eventType); | ||||
|  | ||||
|         await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false); | ||||
|   | ||||
| @@ -10,9 +10,12 @@ using OpenIddict.Abstractions; | ||||
| using OpenIddict.Extensions; | ||||
| using OpenIddict.Server; | ||||
| using OpenIddict.Server.AspNetCore; | ||||
| using StellaOps.Auth.Abstractions; | ||||
| using StellaOps.Authority.OpenIddict; | ||||
| using StellaOps.Authority.Plugins.Abstractions; | ||||
| using StellaOps.Authority.RateLimiting; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
| using StellaOps.Authority.Storage.Mongo.Stores; | ||||
| using StellaOps.Cryptography.Audit; | ||||
|  | ||||
| namespace StellaOps.Authority.OpenIddict.Handlers; | ||||
| @@ -23,6 +26,7 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op | ||||
|     private readonly ActivitySource activitySource; | ||||
|     private readonly IAuthEventSink auditSink; | ||||
|     private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor; | ||||
|     private readonly IAuthorityClientStore clientStore; | ||||
|     private readonly TimeProvider timeProvider; | ||||
|     private readonly ILogger<ValidatePasswordGrantHandler> logger; | ||||
|  | ||||
| @@ -31,6 +35,7 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op | ||||
|         ActivitySource activitySource, | ||||
|         IAuthEventSink auditSink, | ||||
|         IAuthorityRateLimiterMetadataAccessor metadataAccessor, | ||||
|         IAuthorityClientStore clientStore, | ||||
|         TimeProvider timeProvider, | ||||
|         ILogger<ValidatePasswordGrantHandler> logger) | ||||
|     { | ||||
| @@ -38,6 +43,7 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op | ||||
|         this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); | ||||
|         this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink)); | ||||
|         this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor)); | ||||
|         this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore)); | ||||
|         this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
| @@ -67,6 +73,129 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op | ||||
|  | ||||
|         var requestedScopesInput = context.Request.GetScopes(); | ||||
|         var requestedScopes = requestedScopesInput.IsDefaultOrEmpty ? Array.Empty<string>() : requestedScopesInput.ToArray(); | ||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.AuditRequestedScopesProperty] = requestedScopes; | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(clientId)) | ||||
|         { | ||||
|             var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord( | ||||
|                 timeProvider, | ||||
|                 context.Transaction, | ||||
|                 metadata, | ||||
|                 AuthEventOutcome.Failure, | ||||
|                 "Client identifier is required for password grant.", | ||||
|                 clientId: null, | ||||
|                 providerName: null, | ||||
|                 tenant: null, | ||||
|                 user: null, | ||||
|                 username: context.Request.Username, | ||||
|                 scopes: requestedScopes, | ||||
|                 retryAfter: null, | ||||
|                 failureCode: AuthorityCredentialFailureCode.InvalidCredentials, | ||||
|                 extraProperties: null); | ||||
|  | ||||
|             await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             context.Reject(OpenIddictConstants.Errors.InvalidClient, "Client identifier is required."); | ||||
|             logger.LogWarning("Password grant validation failed: missing client_id for {Username}.", context.Request.Username); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false); | ||||
|         if (clientDocument is null || clientDocument.Disabled) | ||||
|         { | ||||
|             var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord( | ||||
|                 timeProvider, | ||||
|                 context.Transaction, | ||||
|                 metadata, | ||||
|                 AuthEventOutcome.Failure, | ||||
|                 "Client is not permitted for password grant.", | ||||
|                 clientId, | ||||
|                 providerName: null, | ||||
|                 tenant: null, | ||||
|                 user: null, | ||||
|                 username: context.Request.Username, | ||||
|                 scopes: requestedScopes, | ||||
|                 retryAfter: null, | ||||
|                 failureCode: AuthorityCredentialFailureCode.InvalidCredentials, | ||||
|                 extraProperties: null); | ||||
|  | ||||
|             await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             context.Reject(OpenIddictConstants.Errors.InvalidClient, "The specified client is not permitted."); | ||||
|             logger.LogWarning("Password grant validation failed: client {ClientId} disabled or missing.", clientId); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty] = clientDocument; | ||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.AuditClientIdProperty] = clientId; | ||||
|  | ||||
|         var tenant = PasswordGrantAuditHelper.NormalizeTenant(clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantValue) ? tenantValue : null); | ||||
|         if (!string.IsNullOrWhiteSpace(tenant)) | ||||
|         { | ||||
|             context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty] = tenant; | ||||
|             metadataAccessor.SetTenant(tenant); | ||||
|             activity?.SetTag("authority.tenant", tenant); | ||||
|         } | ||||
|  | ||||
|         var allowedGrantTypes = ClientCredentialHandlerHelpers.Split(clientDocument.Properties, AuthorityClientMetadataKeys.AllowedGrantTypes); | ||||
|         if (allowedGrantTypes.Count > 0 && | ||||
|             !allowedGrantTypes.Any(static grant => string.Equals(grant, OpenIddictConstants.GrantTypes.Password, StringComparison.Ordinal))) | ||||
|         { | ||||
|             var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord( | ||||
|                 timeProvider, | ||||
|                 context.Transaction, | ||||
|                 metadata, | ||||
|                 AuthEventOutcome.Failure, | ||||
|                 "Password grant is not permitted for this client.", | ||||
|                 clientId, | ||||
|                 providerName: null, | ||||
|                 tenant, | ||||
|                 user: null, | ||||
|                 username: context.Request.Username, | ||||
|                 scopes: requestedScopes, | ||||
|                 retryAfter: null, | ||||
|                 failureCode: AuthorityCredentialFailureCode.InvalidCredentials, | ||||
|                 extraProperties: null); | ||||
|  | ||||
|             await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             context.Reject(OpenIddictConstants.Errors.UnauthorizedClient, "Password grant is not permitted for this client."); | ||||
|             logger.LogWarning("Password grant validation failed for client {ClientId}: grant type not allowed.", clientId); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var allowedScopes = ClientCredentialHandlerHelpers.Split(clientDocument.Properties, AuthorityClientMetadataKeys.AllowedScopes); | ||||
|         var resolvedScopes = ClientCredentialHandlerHelpers.ResolveGrantedScopes(allowedScopes, requestedScopes); | ||||
|  | ||||
|         if (resolvedScopes.InvalidScope is not null) | ||||
|         { | ||||
|             context.Transaction.Properties[AuthorityOpenIddictConstants.AuditInvalidScopeProperty] = resolvedScopes.InvalidScope; | ||||
|  | ||||
|             var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord( | ||||
|                 timeProvider, | ||||
|                 context.Transaction, | ||||
|                 metadata, | ||||
|                 AuthEventOutcome.Failure, | ||||
|                 $"Scope '{resolvedScopes.InvalidScope}' is not permitted for this client.", | ||||
|                 clientId, | ||||
|                 providerName: null, | ||||
|                 tenant, | ||||
|                 user: null, | ||||
|                 username: context.Request.Username, | ||||
|                 scopes: requestedScopes, | ||||
|                 retryAfter: null, | ||||
|                 failureCode: AuthorityCredentialFailureCode.InvalidCredentials, | ||||
|                 extraProperties: null); | ||||
|  | ||||
|             await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             context.Reject(OpenIddictConstants.Errors.InvalidScope, $"Scope '{resolvedScopes.InvalidScope}' is not allowed for this client."); | ||||
|             logger.LogWarning("Password grant validation failed for client {ClientId}: scope {Scope} not permitted.", clientId, resolvedScopes.InvalidScope); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.AuditGrantedScopesProperty] = resolvedScopes.Scopes; | ||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty] = resolvedScopes.Scopes; | ||||
|  | ||||
|         var unexpectedParameters = TokenRequestTamperInspector.GetUnexpectedPasswordGrantParameters(context.Request); | ||||
|         if (unexpectedParameters.Count > 0) | ||||
| @@ -78,6 +207,7 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op | ||||
|                 metadata, | ||||
|                 clientId, | ||||
|                 providerHint, | ||||
|                 tenant, | ||||
|                 context.Request.Username, | ||||
|                 requestedScopes, | ||||
|                 unexpectedParameters); | ||||
| @@ -96,6 +226,7 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op | ||||
|                 selection.Description, | ||||
|                 clientId, | ||||
|                 providerName: null, | ||||
|                 tenant, | ||||
|                 user: null, | ||||
|                 username: context.Request.Username, | ||||
|                 scopes: requestedScopes, | ||||
| @@ -122,6 +253,7 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op | ||||
|                 "Both username and password must be provided.", | ||||
|                 clientId, | ||||
|                 providerName: selectedProvider.Name, | ||||
|                 tenant, | ||||
|                 user: null, | ||||
|                 username: context.Request.Username, | ||||
|                 scopes: requestedScopes, | ||||
| @@ -145,6 +277,7 @@ internal sealed class ValidatePasswordGrantHandler : IOpenIddictServerHandler<Op | ||||
| internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<OpenIddictServerEvents.HandleTokenRequestContext> | ||||
| { | ||||
|     private readonly IAuthorityIdentityProviderRegistry registry; | ||||
|     private readonly IAuthorityClientStore clientStore; | ||||
|     private readonly ActivitySource activitySource; | ||||
|     private readonly IAuthEventSink auditSink; | ||||
|     private readonly IAuthorityRateLimiterMetadataAccessor metadataAccessor; | ||||
| @@ -153,6 +286,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|  | ||||
|     public HandlePasswordGrantHandler( | ||||
|         IAuthorityIdentityProviderRegistry registry, | ||||
|         IAuthorityClientStore clientStore, | ||||
|         ActivitySource activitySource, | ||||
|         IAuthEventSink auditSink, | ||||
|         IAuthorityRateLimiterMetadataAccessor metadataAccessor, | ||||
| @@ -160,6 +294,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|         ILogger<HandlePasswordGrantHandler> logger) | ||||
|     { | ||||
|         this.registry = registry ?? throw new ArgumentNullException(nameof(registry)); | ||||
|         this.clientStore = clientStore ?? throw new ArgumentNullException(nameof(clientStore)); | ||||
|         this.activitySource = activitySource ?? throw new ArgumentNullException(nameof(activitySource)); | ||||
|         this.auditSink = auditSink ?? throw new ArgumentNullException(nameof(auditSink)); | ||||
|         this.metadataAccessor = metadataAccessor ?? throw new ArgumentNullException(nameof(metadataAccessor)); | ||||
| @@ -192,6 +327,65 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|  | ||||
|         var requestedScopesInput = context.Request.GetScopes(); | ||||
|         var requestedScopes = requestedScopesInput.IsDefaultOrEmpty ? Array.Empty<string>() : requestedScopesInput.ToArray(); | ||||
|         var grantedScopes = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientGrantedScopesProperty, out var grantedValue) && | ||||
|                             grantedValue is string[] grantedArray | ||||
|             ? (IReadOnlyList<string>)grantedArray | ||||
|             : requestedScopes; | ||||
|  | ||||
|         AuthorityClientDocument? clientDocument = null; | ||||
|         if (context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ClientTransactionProperty, out var clientValue) && | ||||
|             clientValue is AuthorityClientDocument storedClient) | ||||
|         { | ||||
|             clientDocument = storedClient; | ||||
|         } | ||||
|         else if (!string.IsNullOrWhiteSpace(clientId)) | ||||
|         { | ||||
|             clientDocument = await clientStore.FindByClientIdAsync(clientId, context.CancellationToken).ConfigureAwait(false); | ||||
|         } | ||||
|  | ||||
|         if (clientDocument is null || clientDocument.Disabled) | ||||
|         { | ||||
|             var record = PasswordGrantAuditHelper.CreatePasswordGrantRecord( | ||||
|                 timeProvider, | ||||
|                 context.Transaction, | ||||
|                 metadata, | ||||
|                 AuthEventOutcome.Failure, | ||||
|                 "Client is not permitted for password grant.", | ||||
|                 clientId, | ||||
|                 providerName: null, | ||||
|                 tenant: null, | ||||
|                 user: null, | ||||
|                 username: context.Request.Username, | ||||
|                 scopes: requestedScopes, | ||||
|                 retryAfter: null, | ||||
|                 failureCode: AuthorityCredentialFailureCode.InvalidCredentials, | ||||
|                 extraProperties: null); | ||||
|  | ||||
|             await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false); | ||||
|  | ||||
|             context.Reject(OpenIddictConstants.Errors.InvalidClient, "The specified client is not permitted."); | ||||
|             logger.LogWarning("Password grant handling failed: client {ClientId} disabled or missing.", clientId); | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTransactionProperty] = clientDocument; | ||||
|  | ||||
|         if (grantedScopes.Count == 0) | ||||
|         { | ||||
|             var allowedScopes = ClientCredentialHandlerHelpers.Split(clientDocument.Properties, AuthorityClientMetadataKeys.AllowedScopes); | ||||
|             var resolvedScopes = ClientCredentialHandlerHelpers.ResolveGrantedScopes(allowedScopes, requestedScopes); | ||||
|             grantedScopes = resolvedScopes.InvalidScope is null ? resolvedScopes.Scopes : Array.Empty<string>(); | ||||
|             context.Transaction.Properties[AuthorityOpenIddictConstants.ClientGrantedScopesProperty] = grantedScopes; | ||||
|         } | ||||
|  | ||||
|         var tenant = PasswordGrantAuditHelper.NormalizeTenant( | ||||
|             clientDocument.Properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var tenantValue) ? tenantValue : null); | ||||
|         if (!string.IsNullOrWhiteSpace(tenant)) | ||||
|         { | ||||
|             context.Transaction.Properties[AuthorityOpenIddictConstants.ClientTenantProperty] = tenant; | ||||
|             metadataAccessor.SetTenant(tenant); | ||||
|             activity?.SetTag("authority.tenant", tenant); | ||||
|         } | ||||
|  | ||||
|         var providerName = context.Transaction.Properties.TryGetValue(AuthorityOpenIddictConstants.ProviderTransactionProperty, out var value) | ||||
|             ? value as string | ||||
| @@ -206,15 +400,16 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|                     timeProvider, | ||||
|                     context.Transaction, | ||||
|                     metadata, | ||||
|                     AuthEventOutcome.Failure, | ||||
|                     "Unable to resolve the requested identity provider.", | ||||
|                     clientId, | ||||
|                     providerName, | ||||
|                     user: null, | ||||
|                     username: context.Request.Username, | ||||
|                     scopes: requestedScopes, | ||||
|                     retryAfter: null, | ||||
|                     failureCode: AuthorityCredentialFailureCode.UnknownError, | ||||
|                 AuthEventOutcome.Failure, | ||||
|                 "Unable to resolve the requested identity provider.", | ||||
|                 clientId, | ||||
|                 providerName, | ||||
|                 tenant, | ||||
|                 user: null, | ||||
|                 username: context.Request.Username, | ||||
|                 scopes: requestedScopes, | ||||
|                 retryAfter: null, | ||||
|                 failureCode: AuthorityCredentialFailureCode.UnknownError, | ||||
|                     extraProperties: null); | ||||
|  | ||||
|                 await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false); | ||||
| @@ -233,15 +428,16 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|                     timeProvider, | ||||
|                     context.Transaction, | ||||
|                     metadata, | ||||
|                     AuthEventOutcome.Failure, | ||||
|                     selection.Description, | ||||
|                     clientId, | ||||
|                     providerName: null, | ||||
|                     user: null, | ||||
|                     username: context.Request.Username, | ||||
|                     scopes: requestedScopes, | ||||
|                     retryAfter: null, | ||||
|                     failureCode: AuthorityCredentialFailureCode.InvalidCredentials, | ||||
|                 AuthEventOutcome.Failure, | ||||
|                 selection.Description, | ||||
|                 clientId, | ||||
|                 providerName: null, | ||||
|                 tenant, | ||||
|                 user: null, | ||||
|                 username: context.Request.Username, | ||||
|                 scopes: requestedScopes, | ||||
|                 retryAfter: null, | ||||
|                 failureCode: AuthorityCredentialFailureCode.InvalidCredentials, | ||||
|                     extraProperties: null); | ||||
|  | ||||
|                 await auditSink.WriteAsync(record, context.CancellationToken).ConfigureAwait(false); | ||||
| @@ -275,6 +471,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|                 "Both username and password must be provided.", | ||||
|                 clientId, | ||||
|                 providerMetadata.Name, | ||||
|                 tenant, | ||||
|                 user: null, | ||||
|                 username: username, | ||||
|                 scopes: requestedScopes, | ||||
| @@ -308,6 +505,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|                 verification.Message, | ||||
|                 clientId, | ||||
|                 providerMetadata.Name, | ||||
|                 tenant, | ||||
|                 verification.User, | ||||
|                 username, | ||||
|                 scopes: requestedScopes, | ||||
| @@ -344,6 +542,11 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|             identity.AddClaim(new Claim(OpenIddictConstants.Claims.Role, role)); | ||||
|         } | ||||
|  | ||||
|         if (!string.IsNullOrWhiteSpace(tenant)) | ||||
|         { | ||||
|             identity.SetClaim(StellaOpsClaimTypes.Tenant, tenant); | ||||
|         } | ||||
|  | ||||
|         identity.SetDestinations(static claim => claim.Type switch | ||||
|         { | ||||
|             OpenIddictConstants.Claims.Subject => new[] { OpenIddictConstants.Destinations.AccessToken, OpenIddictConstants.Destinations.IdentityToken }, | ||||
| @@ -354,7 +557,7 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|         }); | ||||
|  | ||||
|         var principal = new ClaimsPrincipal(identity); | ||||
|         principal.SetScopes(context.Request.GetScopes()); | ||||
|         principal.SetScopes(grantedScopes); | ||||
|  | ||||
|         var enrichmentContext = new AuthorityClaimsEnrichmentContext(provider.Context, verification.User, null); | ||||
|         await provider.ClaimsEnricher.EnrichAsync(identity, enrichmentContext, context.CancellationToken).ConfigureAwait(false); | ||||
| @@ -367,9 +570,10 @@ internal sealed class HandlePasswordGrantHandler : IOpenIddictServerHandler<Open | ||||
|             verification.Message, | ||||
|             clientId, | ||||
|             providerMetadata.Name, | ||||
|             tenant, | ||||
|             verification.User, | ||||
|             username, | ||||
|             scopes: requestedScopes, | ||||
|             scopes: grantedScopes, | ||||
|             retryAfter: null, | ||||
|             failureCode: null, | ||||
|             extraProperties: verification.AuditProperties); | ||||
| @@ -410,6 +614,7 @@ internal static class PasswordGrantAuditHelper | ||||
|         string? reason, | ||||
|         string? clientId, | ||||
|         string? providerName, | ||||
|         string? tenant, | ||||
|         AuthorityUserDescriptor? user, | ||||
|         string? username, | ||||
|         IEnumerable<string>? scopes, | ||||
| @@ -423,6 +628,7 @@ internal static class PasswordGrantAuditHelper | ||||
|  | ||||
|         var correlationId = EnsureCorrelationId(transaction); | ||||
|         var normalizedScopes = NormalizeScopes(scopes); | ||||
|         var normalizedTenant = NormalizeTenant(tenant); | ||||
|         var subject = BuildSubject(user, username, providerName); | ||||
|         var client = BuildClient(clientId, providerName); | ||||
|         var network = BuildNetwork(metadata); | ||||
| @@ -439,6 +645,7 @@ internal static class PasswordGrantAuditHelper | ||||
|             Client = client, | ||||
|             Scopes = normalizedScopes, | ||||
|             Network = network, | ||||
|             Tenant = ClassifiedString.Public(normalizedTenant), | ||||
|             Properties = properties | ||||
|         }; | ||||
|     } | ||||
| @@ -517,8 +724,9 @@ internal static class PasswordGrantAuditHelper | ||||
|     { | ||||
|         var remote = Normalize(metadata?.RemoteIp); | ||||
|         var forwarded = Normalize(metadata?.ForwardedFor); | ||||
|         var userAgent = Normalize(metadata?.UserAgent); | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(remote) && string.IsNullOrWhiteSpace(forwarded)) | ||||
|         if (string.IsNullOrWhiteSpace(remote) && string.IsNullOrWhiteSpace(forwarded) && string.IsNullOrWhiteSpace(userAgent)) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
| @@ -526,7 +734,8 @@ internal static class PasswordGrantAuditHelper | ||||
|         return new AuthEventNetwork | ||||
|         { | ||||
|             RemoteAddress = ClassifiedString.Personal(remote), | ||||
|             ForwardedFor = ClassifiedString.Personal(forwarded) | ||||
|             ForwardedFor = ClassifiedString.Personal(forwarded), | ||||
|             UserAgent = ClassifiedString.Personal(userAgent) | ||||
|         }; | ||||
|     } | ||||
|  | ||||
| @@ -603,12 +812,16 @@ internal static class PasswordGrantAuditHelper | ||||
|     private static string? Normalize(string? value) | ||||
|         => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); | ||||
|  | ||||
|     internal static string? NormalizeTenant(string? value) | ||||
|         => string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); | ||||
|  | ||||
|     internal static AuthEventRecord CreateTamperRecord( | ||||
|         TimeProvider timeProvider, | ||||
|         OpenIddictServerTransaction transaction, | ||||
|         AuthorityRateLimiterMetadata? metadata, | ||||
|         string? clientId, | ||||
|         string? providerName, | ||||
|         string? tenant, | ||||
|         string? username, | ||||
|         IEnumerable<string>? scopes, | ||||
|         IEnumerable<string> unexpectedParameters) | ||||
| @@ -651,6 +864,7 @@ internal static class PasswordGrantAuditHelper | ||||
|             reason, | ||||
|             clientId, | ||||
|             providerName, | ||||
|             tenant, | ||||
|             user: null, | ||||
|             username, | ||||
|             scopes, | ||||
|   | ||||
| @@ -10,11 +10,12 @@ using System.Threading.Tasks; | ||||
| using Microsoft.Extensions.Logging; | ||||
| using OpenIddict.Abstractions; | ||||
| using OpenIddict.Extensions; | ||||
| using OpenIddict.Server; | ||||
| using MongoDB.Driver; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
| using StellaOps.Authority.Storage.Mongo.Sessions; | ||||
| using StellaOps.Authority.Storage.Mongo.Stores; | ||||
| using OpenIddict.Server; | ||||
| using MongoDB.Driver; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
| using StellaOps.Authority.Storage.Mongo.Sessions; | ||||
| using StellaOps.Authority.Storage.Mongo.Stores; | ||||
| using StellaOps.Auth.Abstractions; | ||||
|  | ||||
| namespace StellaOps.Authority.OpenIddict.Handlers; | ||||
|  | ||||
| @@ -81,17 +82,23 @@ internal sealed class PersistTokensHandler : IOpenIddictServerHandler<OpenIddict | ||||
|     { | ||||
|         var tokenId = EnsureTokenId(principal); | ||||
|         var scopes = ExtractScopes(principal); | ||||
|         var document = new AuthorityTokenDocument | ||||
|         { | ||||
|             TokenId = tokenId, | ||||
|             Type = tokenType, | ||||
|             SubjectId = principal.GetClaim(OpenIddictConstants.Claims.Subject), | ||||
|             ClientId = principal.GetClaim(OpenIddictConstants.Claims.ClientId), | ||||
|             Scope = scopes, | ||||
|             Status = "valid", | ||||
|             CreatedAt = issuedAt, | ||||
|             ExpiresAt = TryGetExpiration(principal) | ||||
|         }; | ||||
|         var document = new AuthorityTokenDocument | ||||
|         { | ||||
|             TokenId = tokenId, | ||||
|             Type = tokenType, | ||||
|             SubjectId = principal.GetClaim(OpenIddictConstants.Claims.Subject), | ||||
|             ClientId = principal.GetClaim(OpenIddictConstants.Claims.ClientId), | ||||
|             Scope = scopes, | ||||
|             Status = "valid", | ||||
|             CreatedAt = issuedAt, | ||||
|             ExpiresAt = TryGetExpiration(principal) | ||||
|         }; | ||||
|  | ||||
|         var tenantClaim = principal.GetClaim(StellaOpsClaimTypes.Tenant); | ||||
|         if (!string.IsNullOrWhiteSpace(tenantClaim)) | ||||
|         { | ||||
|             document.Tenant = tenantClaim.Trim().ToLowerInvariant(); | ||||
|         } | ||||
|  | ||||
|         var senderConstraint = principal.GetClaim(AuthorityOpenIddictConstants.SenderConstraintClaimType); | ||||
|         if (!string.IsNullOrWhiteSpace(senderConstraint)) | ||||
|   | ||||
| @@ -116,6 +116,10 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open | ||||
|         if (!context.IsRejected && tokenDocument is not null) | ||||
|         { | ||||
|             await TrackTokenUsageAsync(context, tokenDocument, context.Principal, session).ConfigureAwait(false); | ||||
|             if (!string.IsNullOrWhiteSpace(tokenDocument.Tenant)) | ||||
|             { | ||||
|                 metadataAccessor.SetTenant(tokenDocument.Tenant); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var clientId = context.Principal.GetClaim(OpenIddictConstants.Claims.ClientId); | ||||
| @@ -135,6 +139,12 @@ internal sealed class ValidateAccessTokenHandler : IOpenIddictServerHandler<Open | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         var tenantClaim = context.Principal.GetClaim(StellaOpsClaimTypes.Tenant); | ||||
|         if (!string.IsNullOrWhiteSpace(tenantClaim)) | ||||
|         { | ||||
|             metadataAccessor.SetTenant(tenantClaim); | ||||
|         } | ||||
|  | ||||
|         var providerName = context.Principal.GetClaim(StellaOpsClaimTypes.IdentityProvider); | ||||
|         if (string.IsNullOrWhiteSpace(providerName)) | ||||
|         { | ||||
|   | ||||
| @@ -0,0 +1,11 @@ | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Authority.Permalinks; | ||||
|  | ||||
| public sealed record VulnPermalinkRequest( | ||||
|     [property: JsonPropertyName("tenant")] string Tenant, | ||||
|     [property: JsonPropertyName("resourceKind")] string ResourceKind, | ||||
|     [property: JsonPropertyName("state")] JsonElement State, | ||||
|     [property: JsonPropertyName("expiresInSeconds")] int? ExpiresInSeconds, | ||||
|     [property: JsonPropertyName("environment")] string? Environment); | ||||
| @@ -0,0 +1,11 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Authority.Permalinks; | ||||
|  | ||||
| public sealed record VulnPermalinkResponse( | ||||
|     [property: JsonPropertyName("token")] string Token, | ||||
|     [property: JsonPropertyName("issuedAt")] DateTimeOffset IssuedAt, | ||||
|     [property: JsonPropertyName("expiresAt")] DateTimeOffset ExpiresAt, | ||||
|     [property: JsonPropertyName("scopes")] IReadOnlyList<string> Scopes); | ||||
| @@ -0,0 +1,181 @@ | ||||
| using System; | ||||
| using System.Collections.Generic; | ||||
| 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; | ||||
| using StellaOps.Auth.Abstractions; | ||||
| using StellaOps.Configuration; | ||||
| using StellaOps.Cryptography; | ||||
|  | ||||
| namespace StellaOps.Authority.Permalinks; | ||||
|  | ||||
| internal sealed class VulnPermalinkService | ||||
| { | ||||
|     private static readonly JsonSerializerOptions PayloadSerializerOptions = new(JsonSerializerDefaults.Web) | ||||
|     { | ||||
|         DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, | ||||
|         PropertyNamingPolicy = JsonNamingPolicy.CamelCase | ||||
|     }; | ||||
|  | ||||
|     private static readonly JsonSerializerOptions HeaderSerializerOptions = new(JsonSerializerDefaults.General) | ||||
|     { | ||||
|         PropertyNamingPolicy = null, | ||||
|         DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, | ||||
|         WriteIndented = false | ||||
|     }; | ||||
|  | ||||
|     private static readonly TimeSpan DefaultLifetime = TimeSpan.FromHours(24); | ||||
|     private static readonly TimeSpan MaxLifetime = TimeSpan.FromDays(30); | ||||
|     private const int MaxStateBytes = 8 * 1024; | ||||
|  | ||||
|     private readonly ICryptoProviderRegistry providerRegistry; | ||||
|     private readonly IOptions<StellaOpsAuthorityOptions> authorityOptions; | ||||
|     private readonly TimeProvider timeProvider; | ||||
|     private readonly ILogger<VulnPermalinkService> logger; | ||||
|  | ||||
|     public VulnPermalinkService( | ||||
|         ICryptoProviderRegistry providerRegistry, | ||||
|         IOptions<StellaOpsAuthorityOptions> authorityOptions, | ||||
|         TimeProvider timeProvider, | ||||
|         ILogger<VulnPermalinkService> logger) | ||||
|     { | ||||
|         this.providerRegistry = providerRegistry ?? throw new ArgumentNullException(nameof(providerRegistry)); | ||||
|         this.authorityOptions = authorityOptions ?? throw new ArgumentNullException(nameof(authorityOptions)); | ||||
|         this.timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); | ||||
|         this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); | ||||
|     } | ||||
|  | ||||
|     public async Task<VulnPermalinkResponse> CreateAsync(VulnPermalinkRequest request, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(request); | ||||
|  | ||||
|         var tenant = request.Tenant?.Trim(); | ||||
|         if (string.IsNullOrWhiteSpace(tenant)) | ||||
|         { | ||||
|             throw new ArgumentException("Tenant is required.", nameof(request)); | ||||
|         } | ||||
|  | ||||
|         var resourceKind = request.ResourceKind?.Trim(); | ||||
|         if (string.IsNullOrWhiteSpace(resourceKind)) | ||||
|         { | ||||
|             throw new ArgumentException("Resource kind is required.", nameof(request)); | ||||
|         } | ||||
|  | ||||
|         var stateJson = request.State.ValueKind == JsonValueKind.Undefined | ||||
|             ? "{}" | ||||
|             : request.State.GetRawText(); | ||||
|  | ||||
|         if (Encoding.UTF8.GetByteCount(stateJson) > MaxStateBytes) | ||||
|         { | ||||
|             throw new ArgumentException("State payload exceeds 8 KB limit.", nameof(request)); | ||||
|         } | ||||
|  | ||||
|         JsonElement stateElement; | ||||
|         using (var stateDocument = JsonDocument.Parse(string.IsNullOrWhiteSpace(stateJson) ? "{}" : stateJson)) | ||||
|         { | ||||
|             stateElement = stateDocument.RootElement.Clone(); | ||||
|         } | ||||
|  | ||||
|         var lifetime = request.ExpiresInSeconds.HasValue | ||||
|             ? TimeSpan.FromSeconds(request.ExpiresInSeconds.Value) | ||||
|             : DefaultLifetime; | ||||
|  | ||||
|         if (lifetime <= TimeSpan.Zero) | ||||
|         { | ||||
|             throw new ArgumentException("Expiration must be positive.", nameof(request)); | ||||
|         } | ||||
|  | ||||
|         if (lifetime > MaxLifetime) | ||||
|         { | ||||
|             lifetime = MaxLifetime; | ||||
|         } | ||||
|  | ||||
|         var signing = authorityOptions.Value.Signing | ||||
|             ?? throw new InvalidOperationException("Authority signing configuration is required to issue permalinks."); | ||||
|  | ||||
|         if (!signing.Enabled) | ||||
|         { | ||||
|             throw new InvalidOperationException("Authority signing is disabled. Enable signing to issue permalinks."); | ||||
|         } | ||||
|  | ||||
|         if (string.IsNullOrWhiteSpace(signing.ActiveKeyId)) | ||||
|         { | ||||
|             throw new InvalidOperationException("Authority signing configuration requires an active key identifier."); | ||||
|         } | ||||
|  | ||||
|         var algorithm = string.IsNullOrWhiteSpace(signing.Algorithm) | ||||
|             ? SignatureAlgorithms.Es256 | ||||
|             : signing.Algorithm.Trim(); | ||||
|  | ||||
|         var issuedAt = timeProvider.GetUtcNow(); | ||||
|         var expiresAt = issuedAt.Add(lifetime); | ||||
|  | ||||
|         var keyReference = new CryptoKeyReference(signing.ActiveKeyId, signing.Provider); | ||||
|         var resolution = providerRegistry.ResolveSigner( | ||||
|             CryptoCapability.Signing, | ||||
|             algorithm, | ||||
|             keyReference, | ||||
|             signing.Provider); | ||||
|         var signer = resolution.Signer; | ||||
|  | ||||
|         var payload = new VulnPermalinkPayload( | ||||
|             Subject: "vuln:permalink", | ||||
|             Audience: "stellaops:vuln-explorer", | ||||
|             Type: resourceKind, | ||||
|             Tenant: tenant, | ||||
|             Environment: string.IsNullOrWhiteSpace(request.Environment) ? null : request.Environment.Trim(), | ||||
|             Scopes: new[] { StellaOpsScopes.VulnRead }, | ||||
|             IssuedAt: issuedAt.ToUnixTimeSeconds(), | ||||
|             NotBefore: issuedAt.ToUnixTimeSeconds(), | ||||
|             ExpiresAt: expiresAt.ToUnixTimeSeconds(), | ||||
|             TokenId: Guid.NewGuid().ToString("N"), | ||||
|             Resource: new VulnPermalinkResource(resourceKind, stateElement)); | ||||
|  | ||||
|         var payloadBytes = JsonSerializer.SerializeToUtf8Bytes(payload, PayloadSerializerOptions); | ||||
|         var header = new Dictionary<string, object> | ||||
|         { | ||||
|             ["alg"] = algorithm, | ||||
|             ["typ"] = "JWT", | ||||
|             ["kid"] = signer.KeyId | ||||
|         }; | ||||
|  | ||||
|         var headerBytes = JsonSerializer.SerializeToUtf8Bytes(header, HeaderSerializerOptions); | ||||
|         var encodedHeader = Base64UrlEncoder.Encode(headerBytes); | ||||
|         var encodedPayload = Base64UrlEncoder.Encode(payloadBytes); | ||||
|  | ||||
|         var signingInput = Encoding.ASCII.GetBytes(string.Concat(encodedHeader, '.', encodedPayload)); | ||||
|         var signatureBytes = await signer.SignAsync(signingInput, cancellationToken).ConfigureAwait(false); | ||||
|         var encodedSignature = Base64UrlEncoder.Encode(signatureBytes); | ||||
|         var token = string.Concat(encodedHeader, '.', encodedPayload, '.', encodedSignature); | ||||
|  | ||||
|         logger.LogDebug("Issued Vuln Explorer permalink for tenant {Tenant} with resource kind {Resource}.", tenant, resourceKind); | ||||
|  | ||||
|         return new VulnPermalinkResponse( | ||||
|             Token: token, | ||||
|             IssuedAt: issuedAt, | ||||
|             ExpiresAt: expiresAt, | ||||
|             Scopes: new[] { StellaOpsScopes.VulnRead }); | ||||
|     } | ||||
|  | ||||
|     private sealed record VulnPermalinkPayload( | ||||
|         [property: JsonPropertyName("sub")] string Subject, | ||||
|         [property: JsonPropertyName("aud")] string Audience, | ||||
|         [property: JsonPropertyName("type")] string Type, | ||||
|         [property: JsonPropertyName("tenant")] string Tenant, | ||||
|         [property: JsonPropertyName("environment")] string? Environment, | ||||
|         [property: JsonPropertyName("scopes")] IReadOnlyList<string> Scopes, | ||||
|         [property: JsonPropertyName("iat")] long IssuedAt, | ||||
|         [property: JsonPropertyName("nbf")] long NotBefore, | ||||
|         [property: JsonPropertyName("exp")] long ExpiresAt, | ||||
|         [property: JsonPropertyName("jti")] string TokenId, | ||||
|         [property: JsonPropertyName("resource")] VulnPermalinkResource Resource); | ||||
|  | ||||
|     private sealed record VulnPermalinkResource( | ||||
|         [property: JsonPropertyName("kind")] string Kind, | ||||
|         [property: JsonPropertyName("state")] JsonElement State); | ||||
| } | ||||
| @@ -34,11 +34,14 @@ using StellaOps.Authority.OpenIddict.Handlers; | ||||
| using System.Linq; | ||||
| using StellaOps.Cryptography.Audit; | ||||
| using StellaOps.Cryptography.DependencyInjection; | ||||
| using StellaOps.Authority.Permalinks; | ||||
| using StellaOps.Authority.Revocation; | ||||
| using StellaOps.Authority.Signing; | ||||
| using StellaOps.Cryptography; | ||||
| using StellaOps.Authority.Storage.Mongo.Documents; | ||||
| using StellaOps.Authority.Security; | ||||
| using StellaOps.Auth.Abstractions; | ||||
| using StellaOps.Auth.ServerIntegration; | ||||
| #if STELLAOPS_AUTH_SECURITY | ||||
| using StellaOps.Auth.Security.Dpop; | ||||
| using StackExchange.Redis; | ||||
| @@ -155,6 +158,7 @@ builder.Services.AddRateLimiter(rateLimiterOptions => | ||||
| builder.Services.AddStellaOpsCrypto(); | ||||
| builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton<IAuthoritySigningKeySource, FileAuthoritySigningKeySource>()); | ||||
| builder.Services.AddSingleton<AuthoritySigningKeyManager>(); | ||||
| builder.Services.AddSingleton<VulnPermalinkService>(); | ||||
|  | ||||
| AuthorityPluginContext[] pluginContexts = AuthorityPluginConfigurationLoader | ||||
|     .Load(authorityOptions, builder.Environment.ContentRootPath) | ||||
| @@ -228,10 +232,16 @@ builder.Services.AddOpenIddict() | ||||
|         options.DisableAuthorizationStorage(); | ||||
|  | ||||
|         options.RegisterScopes( | ||||
|             OpenIddictConstants.Scopes.OpenId, | ||||
|             OpenIddictConstants.Scopes.Email, | ||||
|             OpenIddictConstants.Scopes.Profile, | ||||
|             OpenIddictConstants.Scopes.OfflineAccess); | ||||
|             new[] | ||||
|             { | ||||
|                 OpenIddictConstants.Scopes.OpenId, | ||||
|                 OpenIddictConstants.Scopes.Email, | ||||
|                 OpenIddictConstants.Scopes.Profile, | ||||
|                 OpenIddictConstants.Scopes.OfflineAccess | ||||
|             } | ||||
|             .Concat(StellaOpsScopes.All) | ||||
|             .Distinct(StringComparer.Ordinal) | ||||
|             .ToArray()); | ||||
|  | ||||
|         options.AddEphemeralEncryptionKey() | ||||
|                .AddEphemeralSigningKey(); | ||||
| @@ -806,6 +816,9 @@ if (authorityOptions.Bootstrap.Enabled) | ||||
|             request.AllowedAudiences ?? Array.Empty<string>(), | ||||
|             redirectUris, | ||||
|             postLogoutUris, | ||||
|             properties.TryGetValue(AuthorityClientMetadataKeys.Tenant, out var requestedTenant) | ||||
|                 ? requestedTenant?.Trim().ToLowerInvariant() | ||||
|                 : null, | ||||
|             properties, | ||||
|             certificateBindings); | ||||
|  | ||||
| @@ -1210,6 +1223,28 @@ app.MapGet("/ready", (IAuthorityIdentityProviderRegistry registry) => | ||||
|     })) | ||||
|     .WithName("ReadinessCheck"); | ||||
|  | ||||
| app.MapPost("/permalinks/vuln", async ( | ||||
|     VulnPermalinkRequest request, | ||||
|     VulnPermalinkService service, | ||||
|     CancellationToken cancellationToken) => | ||||
| { | ||||
|     try | ||||
|     { | ||||
|         var response = await service.CreateAsync(request, cancellationToken).ConfigureAwait(false); | ||||
|         return Results.Ok(response); | ||||
|     } | ||||
|     catch (ArgumentException ex) | ||||
|     { | ||||
|         return Results.BadRequest(new { error = "invalid_request", message = ex.Message }); | ||||
|     } | ||||
|     catch (InvalidOperationException ex) | ||||
|     { | ||||
|         return Results.Problem(ex.Message); | ||||
|     } | ||||
| }) | ||||
|     .RequireAuthorization(policy => policy.RequireStellaOpsScopes(StellaOpsScopes.VulnRead)) | ||||
|     .WithName("CreateVulnPermalink"); | ||||
|  | ||||
| app.MapGet("/jwks", (AuthorityJwksService jwksService) => Results.Ok(jwksService.Build())) | ||||
|     .WithName("JsonWebKeySet"); | ||||
|  | ||||
|   | ||||
| @@ -36,6 +36,11 @@ internal sealed class AuthorityRateLimiterMetadata | ||||
|     /// </summary> | ||||
|     public string? SubjectId { get; set; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Tenant identifier associated with the request, when available. | ||||
|     /// </summary> | ||||
|     public string? Tenant { get; set; } | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Additional metadata tags that can be attached by later handlers. | ||||
|     /// </summary> | ||||
|   | ||||
| @@ -24,6 +24,11 @@ internal interface IAuthorityRateLimiterMetadataAccessor | ||||
|     /// </summary> | ||||
|     void SetSubjectId(string? subjectId); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Updates the tenant identifier associated with the current request. | ||||
|     /// </summary> | ||||
|     void SetTenant(string? tenant); | ||||
|  | ||||
|     /// <summary> | ||||
|     /// Adds or removes a metadata tag for the current request. | ||||
|     /// </summary> | ||||
| @@ -64,6 +69,16 @@ internal sealed class AuthorityRateLimiterMetadataAccessor : IAuthorityRateLimit | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void SetTenant(string? tenant) | ||||
|     { | ||||
|         var metadata = TryGetMetadata(); | ||||
|         if (metadata is not null) | ||||
|         { | ||||
|             metadata.Tenant = NormalizeTenant(tenant); | ||||
|             metadata.SetTag("authority.tenant", metadata.Tenant); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public void SetTag(string name, string? value) | ||||
|     { | ||||
|         var metadata = TryGetMetadata(); | ||||
| @@ -80,4 +95,9 @@ internal sealed class AuthorityRateLimiterMetadataAccessor : IAuthorityRateLimit | ||||
|     { | ||||
|         return string.IsNullOrWhiteSpace(value) ? null : value; | ||||
|     } | ||||
|  | ||||
|     private static string? NormalizeTenant(string? value) | ||||
|     { | ||||
|         return string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant(); | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -1,25 +1,29 @@ | ||||
| # Authority Host Task Board — Epic 1: Aggregation-Only Contract | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | AUTH-AOC-19-001 | TODO | Authority Core & Security Guild | — | Introduce scopes `advisory:read`, `advisory:ingest`, `vex:read`, `vex:ingest`, `aoc:verify` with configuration binding, migrations, and offline kit defaults. | Scopes published in metadata/OpenAPI, configuration validates scope lists, tests cover token issuance + enforcement. | | ||||
| | AUTH-AOC-19-002 | TODO | Authority Core & Security Guild | AUTH-AOC-19-001 | Propagate tenant claim + scope enforcement for ingestion identities; ensure cross-tenant writes/read blocked and audit logs capture tenant context. | Tenant claim injected into downstream services; forbidden cross-tenant access rejected; audit/log fixtures updated. | | ||||
| | AUTH-AOC-19-001 | DONE (2025-10-26) | Authority Core & Security Guild | — | Introduce scopes `advisory:read`, `advisory:ingest`, `vex:read`, `vex:ingest`, `aoc:verify` with configuration binding, migrations, and offline kit defaults. | Scopes published in metadata/OpenAPI, configuration validates scope lists, tests cover token issuance + enforcement. | | ||||
| | AUTH-AOC-19-002 | DOING (2025-10-26) | Authority Core & Security Guild | AUTH-AOC-19-001 | Propagate tenant claim + scope enforcement for ingestion identities; ensure cross-tenant writes/read blocked and audit logs capture tenant context. | Tenant claim injected into downstream services; forbidden cross-tenant access rejected; audit/log fixtures updated. | | ||||
| > 2025-10-26: Rate limiter metadata/audit records now include tenants, password grant scopes/tenants enforced, token persistence + tests updated. Docs refresh tracked via AUTH-AOC-19-003. | ||||
| | AUTH-AOC-19-003 | TODO | Authority Core & Docs Guild | AUTH-AOC-19-001 | Update Authority docs and sample configs to describe new scopes, tenancy enforcement, and verify endpoints. | Docs and examples refreshed; release notes prepared; smoke tests confirm new scopes required. | | ||||
| > 2025-10-26: Docs updated (`docs/11_AUTHORITY.md`, Concelier audit runbook, `docs/security/authority-scopes.md`); sample config highlights tenant-aware clients. Release notes + smoke verification pending (blocked on Concelier/Excititor smoke updates). | ||||
|  | ||||
| ## Policy Engine v2 | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | AUTH-POLICY-20-001 | TODO | Authority Core & Security Guild | AUTH-AOC-19-001 | Add scopes `policy:write`, `policy:submit`, `policy:approve`, `policy:run`, `findings:read`, `effective:write` with configuration binding and issuer policy updates. | Scopes available in metadata; token issuance validated; offline kit defaults updated; tests cover scope combinations. | | ||||
| | AUTH-POLICY-20-002 | TODO | Authority Core & Security Guild | AUTH-POLICY-20-001, AUTH-AOC-19-002 | Enforce Policy Engine service identity with `effective:write` and ensure API gateway enforces scopes/tenant claims for new endpoints. | Gateway policies updated; unauthorized requests rejected in tests; audit logs capture scope usage. | | ||||
| | AUTH-POLICY-20-003 | TODO | Authority Core & Docs Guild | AUTH-POLICY-20-001 | Update Authority configuration/docs with policy scopes, service identities, and approval workflows; include compliance checklist. | Docs refreshed; samples updated; release notes prepared; doc lint passes. | | ||||
| | AUTH-POLICY-20-001 | DONE (2025-10-26) | Authority Core & Security Guild | AUTH-AOC-19-001 | Add scopes `policy:write`, `policy:submit`, `policy:approve`, `policy:run`, `findings:read`, `effective:write` with configuration binding and issuer policy updates. | Scopes available in metadata; token issuance validated; offline kit defaults updated; tests cover scope combinations. | | ||||
| | AUTH-POLICY-20-002 | DONE (2025-10-26) | Authority Core & Security Guild | AUTH-POLICY-20-001, AUTH-AOC-19-002 | Enforce Policy Engine service identity with `effective:write` and ensure API gateway enforces scopes/tenant claims for new endpoints. | Gateway policies updated; unauthorized requests rejected in tests; audit logs capture scope usage. | | ||||
| > 2025-10-26: Restricted `effective:write` to Policy Engine service identities with tenant requirement, registered full scope set, and tightened resource server default scope enforcement (unit tests pass). | ||||
| | AUTH-POLICY-20-003 | DONE (2025-10-26) | Authority Core & Docs Guild | AUTH-POLICY-20-001 | Update Authority configuration/docs with policy scopes, service identities, and approval workflows; include compliance checklist. | Docs refreshed; samples updated; release notes prepared; doc lint passes. | | ||||
| > 2025-10-26: Authority docs now detail policy scopes/service identity guardrails with checklist; `authority.yaml.sample` includes `properties.serviceIdentity` example. | ||||
|  | ||||
| ## Graph Explorer v1 | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | AUTH-GRAPH-21-001 | TODO | Authority Core & Security Guild | AUTH-POLICY-20-001 | Define scopes `graph:write`, `graph:read`, `graph:export`, `graph:simulate`, update metadata/OpenAPI, and add OFFLINE kit defaults. | Scopes exposed via discovery docs; smoke tests ensure enforcement; offline kit updated. | | ||||
| | AUTH-GRAPH-21-002 | TODO | Authority Core & Security Guild | AUTH-GRAPH-21-001, AUTH-AOC-19-002 | Wire gateway enforcement for new graph scopes, Cartographer service identity, and tenant propagation across graph APIs. | Gateway config updated; unauthorized access blocked in integration tests; audit logs include graph scope usage. | | ||||
| | AUTH-GRAPH-21-003 | TODO | Authority Core & Docs Guild | AUTH-GRAPH-21-001 | Update security docs and samples describing graph access roles, least privilege guidance, and service identities. | Docs merged with compliance checklist; examples refreshed; release notes prepared. | | ||||
| | AUTH-GRAPH-21-001 | DONE (2025-10-26) | Authority Core & Security Guild | AUTH-POLICY-20-001 | Define scopes `graph:write`, `graph:read`, `graph:export`, `graph:simulate`, update metadata/OpenAPI, and add OFFLINE kit defaults. | Scopes exposed via discovery docs; smoke tests ensure enforcement; offline kit updated. | | ||||
| | AUTH-GRAPH-21-002 | DONE (2025-10-26) | Authority Core & Security Guild | AUTH-GRAPH-21-001, AUTH-AOC-19-002 | Wire gateway enforcement for new graph scopes, Cartographer service identity, and tenant propagation across graph APIs. | Gateway config updated; unauthorized access blocked in integration tests; audit logs include graph scope usage. | | ||||
| | AUTH-GRAPH-21-003 | DONE (2025-10-26) | Authority Core & Docs Guild | AUTH-GRAPH-21-001 | Update security docs and samples describing graph access roles, least privilege guidance, and service identities. | Docs merged with compliance checklist; examples refreshed; release notes prepared. | | ||||
|  | ||||
| ## Policy Engine + Editor v1 | ||||
|  | ||||
| @@ -33,7 +37,7 @@ | ||||
|  | ||||
| | ID | Status | Owner(s) | Depends on | Description | Exit Criteria | | ||||
| |----|--------|----------|------------|-------------|---------------| | ||||
| | AUTH-GRAPH-24-001 | TODO | Authority Core & Security Guild | AUTH-GRAPH-21-001 | Extend scopes to include `vuln:read` and signed permalinks with scoped claims for Graph/Vuln Explorer; update metadata. | Scopes published; permalinks validated; integration tests cover RBAC. | | ||||
| | AUTH-VULN-24-001 | TODO | Authority Core & Security Guild | AUTH-GRAPH-21-001 | Extend scopes to include `vuln:read` and signed permalinks with scoped claims for Vuln Explorer; update metadata. | Scopes published; permalinks validated; integration tests cover RBAC. | | ||||
|  | ||||
| ## Orchestrator Dashboard | ||||
|  | ||||
|   | ||||
							
								
								
									
										26
									
								
								src/StellaOps.Bench/LinkNotMerge.Vex/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/StellaOps.Bench/LinkNotMerge.Vex/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| # Link-Not-Merge VEX Bench | ||||
|  | ||||
| Measures synthetic VEX observation ingest and event emission throughput for the Link-Not-Merge program. | ||||
|  | ||||
| ## Scenarios | ||||
|  | ||||
| `config.json` defines workloads with varying statement density and tenant fan-out. Metrics captured per scenario: | ||||
|  | ||||
| - Total latency (ingest + correlation) and p95/max percentiles | ||||
| - Correlator-only latency and Mongo insert latency | ||||
| - Observation throughput (observations/sec) | ||||
| - Event emission throughput (events/sec) | ||||
| - Peak managed heap allocations | ||||
|  | ||||
| ## Running locally | ||||
|  | ||||
| ```bash | ||||
| dotnet run \ | ||||
|   --project src/StellaOps.Bench/LinkNotMerge.Vex/StellaOps.Bench.LinkNotMerge.Vex/StellaOps.Bench.LinkNotMerge.Vex.csproj \ | ||||
|   -- \ | ||||
|   --csv out/linknotmerge-vex-bench.csv \ | ||||
|   --json out/linknotmerge-vex-bench.json \ | ||||
|   --prometheus out/linknotmerge-vex-bench.prom | ||||
| ``` | ||||
|  | ||||
| The benchmark exits non-zero if latency thresholds are exceeded, observation or event throughput drops below configured floors, allocations exceed the ceiling, or regression ratios breach the baseline. | ||||
| @@ -0,0 +1,37 @@ | ||||
| using System.IO; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using StellaOps.Bench.LinkNotMerge.Vex.Baseline; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge.Vex.Tests; | ||||
|  | ||||
| public sealed class BaselineLoaderTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task LoadAsync_ReadsEntries() | ||||
|     { | ||||
|         var path = Path.GetTempFileName(); | ||||
|         try | ||||
|         { | ||||
|             await File.WriteAllTextAsync( | ||||
|                 path, | ||||
|                 "scenario,iterations,observations,statements,events,mean_total_ms,p95_total_ms,max_total_ms,mean_insert_ms,mean_correlation_ms,mean_observation_throughput_per_sec,min_observation_throughput_per_sec,mean_event_throughput_per_sec,min_event_throughput_per_sec,max_allocated_mb\n" + | ||||
|                 "vex_ingest_baseline,5,4000,24000,12000,620.5,700.1,820.9,320.5,300.0,9800.0,9100.0,4200.0,3900.0,150.0\n"); | ||||
|  | ||||
|             var baseline = await BaselineLoader.LoadAsync(path, CancellationToken.None); | ||||
|             var entry = Assert.Single(baseline); | ||||
|  | ||||
|             Assert.Equal("vex_ingest_baseline", entry.Key); | ||||
|             Assert.Equal(4000, entry.Value.Observations); | ||||
|             Assert.Equal(24000, entry.Value.Statements); | ||||
|             Assert.Equal(12000, entry.Value.Events); | ||||
|             Assert.Equal(700.1, entry.Value.P95TotalMs); | ||||
|             Assert.Equal(3900.0, entry.Value.MinEventThroughputPerSecond); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             File.Delete(path); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,83 @@ | ||||
| using StellaOps.Bench.LinkNotMerge.Vex.Baseline; | ||||
| using StellaOps.Bench.LinkNotMerge.Vex.Reporting; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge.Vex.Tests; | ||||
|  | ||||
| public sealed class BenchmarkScenarioReportTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void RegressionDetection_FlagsBreaches() | ||||
|     { | ||||
|         var result = new VexScenarioResult( | ||||
|             Id: "scenario", | ||||
|             Label: "Scenario", | ||||
|             Iterations: 3, | ||||
|             ObservationCount: 1000, | ||||
|             AliasGroups: 100, | ||||
|             StatementCount: 6000, | ||||
|             EventCount: 3200, | ||||
|             TotalStatistics: new DurationStatistics(600, 700, 750), | ||||
|             InsertStatistics: new DurationStatistics(320, 360, 380), | ||||
|             CorrelationStatistics: new DurationStatistics(280, 320, 340), | ||||
|             ObservationThroughputStatistics: new ThroughputStatistics(8000, 7000), | ||||
|             EventThroughputStatistics: new ThroughputStatistics(3500, 3200), | ||||
|             AllocationStatistics: new AllocationStatistics(180), | ||||
|             ThresholdMs: null, | ||||
|             MinObservationThroughputPerSecond: null, | ||||
|             MinEventThroughputPerSecond: null, | ||||
|             MaxAllocatedThresholdMb: null); | ||||
|  | ||||
|         var baseline = new BaselineEntry( | ||||
|             ScenarioId: "scenario", | ||||
|             Iterations: 3, | ||||
|             Observations: 1000, | ||||
|             Statements: 6000, | ||||
|             Events: 3200, | ||||
|             MeanTotalMs: 520, | ||||
|             P95TotalMs: 560, | ||||
|             MaxTotalMs: 580, | ||||
|             MeanInsertMs: 250, | ||||
|             MeanCorrelationMs: 260, | ||||
|             MeanObservationThroughputPerSecond: 9000, | ||||
|             MinObservationThroughputPerSecond: 8500, | ||||
|             MeanEventThroughputPerSecond: 4200, | ||||
|             MinEventThroughputPerSecond: 3800, | ||||
|             MaxAllocatedMb: 140); | ||||
|  | ||||
|         var report = new BenchmarkScenarioReport(result, baseline, regressionLimit: 1.1); | ||||
|  | ||||
|         Assert.True(report.DurationRegressionBreached); | ||||
|         Assert.True(report.ObservationThroughputRegressionBreached); | ||||
|         Assert.True(report.EventThroughputRegressionBreached); | ||||
|         Assert.Contains(report.BuildRegressionFailureMessages(), message => message.Contains("event throughput")); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void RegressionDetection_NoBaseline_NoBreaches() | ||||
|     { | ||||
|         var result = new VexScenarioResult( | ||||
|             Id: "scenario", | ||||
|             Label: "Scenario", | ||||
|             Iterations: 3, | ||||
|             ObservationCount: 1000, | ||||
|             AliasGroups: 100, | ||||
|             StatementCount: 6000, | ||||
|             EventCount: 3200, | ||||
|             TotalStatistics: new DurationStatistics(480, 520, 540), | ||||
|             InsertStatistics: new DurationStatistics(260, 280, 300), | ||||
|             CorrelationStatistics: new DurationStatistics(220, 240, 260), | ||||
|             ObservationThroughputStatistics: new ThroughputStatistics(9000, 8800), | ||||
|             EventThroughputStatistics: new ThroughputStatistics(4200, 4100), | ||||
|             AllocationStatistics: new AllocationStatistics(150), | ||||
|             ThresholdMs: null, | ||||
|             MinObservationThroughputPerSecond: null, | ||||
|             MinEventThroughputPerSecond: null, | ||||
|             MaxAllocatedThresholdMb: null); | ||||
|  | ||||
|         var report = new BenchmarkScenarioReport(result, baseline: null, regressionLimit: null); | ||||
|  | ||||
|         Assert.False(report.RegressionBreached); | ||||
|         Assert.Empty(report.BuildRegressionFailureMessages()); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,28 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|     <UseConcelierTestInfra>false</UseConcelierTestInfra> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" /> | ||||
|     <PackageReference Include="xunit" Version="2.9.2" /> | ||||
|     <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> | ||||
|       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||
|       <PrivateAssets>all</PrivateAssets> | ||||
|     </PackageReference> | ||||
|     <PackageReference Include="coverlet.collector" Version="6.0.4"> | ||||
|       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||
|       <PrivateAssets>all</PrivateAssets> | ||||
|     </PackageReference> | ||||
|     <PackageReference Include="EphemeralMongo" Version="3.0.0" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Bench.LinkNotMerge.Vex\StellaOps.Bench.LinkNotMerge.Vex.csproj" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| @@ -0,0 +1,34 @@ | ||||
| using System.Linq; | ||||
| using System.Threading; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge.Vex.Tests; | ||||
|  | ||||
| public sealed class VexScenarioRunnerTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void Execute_ComputesEvents() | ||||
|     { | ||||
|         var config = new VexScenarioConfig | ||||
|         { | ||||
|             Id = "unit", | ||||
|             Observations = 600, | ||||
|             AliasGroups = 120, | ||||
|             StatementsPerObservation = 5, | ||||
|             ProductsPerObservation = 3, | ||||
|             Tenants = 2, | ||||
|             BatchSize = 120, | ||||
|             Seed = 12345, | ||||
|         }; | ||||
|  | ||||
|         var runner = new VexScenarioRunner(config); | ||||
|         var result = runner.Execute(2, CancellationToken.None); | ||||
|  | ||||
|         Assert.Equal(600, result.ObservationCount); | ||||
|         Assert.True(result.StatementCount > 0); | ||||
|         Assert.True(result.EventCount > 0); | ||||
|         Assert.All(result.TotalDurationsMs, duration => Assert.True(duration > 0)); | ||||
|         Assert.All(result.EventThroughputsPerSecond, throughput => Assert.True(throughput > 0)); | ||||
|         Assert.Equal(result.AggregationResult.EventCount, result.EventCount); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,18 @@ | ||||
| namespace StellaOps.Bench.LinkNotMerge.Vex.Baseline; | ||||
|  | ||||
| internal sealed record BaselineEntry( | ||||
|     string ScenarioId, | ||||
|     int Iterations, | ||||
|     int Observations, | ||||
|     int Statements, | ||||
|     int Events, | ||||
|     double MeanTotalMs, | ||||
|     double P95TotalMs, | ||||
|     double MaxTotalMs, | ||||
|     double MeanInsertMs, | ||||
|     double MeanCorrelationMs, | ||||
|     double MeanObservationThroughputPerSecond, | ||||
|     double MinObservationThroughputPerSecond, | ||||
|     double MeanEventThroughputPerSecond, | ||||
|     double MinEventThroughputPerSecond, | ||||
|     double MaxAllocatedMb); | ||||
| @@ -0,0 +1,87 @@ | ||||
| using System.Globalization; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge.Vex.Baseline; | ||||
|  | ||||
| internal static class BaselineLoader | ||||
| { | ||||
|     public static async Task<IReadOnlyDictionary<string, BaselineEntry>> LoadAsync(string path, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(path); | ||||
|  | ||||
|         var resolved = Path.GetFullPath(path); | ||||
|         if (!File.Exists(resolved)) | ||||
|         { | ||||
|             return new Dictionary<string, BaselineEntry>(StringComparer.OrdinalIgnoreCase); | ||||
|         } | ||||
|  | ||||
|         var result = new Dictionary<string, BaselineEntry>(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|         await using var stream = new FileStream(resolved, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||
|         using var reader = new StreamReader(stream); | ||||
|  | ||||
|         var lineNumber = 0; | ||||
|         while (true) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             var line = await reader.ReadLineAsync().ConfigureAwait(false); | ||||
|             if (line is null) | ||||
|             { | ||||
|                 break; | ||||
|             } | ||||
|  | ||||
|             lineNumber++; | ||||
|             if (lineNumber == 1 || string.IsNullOrWhiteSpace(line)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var parts = line.Split(',', StringSplitOptions.TrimEntries); | ||||
|             if (parts.Length < 15) | ||||
|             { | ||||
|                 throw new InvalidOperationException($"Baseline '{resolved}' line {lineNumber} is invalid (expected 15 columns, found {parts.Length})."); | ||||
|             } | ||||
|  | ||||
|             var entry = new BaselineEntry( | ||||
|                 ScenarioId: parts[0], | ||||
|                 Iterations: ParseInt(parts[1], resolved, lineNumber), | ||||
|                 Observations: ParseInt(parts[2], resolved, lineNumber), | ||||
|                 Statements: ParseInt(parts[3], resolved, lineNumber), | ||||
|                 Events: ParseInt(parts[4], resolved, lineNumber), | ||||
|                 MeanTotalMs: ParseDouble(parts[5], resolved, lineNumber), | ||||
|                 P95TotalMs: ParseDouble(parts[6], resolved, lineNumber), | ||||
|                 MaxTotalMs: ParseDouble(parts[7], resolved, lineNumber), | ||||
|                 MeanInsertMs: ParseDouble(parts[8], resolved, lineNumber), | ||||
|                 MeanCorrelationMs: ParseDouble(parts[9], resolved, lineNumber), | ||||
|                 MeanObservationThroughputPerSecond: ParseDouble(parts[10], resolved, lineNumber), | ||||
|                 MinObservationThroughputPerSecond: ParseDouble(parts[11], resolved, lineNumber), | ||||
|                 MeanEventThroughputPerSecond: ParseDouble(parts[12], resolved, lineNumber), | ||||
|                 MinEventThroughputPerSecond: ParseDouble(parts[13], resolved, lineNumber), | ||||
|                 MaxAllocatedMb: ParseDouble(parts[14], resolved, lineNumber)); | ||||
|  | ||||
|             result[entry.ScenarioId] = entry; | ||||
|         } | ||||
|  | ||||
|         return result; | ||||
|     } | ||||
|  | ||||
|     private static int ParseInt(string value, string file, int line) | ||||
|     { | ||||
|         if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)) | ||||
|         { | ||||
|             return parsed; | ||||
|         } | ||||
|  | ||||
|         throw new InvalidOperationException($"Baseline '{file}' line {line} contains an invalid integer '{value}'."); | ||||
|     } | ||||
|  | ||||
|     private static double ParseDouble(string value, string file, int line) | ||||
|     { | ||||
|         if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsed)) | ||||
|         { | ||||
|             return parsed; | ||||
|         } | ||||
|  | ||||
|         throw new InvalidOperationException($"Baseline '{file}' line {line} contains an invalid number '{value}'."); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,376 @@ | ||||
| using System.Globalization; | ||||
| using StellaOps.Bench.LinkNotMerge.Vex.Baseline; | ||||
| using StellaOps.Bench.LinkNotMerge.Vex.Reporting; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge.Vex; | ||||
|  | ||||
| internal static class Program | ||||
| { | ||||
|     public static async Task<int> Main(string[] args) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             var options = ProgramOptions.Parse(args); | ||||
|             var config = await VexBenchmarkConfig.LoadAsync(options.ConfigPath).ConfigureAwait(false); | ||||
|             var baseline = await BaselineLoader.LoadAsync(options.BaselinePath, CancellationToken.None).ConfigureAwait(false); | ||||
|  | ||||
|             var results = new List<VexScenarioResult>(); | ||||
|             var reports = new List<BenchmarkScenarioReport>(); | ||||
|             var failures = new List<string>(); | ||||
|  | ||||
|             foreach (var scenario in config.Scenarios) | ||||
|             { | ||||
|                 var iterations = scenario.ResolveIterations(config.Iterations); | ||||
|                 var runner = new VexScenarioRunner(scenario); | ||||
|                 var execution = runner.Execute(iterations, CancellationToken.None); | ||||
|  | ||||
|                 var totalStats = DurationStatistics.From(execution.TotalDurationsMs); | ||||
|                 var insertStats = DurationStatistics.From(execution.InsertDurationsMs); | ||||
|                 var correlationStats = DurationStatistics.From(execution.CorrelationDurationsMs); | ||||
|                 var allocationStats = AllocationStatistics.From(execution.AllocatedMb); | ||||
|                 var observationThroughputStats = ThroughputStatistics.From(execution.ObservationThroughputsPerSecond); | ||||
|                 var eventThroughputStats = ThroughputStatistics.From(execution.EventThroughputsPerSecond); | ||||
|  | ||||
|                 var thresholdMs = scenario.ThresholdMs ?? options.ThresholdMs ?? config.ThresholdMs; | ||||
|                 var observationFloor = scenario.MinThroughputPerSecond ?? options.MinThroughputPerSecond ?? config.MinThroughputPerSecond; | ||||
|                 var eventFloor = scenario.MinEventThroughputPerSecond ?? options.MinEventThroughputPerSecond ?? config.MinEventThroughputPerSecond; | ||||
|                 var allocationLimit = scenario.MaxAllocatedMb ?? options.MaxAllocatedMb ?? config.MaxAllocatedMb; | ||||
|  | ||||
|                 var result = new VexScenarioResult( | ||||
|                     scenario.ScenarioId, | ||||
|                     scenario.DisplayLabel, | ||||
|                     iterations, | ||||
|                     execution.ObservationCount, | ||||
|                     execution.AliasGroups, | ||||
|                     execution.StatementCount, | ||||
|                     execution.EventCount, | ||||
|                     totalStats, | ||||
|                     insertStats, | ||||
|                     correlationStats, | ||||
|                     observationThroughputStats, | ||||
|                     eventThroughputStats, | ||||
|                     allocationStats, | ||||
|                     thresholdMs, | ||||
|                     observationFloor, | ||||
|                     eventFloor, | ||||
|                     allocationLimit); | ||||
|  | ||||
|                 results.Add(result); | ||||
|  | ||||
|                 if (thresholdMs is { } threshold && result.TotalStatistics.MaxMs > threshold) | ||||
|                 { | ||||
|                     failures.Add($"{result.Id} exceeded total latency threshold: {result.TotalStatistics.MaxMs:F2} ms > {threshold:F2} ms"); | ||||
|                 } | ||||
|  | ||||
|                 if (observationFloor is { } obsFloor && result.ObservationThroughputStatistics.MinPerSecond < obsFloor) | ||||
|                 { | ||||
|                     failures.Add($"{result.Id} fell below observation throughput floor: {result.ObservationThroughputStatistics.MinPerSecond:N0} obs/s < {obsFloor:N0} obs/s"); | ||||
|                 } | ||||
|  | ||||
|                 if (eventFloor is { } evtFloor && result.EventThroughputStatistics.MinPerSecond < evtFloor) | ||||
|                 { | ||||
|                     failures.Add($"{result.Id} fell below event throughput floor: {result.EventThroughputStatistics.MinPerSecond:N0} events/s < {evtFloor:N0} events/s"); | ||||
|                 } | ||||
|  | ||||
|                 if (allocationLimit is { } limit && result.AllocationStatistics.MaxAllocatedMb > limit) | ||||
|                 { | ||||
|                     failures.Add($"{result.Id} exceeded allocation budget: {result.AllocationStatistics.MaxAllocatedMb:F2} MB > {limit:F2} MB"); | ||||
|                 } | ||||
|  | ||||
|                 baseline.TryGetValue(result.Id, out var baselineEntry); | ||||
|                 var report = new BenchmarkScenarioReport(result, baselineEntry, options.RegressionLimit); | ||||
|                 reports.Add(report); | ||||
|                 failures.AddRange(report.BuildRegressionFailureMessages()); | ||||
|             } | ||||
|  | ||||
|             TablePrinter.Print(results); | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(options.CsvOutPath)) | ||||
|             { | ||||
|                 CsvWriter.Write(options.CsvOutPath!, results); | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(options.JsonOutPath)) | ||||
|             { | ||||
|                 var metadata = new BenchmarkJsonMetadata( | ||||
|                     SchemaVersion: "linknotmerge-vex-bench/1.0", | ||||
|                     CapturedAtUtc: (options.CapturedAtUtc ?? DateTimeOffset.UtcNow).ToUniversalTime(), | ||||
|                     Commit: options.Commit, | ||||
|                     Environment: options.Environment); | ||||
|  | ||||
|                 await BenchmarkJsonWriter.WriteAsync(options.JsonOutPath!, metadata, reports, CancellationToken.None).ConfigureAwait(false); | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(options.PrometheusOutPath)) | ||||
|             { | ||||
|                 PrometheusWriter.Write(options.PrometheusOutPath!, reports); | ||||
|             } | ||||
|  | ||||
|             if (failures.Count > 0) | ||||
|             { | ||||
|                 Console.Error.WriteLine(); | ||||
|                 Console.Error.WriteLine("Benchmark failures detected:"); | ||||
|                 foreach (var failure in failures.Distinct()) | ||||
|                 { | ||||
|                     Console.Error.WriteLine($" - {failure}"); | ||||
|                 } | ||||
|  | ||||
|                 return 1; | ||||
|             } | ||||
|  | ||||
|             return 0; | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             Console.Error.WriteLine($"linknotmerge-vex-bench error: {ex.Message}"); | ||||
|             return 1; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed record ProgramOptions( | ||||
|         string ConfigPath, | ||||
|         int? Iterations, | ||||
|         double? ThresholdMs, | ||||
|         double? MinThroughputPerSecond, | ||||
|         double? MinEventThroughputPerSecond, | ||||
|         double? MaxAllocatedMb, | ||||
|         string? CsvOutPath, | ||||
|         string? JsonOutPath, | ||||
|         string? PrometheusOutPath, | ||||
|         string BaselinePath, | ||||
|         DateTimeOffset? CapturedAtUtc, | ||||
|         string? Commit, | ||||
|         string? Environment, | ||||
|         double? RegressionLimit) | ||||
|     { | ||||
|         public static ProgramOptions Parse(string[] args) | ||||
|         { | ||||
|             var configPath = DefaultConfigPath(); | ||||
|             var baselinePath = DefaultBaselinePath(); | ||||
|  | ||||
|             int? iterations = null; | ||||
|             double? thresholdMs = null; | ||||
|             double? minThroughput = null; | ||||
|             double? minEventThroughput = null; | ||||
|             double? maxAllocated = null; | ||||
|             string? csvOut = null; | ||||
|             string? jsonOut = null; | ||||
|             string? promOut = null; | ||||
|             DateTimeOffset? capturedAt = null; | ||||
|             string? commit = null; | ||||
|             string? environment = null; | ||||
|             double? regressionLimit = null; | ||||
|  | ||||
|             for (var index = 0; index < args.Length; index++) | ||||
|             { | ||||
|                 var current = args[index]; | ||||
|                 switch (current) | ||||
|                 { | ||||
|                     case "--config": | ||||
|                         EnsureNext(args, index); | ||||
|                         configPath = Path.GetFullPath(args[++index]); | ||||
|                         break; | ||||
|                     case "--iterations": | ||||
|                         EnsureNext(args, index); | ||||
|                         iterations = int.Parse(args[++index], CultureInfo.InvariantCulture); | ||||
|                         break; | ||||
|                     case "--threshold-ms": | ||||
|                         EnsureNext(args, index); | ||||
|                         thresholdMs = double.Parse(args[++index], CultureInfo.InvariantCulture); | ||||
|                         break; | ||||
|                     case "--min-throughput": | ||||
|                         EnsureNext(args, index); | ||||
|                         minThroughput = double.Parse(args[++index], CultureInfo.InvariantCulture); | ||||
|                         break; | ||||
|                     case "--min-event-throughput": | ||||
|                         EnsureNext(args, index); | ||||
|                         minEventThroughput = double.Parse(args[++index], CultureInfo.InvariantCulture); | ||||
|                         break; | ||||
|                     case "--max-allocated-mb": | ||||
|                         EnsureNext(args, index); | ||||
|                         maxAllocated = double.Parse(args[++index], CultureInfo.InvariantCulture); | ||||
|                         break; | ||||
|                     case "--csv": | ||||
|                         EnsureNext(args, index); | ||||
|                         csvOut = args[++index]; | ||||
|                         break; | ||||
|                     case "--json": | ||||
|                         EnsureNext(args, index); | ||||
|                         jsonOut = args[++index]; | ||||
|                         break; | ||||
|                     case "--prometheus": | ||||
|                         EnsureNext(args, index); | ||||
|                         promOut = args[++index]; | ||||
|                         break; | ||||
|                     case "--baseline": | ||||
|                         EnsureNext(args, index); | ||||
|                         baselinePath = Path.GetFullPath(args[++index]); | ||||
|                         break; | ||||
|                     case "--captured-at": | ||||
|                         EnsureNext(args, index); | ||||
|                         capturedAt = DateTimeOffset.Parse(args[++index], CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); | ||||
|                         break; | ||||
|                     case "--commit": | ||||
|                         EnsureNext(args, index); | ||||
|                         commit = args[++index]; | ||||
|                         break; | ||||
|                     case "--environment": | ||||
|                         EnsureNext(args, index); | ||||
|                         environment = args[++index]; | ||||
|                         break; | ||||
|                     case "--regression-limit": | ||||
|                         EnsureNext(args, index); | ||||
|                         regressionLimit = double.Parse(args[++index], CultureInfo.InvariantCulture); | ||||
|                         break; | ||||
|                     case "--help": | ||||
|                     case "-h": | ||||
|                         PrintUsage(); | ||||
|                         System.Environment.Exit(0); | ||||
|                         break; | ||||
|                     default: | ||||
|                         throw new ArgumentException($"Unknown argument '{current}'."); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return new ProgramOptions( | ||||
|                 configPath, | ||||
|                 iterations, | ||||
|                 thresholdMs, | ||||
|                 minThroughput, | ||||
|                 minEventThroughput, | ||||
|                 maxAllocated, | ||||
|                 csvOut, | ||||
|                 jsonOut, | ||||
|                 promOut, | ||||
|                 baselinePath, | ||||
|                 capturedAt, | ||||
|                 commit, | ||||
|                 environment, | ||||
|                 regressionLimit); | ||||
|         } | ||||
|  | ||||
|         private static string DefaultConfigPath() | ||||
|         { | ||||
|             var binaryDir = AppContext.BaseDirectory; | ||||
|             var projectDir = Path.GetFullPath(Path.Combine(binaryDir, "..", "..", "..")); | ||||
|             var benchRoot = Path.GetFullPath(Path.Combine(projectDir, "..")); | ||||
|             return Path.Combine(benchRoot, "config.json"); | ||||
|         } | ||||
|  | ||||
|         private static string DefaultBaselinePath() | ||||
|         { | ||||
|             var binaryDir = AppContext.BaseDirectory; | ||||
|             var projectDir = Path.GetFullPath(Path.Combine(binaryDir, "..", "..", "..")); | ||||
|             var benchRoot = Path.GetFullPath(Path.Combine(projectDir, "..")); | ||||
|             return Path.Combine(benchRoot, "baseline.csv"); | ||||
|         } | ||||
|  | ||||
|         private static void EnsureNext(string[] args, int index) | ||||
|         { | ||||
|             if (index + 1 >= args.Length) | ||||
|             { | ||||
|                 throw new ArgumentException("Missing value for argument."); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private static void PrintUsage() | ||||
|         { | ||||
|             Console.WriteLine("Usage: linknotmerge-vex-bench [options]"); | ||||
|             Console.WriteLine(); | ||||
|             Console.WriteLine("Options:"); | ||||
|             Console.WriteLine("  --config <path>                 Path to benchmark configuration JSON."); | ||||
|             Console.WriteLine("  --iterations <count>            Override iteration count."); | ||||
|             Console.WriteLine("  --threshold-ms <value>          Global latency threshold in milliseconds."); | ||||
|             Console.WriteLine("  --min-throughput <value>        Observation throughput floor (observations/second)."); | ||||
|             Console.WriteLine("  --min-event-throughput <value>  Event emission throughput floor (events/second)."); | ||||
|             Console.WriteLine("  --max-allocated-mb <value>      Global allocation ceiling (MB)."); | ||||
|             Console.WriteLine("  --csv <path>                    Write CSV results to path."); | ||||
|             Console.WriteLine("  --json <path>                   Write JSON results to path."); | ||||
|             Console.WriteLine("  --prometheus <path>             Write Prometheus exposition metrics to path."); | ||||
|             Console.WriteLine("  --baseline <path>               Baseline CSV path."); | ||||
|             Console.WriteLine("  --captured-at <iso8601>         Timestamp to embed in JSON metadata."); | ||||
|             Console.WriteLine("  --commit <sha>                  Commit identifier for metadata."); | ||||
|             Console.WriteLine("  --environment <name>            Environment label for metadata."); | ||||
|             Console.WriteLine("  --regression-limit <value>      Regression multiplier (default 1.15)."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal static class TablePrinter | ||||
| { | ||||
|     public static void Print(IEnumerable<VexScenarioResult> results) | ||||
|     { | ||||
|         Console.WriteLine("Scenario                     |   Observations | Statements |  Events |  Total(ms) | Correl(ms) |  Insert(ms) | Obs k/s | Evnt k/s | Alloc(MB)"); | ||||
|         Console.WriteLine("---------------------------- | ------------- | ---------- | ------- | ---------- | ---------- | ----------- | ------- | -------- | --------"); | ||||
|         foreach (var row in results) | ||||
|         { | ||||
|             Console.WriteLine(string.Join(" | ", new[] | ||||
|             { | ||||
|                 row.IdColumn, | ||||
|                 row.ObservationsColumn, | ||||
|                 row.StatementColumn, | ||||
|                 row.EventColumn, | ||||
|                 row.TotalMeanColumn, | ||||
|                 row.CorrelationMeanColumn, | ||||
|                 row.InsertMeanColumn, | ||||
|                 row.ObservationThroughputColumn, | ||||
|                 row.EventThroughputColumn, | ||||
|                 row.AllocatedColumn, | ||||
|             })); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal static class CsvWriter | ||||
| { | ||||
|     public static void Write(string path, IEnumerable<VexScenarioResult> results) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(path); | ||||
|         ArgumentNullException.ThrowIfNull(results); | ||||
|  | ||||
|         var resolved = Path.GetFullPath(path); | ||||
|         var directory = Path.GetDirectoryName(resolved); | ||||
|         if (!string.IsNullOrEmpty(directory)) | ||||
|         { | ||||
|             Directory.CreateDirectory(directory); | ||||
|         } | ||||
|  | ||||
|         using var stream = new FileStream(resolved, FileMode.Create, FileAccess.Write, FileShare.None); | ||||
|         using var writer = new StreamWriter(stream); | ||||
|         writer.WriteLine("scenario,iterations,observations,statements,events,mean_total_ms,p95_total_ms,max_total_ms,mean_insert_ms,mean_correlation_ms,mean_observation_throughput_per_sec,min_observation_throughput_per_sec,mean_event_throughput_per_sec,min_event_throughput_per_sec,max_allocated_mb"); | ||||
|  | ||||
|         foreach (var result in results) | ||||
|         { | ||||
|             writer.Write(result.Id); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.Iterations.ToString(CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.ObservationCount.ToString(CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.StatementCount.ToString(CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.EventCount.ToString(CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.TotalStatistics.MeanMs.ToString("F4", CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.TotalStatistics.P95Ms.ToString("F4", CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.TotalStatistics.MaxMs.ToString("F4", CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.InsertStatistics.MeanMs.ToString("F4", CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.CorrelationStatistics.MeanMs.ToString("F4", CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.ObservationThroughputStatistics.MeanPerSecond.ToString("F4", CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.ObservationThroughputStatistics.MinPerSecond.ToString("F4", CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.EventThroughputStatistics.MeanPerSecond.ToString("F4", CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.EventThroughputStatistics.MinPerSecond.ToString("F4", CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.AllocationStatistics.MaxAllocatedMb.ToString("F4", CultureInfo.InvariantCulture)); | ||||
|             writer.WriteLine(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,3 @@ | ||||
| using System.Runtime.CompilerServices; | ||||
|  | ||||
| [assembly: InternalsVisibleTo("StellaOps.Bench.LinkNotMerge.Vex.Tests")] | ||||
| @@ -0,0 +1,151 @@ | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge.Vex.Reporting; | ||||
|  | ||||
| internal static class BenchmarkJsonWriter | ||||
| { | ||||
|     private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) | ||||
|     { | ||||
|         WriteIndented = true, | ||||
|         DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, | ||||
|     }; | ||||
|  | ||||
|     public static async Task WriteAsync( | ||||
|         string path, | ||||
|         BenchmarkJsonMetadata metadata, | ||||
|         IReadOnlyList<BenchmarkScenarioReport> reports, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(path); | ||||
|         ArgumentNullException.ThrowIfNull(metadata); | ||||
|         ArgumentNullException.ThrowIfNull(reports); | ||||
|  | ||||
|         var resolved = Path.GetFullPath(path); | ||||
|         var directory = Path.GetDirectoryName(resolved); | ||||
|         if (!string.IsNullOrEmpty(directory)) | ||||
|         { | ||||
|             Directory.CreateDirectory(directory); | ||||
|         } | ||||
|  | ||||
|         var document = new BenchmarkJsonDocument( | ||||
|             metadata.SchemaVersion, | ||||
|             metadata.CapturedAtUtc, | ||||
|             metadata.Commit, | ||||
|             metadata.Environment, | ||||
|             reports.Select(CreateScenario).ToArray()); | ||||
|  | ||||
|         await using var stream = new FileStream(resolved, FileMode.Create, FileAccess.Write, FileShare.None); | ||||
|         await JsonSerializer.SerializeAsync(stream, document, SerializerOptions, cancellationToken).ConfigureAwait(false); | ||||
|         await stream.FlushAsync(cancellationToken).ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     private static BenchmarkJsonScenario CreateScenario(BenchmarkScenarioReport report) | ||||
|     { | ||||
|         var baseline = report.Baseline; | ||||
|         return new BenchmarkJsonScenario( | ||||
|             report.Result.Id, | ||||
|             report.Result.Label, | ||||
|             report.Result.Iterations, | ||||
|             report.Result.ObservationCount, | ||||
|             report.Result.StatementCount, | ||||
|             report.Result.EventCount, | ||||
|             report.Result.TotalStatistics.MeanMs, | ||||
|             report.Result.TotalStatistics.P95Ms, | ||||
|             report.Result.TotalStatistics.MaxMs, | ||||
|             report.Result.InsertStatistics.MeanMs, | ||||
|             report.Result.CorrelationStatistics.MeanMs, | ||||
|             report.Result.ObservationThroughputStatistics.MeanPerSecond, | ||||
|             report.Result.ObservationThroughputStatistics.MinPerSecond, | ||||
|             report.Result.EventThroughputStatistics.MeanPerSecond, | ||||
|             report.Result.EventThroughputStatistics.MinPerSecond, | ||||
|             report.Result.AllocationStatistics.MaxAllocatedMb, | ||||
|             report.Result.ThresholdMs, | ||||
|             report.Result.MinObservationThroughputPerSecond, | ||||
|             report.Result.MinEventThroughputPerSecond, | ||||
|             report.Result.MaxAllocatedThresholdMb, | ||||
|             baseline is null | ||||
|                 ? null | ||||
|                 : new BenchmarkJsonScenarioBaseline( | ||||
|                     baseline.Iterations, | ||||
|                     baseline.Observations, | ||||
|                     baseline.Statements, | ||||
|                     baseline.Events, | ||||
|                     baseline.MeanTotalMs, | ||||
|                     baseline.P95TotalMs, | ||||
|                     baseline.MaxTotalMs, | ||||
|                     baseline.MeanInsertMs, | ||||
|                     baseline.MeanCorrelationMs, | ||||
|                     baseline.MeanObservationThroughputPerSecond, | ||||
|                     baseline.MinObservationThroughputPerSecond, | ||||
|                     baseline.MeanEventThroughputPerSecond, | ||||
|                     baseline.MinEventThroughputPerSecond, | ||||
|                     baseline.MaxAllocatedMb), | ||||
|             new BenchmarkJsonScenarioRegression( | ||||
|                 report.DurationRegressionRatio, | ||||
|                 report.ObservationThroughputRegressionRatio, | ||||
|                 report.EventThroughputRegressionRatio, | ||||
|                 report.RegressionLimit, | ||||
|                 report.RegressionBreached)); | ||||
|     } | ||||
|  | ||||
|     private sealed record BenchmarkJsonDocument( | ||||
|         string SchemaVersion, | ||||
|         DateTimeOffset CapturedAt, | ||||
|         string? Commit, | ||||
|         string? Environment, | ||||
|         IReadOnlyList<BenchmarkJsonScenario> Scenarios); | ||||
|  | ||||
|     private sealed record BenchmarkJsonScenario( | ||||
|         string Id, | ||||
|         string Label, | ||||
|         int Iterations, | ||||
|         int Observations, | ||||
|         int Statements, | ||||
|         int Events, | ||||
|         double MeanTotalMs, | ||||
|         double P95TotalMs, | ||||
|         double MaxTotalMs, | ||||
|         double MeanInsertMs, | ||||
|         double MeanCorrelationMs, | ||||
|         double MeanObservationThroughputPerSecond, | ||||
|         double MinObservationThroughputPerSecond, | ||||
|         double MeanEventThroughputPerSecond, | ||||
|         double MinEventThroughputPerSecond, | ||||
|         double MaxAllocatedMb, | ||||
|         double? ThresholdMs, | ||||
|         double? MinObservationThroughputThresholdPerSecond, | ||||
|         double? MinEventThroughputThresholdPerSecond, | ||||
|         double? MaxAllocatedThresholdMb, | ||||
|         BenchmarkJsonScenarioBaseline? Baseline, | ||||
|         BenchmarkJsonScenarioRegression Regression); | ||||
|  | ||||
|     private sealed record BenchmarkJsonScenarioBaseline( | ||||
|         int Iterations, | ||||
|         int Observations, | ||||
|         int Statements, | ||||
|         int Events, | ||||
|         double MeanTotalMs, | ||||
|         double P95TotalMs, | ||||
|         double MaxTotalMs, | ||||
|         double MeanInsertMs, | ||||
|         double MeanCorrelationMs, | ||||
|         double MeanObservationThroughputPerSecond, | ||||
|         double MinObservationThroughputPerSecond, | ||||
|         double MeanEventThroughputPerSecond, | ||||
|         double MinEventThroughputPerSecond, | ||||
|         double MaxAllocatedMb); | ||||
|  | ||||
|     private sealed record BenchmarkJsonScenarioRegression( | ||||
|         double? DurationRatio, | ||||
|         double? ObservationThroughputRatio, | ||||
|         double? EventThroughputRatio, | ||||
|         double Limit, | ||||
|         bool Breached); | ||||
| } | ||||
|  | ||||
| internal sealed record BenchmarkJsonMetadata( | ||||
|     string SchemaVersion, | ||||
|     DateTimeOffset CapturedAtUtc, | ||||
|     string? Commit, | ||||
|     string? Environment); | ||||
| @@ -0,0 +1,89 @@ | ||||
| using StellaOps.Bench.LinkNotMerge.Vex.Baseline; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge.Vex.Reporting; | ||||
|  | ||||
| internal sealed class BenchmarkScenarioReport | ||||
| { | ||||
|     private const double DefaultRegressionLimit = 1.15d; | ||||
|  | ||||
|     public BenchmarkScenarioReport(VexScenarioResult result, BaselineEntry? baseline, double? regressionLimit = null) | ||||
|     { | ||||
|         Result = result ?? throw new ArgumentNullException(nameof(result)); | ||||
|         Baseline = baseline; | ||||
|         RegressionLimit = regressionLimit is { } limit && limit > 0 ? limit : DefaultRegressionLimit; | ||||
|         DurationRegressionRatio = CalculateRatio(result.TotalStatistics.MaxMs, baseline?.MaxTotalMs); | ||||
|         ObservationThroughputRegressionRatio = CalculateInverseRatio(result.ObservationThroughputStatistics.MinPerSecond, baseline?.MinObservationThroughputPerSecond); | ||||
|         EventThroughputRegressionRatio = CalculateInverseRatio(result.EventThroughputStatistics.MinPerSecond, baseline?.MinEventThroughputPerSecond); | ||||
|     } | ||||
|  | ||||
|     public VexScenarioResult Result { get; } | ||||
|  | ||||
|     public BaselineEntry? Baseline { get; } | ||||
|  | ||||
|     public double RegressionLimit { get; } | ||||
|  | ||||
|     public double? DurationRegressionRatio { get; } | ||||
|  | ||||
|     public double? ObservationThroughputRegressionRatio { get; } | ||||
|  | ||||
|     public double? EventThroughputRegressionRatio { get; } | ||||
|  | ||||
|     public bool DurationRegressionBreached => DurationRegressionRatio is { } ratio && ratio >= RegressionLimit; | ||||
|  | ||||
|     public bool ObservationThroughputRegressionBreached => ObservationThroughputRegressionRatio is { } ratio && ratio >= RegressionLimit; | ||||
|  | ||||
|     public bool EventThroughputRegressionBreached => EventThroughputRegressionRatio is { } ratio && ratio >= RegressionLimit; | ||||
|  | ||||
|     public bool RegressionBreached => DurationRegressionBreached || ObservationThroughputRegressionBreached || EventThroughputRegressionBreached; | ||||
|  | ||||
|     public IEnumerable<string> BuildRegressionFailureMessages() | ||||
|     { | ||||
|         if (Baseline is null) | ||||
|         { | ||||
|             yield break; | ||||
|         } | ||||
|  | ||||
|         if (DurationRegressionBreached && DurationRegressionRatio is { } durationRatio) | ||||
|         { | ||||
|             var delta = (durationRatio - 1d) * 100d; | ||||
|             yield return $"{Result.Id} exceeded max duration budget: {Result.TotalStatistics.MaxMs:F2} ms vs baseline {Baseline.MaxTotalMs:F2} ms (+{delta:F1}%)."; | ||||
|         } | ||||
|  | ||||
|         if (ObservationThroughputRegressionBreached && ObservationThroughputRegressionRatio is { } obsRatio) | ||||
|         { | ||||
|             var delta = (obsRatio - 1d) * 100d; | ||||
|             yield return $"{Result.Id} observation throughput regressed: min {Result.ObservationThroughputStatistics.MinPerSecond:N0} obs/s vs baseline {Baseline.MinObservationThroughputPerSecond:N0} obs/s (-{delta:F1}%)."; | ||||
|         } | ||||
|  | ||||
|         if (EventThroughputRegressionBreached && EventThroughputRegressionRatio is { } evtRatio) | ||||
|         { | ||||
|             var delta = (evtRatio - 1d) * 100d; | ||||
|             yield return $"{Result.Id} event throughput regressed: min {Result.EventThroughputStatistics.MinPerSecond:N0} events/s vs baseline {Baseline.MinEventThroughputPerSecond:N0} events/s (-{delta:F1}%)."; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static double? CalculateRatio(double current, double? baseline) | ||||
|     { | ||||
|         if (!baseline.HasValue || baseline.Value <= 0d) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return current / baseline.Value; | ||||
|     } | ||||
|  | ||||
|     private static double? CalculateInverseRatio(double current, double? baseline) | ||||
|     { | ||||
|         if (!baseline.HasValue || baseline.Value <= 0d) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         if (current <= 0d) | ||||
|         { | ||||
|             return double.PositiveInfinity; | ||||
|         } | ||||
|  | ||||
|         return baseline.Value / current; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,94 @@ | ||||
| using System.Globalization; | ||||
| using System.Text; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge.Vex.Reporting; | ||||
|  | ||||
| internal static class PrometheusWriter | ||||
| { | ||||
|     public static void Write(string path, IReadOnlyList<BenchmarkScenarioReport> reports) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(path); | ||||
|         ArgumentNullException.ThrowIfNull(reports); | ||||
|  | ||||
|         var resolved = Path.GetFullPath(path); | ||||
|         var directory = Path.GetDirectoryName(resolved); | ||||
|         if (!string.IsNullOrEmpty(directory)) | ||||
|         { | ||||
|             Directory.CreateDirectory(directory); | ||||
|         } | ||||
|  | ||||
|         var builder = new StringBuilder(); | ||||
|         builder.AppendLine("# HELP linknotmerge_vex_bench_total_ms Link-Not-Merge VEX benchmark total duration (milliseconds)."); | ||||
|         builder.AppendLine("# TYPE linknotmerge_vex_bench_total_ms gauge"); | ||||
|         builder.AppendLine("# HELP linknotmerge_vex_bench_throughput_per_sec Link-Not-Merge VEX benchmark observation throughput (observations per second)."); | ||||
|         builder.AppendLine("# TYPE linknotmerge_vex_bench_throughput_per_sec gauge"); | ||||
|         builder.AppendLine("# HELP linknotmerge_vex_bench_event_throughput_per_sec Link-Not-Merge VEX benchmark event throughput (events per second)."); | ||||
|         builder.AppendLine("# TYPE linknotmerge_vex_bench_event_throughput_per_sec gauge"); | ||||
|         builder.AppendLine("# HELP linknotmerge_vex_bench_allocated_mb Link-Not-Merge VEX benchmark max allocations (megabytes)."); | ||||
|         builder.AppendLine("# TYPE linknotmerge_vex_bench_allocated_mb gauge"); | ||||
|  | ||||
|         foreach (var report in reports) | ||||
|         { | ||||
|             var scenario = Escape(report.Result.Id); | ||||
|             AppendMetric(builder, "linknotmerge_vex_bench_mean_total_ms", scenario, report.Result.TotalStatistics.MeanMs); | ||||
|             AppendMetric(builder, "linknotmerge_vex_bench_p95_total_ms", scenario, report.Result.TotalStatistics.P95Ms); | ||||
|             AppendMetric(builder, "linknotmerge_vex_bench_max_total_ms", scenario, report.Result.TotalStatistics.MaxMs); | ||||
|             AppendMetric(builder, "linknotmerge_vex_bench_threshold_ms", scenario, report.Result.ThresholdMs); | ||||
|  | ||||
|             AppendMetric(builder, "linknotmerge_vex_bench_mean_observation_throughput_per_sec", scenario, report.Result.ObservationThroughputStatistics.MeanPerSecond); | ||||
|             AppendMetric(builder, "linknotmerge_vex_bench_min_observation_throughput_per_sec", scenario, report.Result.ObservationThroughputStatistics.MinPerSecond); | ||||
|             AppendMetric(builder, "linknotmerge_vex_bench_observation_throughput_floor_per_sec", scenario, report.Result.MinObservationThroughputPerSecond); | ||||
|  | ||||
|             AppendMetric(builder, "linknotmerge_vex_bench_mean_event_throughput_per_sec", scenario, report.Result.EventThroughputStatistics.MeanPerSecond); | ||||
|             AppendMetric(builder, "linknotmerge_vex_bench_min_event_throughput_per_sec", scenario, report.Result.EventThroughputStatistics.MinPerSecond); | ||||
|             AppendMetric(builder, "linknotmerge_vex_bench_event_throughput_floor_per_sec", scenario, report.Result.MinEventThroughputPerSecond); | ||||
|  | ||||
|             AppendMetric(builder, "linknotmerge_vex_bench_max_allocated_mb", scenario, report.Result.AllocationStatistics.MaxAllocatedMb); | ||||
|             AppendMetric(builder, "linknotmerge_vex_bench_max_allocated_threshold_mb", scenario, report.Result.MaxAllocatedThresholdMb); | ||||
|  | ||||
|             if (report.Baseline is { } baseline) | ||||
|             { | ||||
|                 AppendMetric(builder, "linknotmerge_vex_bench_baseline_max_total_ms", scenario, baseline.MaxTotalMs); | ||||
|                 AppendMetric(builder, "linknotmerge_vex_bench_baseline_min_observation_throughput_per_sec", scenario, baseline.MinObservationThroughputPerSecond); | ||||
|                 AppendMetric(builder, "linknotmerge_vex_bench_baseline_min_event_throughput_per_sec", scenario, baseline.MinEventThroughputPerSecond); | ||||
|             } | ||||
|  | ||||
|             if (report.DurationRegressionRatio is { } durationRatio) | ||||
|             { | ||||
|                 AppendMetric(builder, "linknotmerge_vex_bench_duration_regression_ratio", scenario, durationRatio); | ||||
|             } | ||||
|  | ||||
|             if (report.ObservationThroughputRegressionRatio is { } obsRatio) | ||||
|             { | ||||
|                 AppendMetric(builder, "linknotmerge_vex_bench_observation_regression_ratio", scenario, obsRatio); | ||||
|             } | ||||
|  | ||||
|             if (report.EventThroughputRegressionRatio is { } evtRatio) | ||||
|             { | ||||
|                 AppendMetric(builder, "linknotmerge_vex_bench_event_regression_ratio", scenario, evtRatio); | ||||
|             } | ||||
|  | ||||
|             AppendMetric(builder, "linknotmerge_vex_bench_regression_limit", scenario, report.RegressionLimit); | ||||
|             AppendMetric(builder, "linknotmerge_vex_bench_regression_breached", scenario, report.RegressionBreached ? 1 : 0); | ||||
|         } | ||||
|  | ||||
|         File.WriteAllText(resolved, builder.ToString(), Encoding.UTF8); | ||||
|     } | ||||
|  | ||||
|     private static void AppendMetric(StringBuilder builder, string metric, string scenario, double? value) | ||||
|     { | ||||
|         if (!value.HasValue) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         builder.Append(metric); | ||||
|         builder.Append("{scenario=\""); | ||||
|         builder.Append(scenario); | ||||
|         builder.Append("\"} "); | ||||
|         builder.AppendLine(value.Value.ToString("G17", CultureInfo.InvariantCulture)); | ||||
|     } | ||||
|  | ||||
|     private static string Escape(string value) => | ||||
|         value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal); | ||||
| } | ||||
| @@ -0,0 +1,84 @@ | ||||
| namespace StellaOps.Bench.LinkNotMerge.Vex; | ||||
|  | ||||
| internal readonly record struct DurationStatistics(double MeanMs, double P95Ms, double MaxMs) | ||||
| { | ||||
|     public static DurationStatistics From(IReadOnlyList<double> values) | ||||
|     { | ||||
|         if (values.Count == 0) | ||||
|         { | ||||
|             return new DurationStatistics(0, 0, 0); | ||||
|         } | ||||
|  | ||||
|         var sorted = values.ToArray(); | ||||
|         Array.Sort(sorted); | ||||
|  | ||||
|         var total = 0d; | ||||
|         foreach (var value in values) | ||||
|         { | ||||
|             total += value; | ||||
|         } | ||||
|  | ||||
|         var mean = total / values.Count; | ||||
|         var p95 = Percentile(sorted, 95); | ||||
|         var max = sorted[^1]; | ||||
|  | ||||
|         return new DurationStatistics(mean, p95, max); | ||||
|     } | ||||
|  | ||||
|     private static double Percentile(IReadOnlyList<double> sorted, double percentile) | ||||
|     { | ||||
|         if (sorted.Count == 0) | ||||
|         { | ||||
|             return 0; | ||||
|         } | ||||
|  | ||||
|         var rank = (percentile / 100d) * (sorted.Count - 1); | ||||
|         var lower = (int)Math.Floor(rank); | ||||
|         var upper = (int)Math.Ceiling(rank); | ||||
|         var weight = rank - lower; | ||||
|  | ||||
|         if (upper >= sorted.Count) | ||||
|         { | ||||
|             return sorted[lower]; | ||||
|         } | ||||
|  | ||||
|         return sorted[lower] + weight * (sorted[upper] - sorted[lower]); | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal readonly record struct ThroughputStatistics(double MeanPerSecond, double MinPerSecond) | ||||
| { | ||||
|     public static ThroughputStatistics From(IReadOnlyList<double> values) | ||||
|     { | ||||
|         if (values.Count == 0) | ||||
|         { | ||||
|             return new ThroughputStatistics(0, 0); | ||||
|         } | ||||
|  | ||||
|         var total = 0d; | ||||
|         var min = double.MaxValue; | ||||
|  | ||||
|         foreach (var value in values) | ||||
|         { | ||||
|             total += value; | ||||
|             min = Math.Min(min, value); | ||||
|         } | ||||
|  | ||||
|         var mean = total / values.Count; | ||||
|         return new ThroughputStatistics(mean, min); | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal readonly record struct AllocationStatistics(double MaxAllocatedMb) | ||||
| { | ||||
|     public static AllocationStatistics From(IReadOnlyList<double> values) | ||||
|     { | ||||
|         var max = 0d; | ||||
|         foreach (var value in values) | ||||
|         { | ||||
|             max = Math.Max(max, value); | ||||
|         } | ||||
|  | ||||
|         return new AllocationStatistics(max); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,16 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <OutputType>Exe</OutputType> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="MongoDB.Driver" Version="3.5.0" /> | ||||
|     <PackageReference Include="EphemeralMongo" Version="3.0.0" /> | ||||
|     <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0-rc.2.25502.107" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| @@ -0,0 +1,166 @@ | ||||
| using MongoDB.Bson; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge.Vex; | ||||
|  | ||||
| internal sealed class VexLinksetAggregator | ||||
| { | ||||
|     public VexAggregationResult Correlate(IEnumerable<BsonDocument> documents) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(documents); | ||||
|  | ||||
|         var groups = new Dictionary<string, VexAccumulator>(StringComparer.Ordinal); | ||||
|         var statementsSeen = 0; | ||||
|  | ||||
|         foreach (var document in documents) | ||||
|         { | ||||
|             var tenant = document.GetValue("tenant", "unknown").AsString; | ||||
|             var linksetValue = document.GetValue("linkset", new BsonDocument()); | ||||
|             var linkset = linksetValue.IsBsonDocument ? linksetValue.AsBsonDocument : new BsonDocument(); | ||||
|             var aliases = linkset.GetValue("aliases", new BsonArray()).AsBsonArray; | ||||
|  | ||||
|             var statementsValue = document.GetValue("statements", new BsonArray()); | ||||
|             var statements = statementsValue.IsBsonArray ? statementsValue.AsBsonArray : new BsonArray(); | ||||
|  | ||||
|             foreach (var statementValue in statements) | ||||
|             { | ||||
|                 if (!statementValue.IsBsonDocument) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 statementsSeen++; | ||||
|  | ||||
|                 var statement = statementValue.AsBsonDocument; | ||||
|                 var status = statement.GetValue("status", "unknown").AsString; | ||||
|                 var justification = statement.GetValue("justification", BsonNull.Value); | ||||
|                 var lastUpdated = statement.GetValue("last_updated", BsonNull.Value); | ||||
|                 var productValue = statement.GetValue("product", new BsonDocument()); | ||||
|                 var product = productValue.IsBsonDocument ? productValue.AsBsonDocument : new BsonDocument(); | ||||
|                 var productKey = product.GetValue("purl", "unknown").AsString; | ||||
|  | ||||
|                 foreach (var aliasValue in aliases) | ||||
|                 { | ||||
|                     if (!aliasValue.IsString) | ||||
|                     { | ||||
|                         continue; | ||||
|                     } | ||||
|  | ||||
|                     var alias = aliasValue.AsString; | ||||
|                     var key = string.Create(alias.Length + tenant.Length + productKey.Length + 2, (tenant, alias, productKey), static (span, data) => | ||||
|                     { | ||||
|                         var (tenantValue, aliasValue, productValue) = data; | ||||
|                         var offset = 0; | ||||
|                         tenantValue.AsSpan().CopyTo(span); | ||||
|                         offset += tenantValue.Length; | ||||
|                         span[offset++] = '|'; | ||||
|                         aliasValue.AsSpan().CopyTo(span[offset..]); | ||||
|                         offset += aliasValue.Length; | ||||
|                         span[offset++] = '|'; | ||||
|                         productValue.AsSpan().CopyTo(span[offset..]); | ||||
|                     }); | ||||
|  | ||||
|                     if (!groups.TryGetValue(key, out var accumulator)) | ||||
|                     { | ||||
|                         accumulator = new VexAccumulator(tenant, alias, productKey); | ||||
|                         groups[key] = accumulator; | ||||
|                     } | ||||
|  | ||||
|                     accumulator.AddStatement(status, justification, lastUpdated); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var eventDocuments = new List<BsonDocument>(groups.Count); | ||||
|         foreach (var accumulator in groups.Values) | ||||
|         { | ||||
|             if (accumulator.ShouldEmitEvent) | ||||
|             { | ||||
|                 eventDocuments.Add(accumulator.ToEvent()); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         return new VexAggregationResult( | ||||
|             LinksetCount: groups.Count, | ||||
|             StatementCount: statementsSeen, | ||||
|             EventCount: eventDocuments.Count, | ||||
|             EventDocuments: eventDocuments); | ||||
|     } | ||||
|  | ||||
|     private sealed class VexAccumulator | ||||
|     { | ||||
|         private readonly Dictionary<string, int> _statusCounts = new(StringComparer.Ordinal); | ||||
|         private readonly HashSet<string> _justifications = new(StringComparer.Ordinal); | ||||
|         private readonly string _tenant; | ||||
|         private readonly string _alias; | ||||
|         private readonly string _product; | ||||
|         private DateTime? _latest; | ||||
|  | ||||
|         public VexAccumulator(string tenant, string alias, string product) | ||||
|         { | ||||
|             _tenant = tenant; | ||||
|             _alias = alias; | ||||
|             _product = product; | ||||
|         } | ||||
|  | ||||
|         public void AddStatement(string status, BsonValue justification, BsonValue updatedAt) | ||||
|         { | ||||
|             if (!_statusCounts.TryAdd(status, 1)) | ||||
|             { | ||||
|                 _statusCounts[status]++; | ||||
|             } | ||||
|  | ||||
|             if (justification.IsString) | ||||
|             { | ||||
|                 _justifications.Add(justification.AsString); | ||||
|             } | ||||
|  | ||||
|             if (updatedAt.IsValidDateTime) | ||||
|             { | ||||
|                 var value = updatedAt.ToUniversalTime(); | ||||
|                 if (!_latest.HasValue || value > _latest) | ||||
|                 { | ||||
|                     _latest = value; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public bool ShouldEmitEvent | ||||
|         { | ||||
|             get | ||||
|             { | ||||
|                 if (_statusCounts.TryGetValue("affected", out var affected) && affected > 0) | ||||
|                 { | ||||
|                     return true; | ||||
|                 } | ||||
|  | ||||
|                 if (_statusCounts.TryGetValue("under_investigation", out var investigating) && investigating > 0) | ||||
|                 { | ||||
|                     return true; | ||||
|                 } | ||||
|  | ||||
|                 return false; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public BsonDocument ToEvent() | ||||
|         { | ||||
|             var payload = new BsonDocument | ||||
|             { | ||||
|                 ["tenant"] = _tenant, | ||||
|                 ["alias"] = _alias, | ||||
|                 ["product"] = _product, | ||||
|                 ["statuses"] = new BsonDocument(_statusCounts.Select(kvp => new BsonElement(kvp.Key, kvp.Value))), | ||||
|                 ["justifications"] = new BsonArray(_justifications.Select(justification => justification)), | ||||
|                 ["last_updated"] = _latest.HasValue ? _latest.Value : (BsonValue)BsonNull.Value, | ||||
|             }; | ||||
|  | ||||
|             return payload; | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed record VexAggregationResult( | ||||
|     int LinksetCount, | ||||
|     int StatementCount, | ||||
|     int EventCount, | ||||
|     IReadOnlyList<BsonDocument> EventDocuments); | ||||
| @@ -0,0 +1,252 @@ | ||||
| using System.Collections.Immutable; | ||||
| using System.Security.Cryptography; | ||||
| using MongoDB.Bson; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge.Vex; | ||||
|  | ||||
| internal static class VexObservationGenerator | ||||
| { | ||||
|     private static readonly ImmutableArray<string> StatusPool = ImmutableArray.Create( | ||||
|         "affected", | ||||
|         "not_affected", | ||||
|         "under_investigation"); | ||||
|  | ||||
|     private static readonly ImmutableArray<string> JustificationPool = ImmutableArray.Create( | ||||
|         "exploitation_mitigated", | ||||
|         "component_not_present", | ||||
|         "vulnerable_code_not_present", | ||||
|         "vulnerable_code_not_in_execute_path"); | ||||
|  | ||||
|     public static IReadOnlyList<VexObservationSeed> Generate(VexScenarioConfig config) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(config); | ||||
|  | ||||
|         var observationCount = config.ResolveObservationCount(); | ||||
|         var aliasGroups = config.ResolveAliasGroups(); | ||||
|         var statementsPerObservation = config.ResolveStatementsPerObservation(); | ||||
|         var tenantCount = config.ResolveTenantCount(); | ||||
|         var productsPerObservation = config.ResolveProductsPerObservation(); | ||||
|         var seed = config.ResolveSeed(); | ||||
|  | ||||
|         var seeds = new VexObservationSeed[observationCount]; | ||||
|         var random = new Random(seed); | ||||
|         var baseTime = new DateTimeOffset(2025, 10, 1, 0, 0, 0, TimeSpan.Zero); | ||||
|  | ||||
|         for (var index = 0; index < observationCount; index++) | ||||
|         { | ||||
|             var tenantIndex = index % tenantCount; | ||||
|             var tenant = $"tenant-{tenantIndex:D2}"; | ||||
|             var group = index % aliasGroups; | ||||
|             var revision = index / aliasGroups; | ||||
|             var vulnerabilityAlias = $"CVE-2025-{group:D4}"; | ||||
|             var upstreamId = $"VEX-{group:D4}-{revision:D3}"; | ||||
|             var observationId = $"{tenant}:vex:{group:D5}:{revision:D6}"; | ||||
|  | ||||
|             var fetchedAt = baseTime.AddMinutes(revision); | ||||
|             var receivedAt = fetchedAt.AddSeconds(2); | ||||
|             var documentVersion = fetchedAt.AddSeconds(15).ToString("O"); | ||||
|  | ||||
|             var products = CreateProducts(group, revision, productsPerObservation); | ||||
|             var statements = CreateStatements(vulnerabilityAlias, products, statementsPerObservation, random, fetchedAt); | ||||
|             var rawPayload = CreateRawPayload(upstreamId, vulnerabilityAlias, statements); | ||||
|             var contentHash = ComputeContentHash(rawPayload, tenant, group, revision); | ||||
|  | ||||
|             var aliases = ImmutableArray.Create(vulnerabilityAlias, $"GHSA-{group:D4}-{revision % 26 + 'a'}{revision % 26 + 'a'}"); | ||||
|             var references = ImmutableArray.Create( | ||||
|                 new VexReference("advisory", $"https://vendor.example/advisories/{vulnerabilityAlias.ToLowerInvariant()}"), | ||||
|                 new VexReference("fix", $"https://vendor.example/patch/{vulnerabilityAlias.ToLowerInvariant()}")); | ||||
|  | ||||
|             seeds[index] = new VexObservationSeed( | ||||
|                 ObservationId: observationId, | ||||
|                 Tenant: tenant, | ||||
|                 Vendor: "excititor-bench", | ||||
|                 Stream: "simulated", | ||||
|                 Api: $"https://bench.stella/vex/{group:D4}/{revision:D3}", | ||||
|                 CollectorVersion: "1.0.0-bench", | ||||
|                 UpstreamId: upstreamId, | ||||
|                 DocumentVersion: documentVersion, | ||||
|                 FetchedAt: fetchedAt, | ||||
|                 ReceivedAt: receivedAt, | ||||
|                 ContentHash: contentHash, | ||||
|                 VulnerabilityAlias: vulnerabilityAlias, | ||||
|                 Aliases: aliases, | ||||
|                 Products: products, | ||||
|                 Statements: statements, | ||||
|                 References: references, | ||||
|                 ContentFormat: "CycloneDX-VEX", | ||||
|                 SpecVersion: "1.4", | ||||
|                 RawPayload: rawPayload); | ||||
|         } | ||||
|  | ||||
|         return seeds; | ||||
|     } | ||||
|  | ||||
|     private static ImmutableArray<VexProduct> CreateProducts(int group, int revision, int count) | ||||
|     { | ||||
|         var builder = ImmutableArray.CreateBuilder<VexProduct>(count); | ||||
|         for (var index = 0; index < count; index++) | ||||
|         { | ||||
|             var purl = $"pkg:generic/stella/product-{group:D4}-{index}@{1 + revision % 5}.{index + 1}.{revision % 9}"; | ||||
|             builder.Add(new VexProduct(purl, $"component-{group % 30:D2}", $"namespace-{group % 10:D2}")); | ||||
|         } | ||||
|  | ||||
|         return builder.MoveToImmutable(); | ||||
|     } | ||||
|  | ||||
|     private static ImmutableArray<BsonDocument> CreateStatements( | ||||
|         string vulnerabilityAlias, | ||||
|         ImmutableArray<VexProduct> products, | ||||
|         int statementsPerObservation, | ||||
|         Random random, | ||||
|         DateTimeOffset baseTime) | ||||
|     { | ||||
|         var builder = ImmutableArray.CreateBuilder<BsonDocument>(statementsPerObservation); | ||||
|         for (var index = 0; index < statementsPerObservation; index++) | ||||
|         { | ||||
|             var statusIndex = random.Next(StatusPool.Length); | ||||
|             var status = StatusPool[statusIndex]; | ||||
|             var justification = JustificationPool[random.Next(JustificationPool.Length)]; | ||||
|             var product = products[index % products.Length]; | ||||
|             var statementId = $"stmt-{vulnerabilityAlias}-{index:D2}"; | ||||
|  | ||||
|             var document = new BsonDocument | ||||
|             { | ||||
|                 ["statement_id"] = statementId, | ||||
|                 ["vulnerability_alias"] = vulnerabilityAlias, | ||||
|                 ["product"] = new BsonDocument | ||||
|                 { | ||||
|                     ["purl"] = product.Purl, | ||||
|                     ["component"] = product.Component, | ||||
|                     ["namespace"] = product.Namespace, | ||||
|                 }, | ||||
|                 ["status"] = status, | ||||
|                 ["justification"] = justification, | ||||
|                 ["impact"] = status == "affected" ? "high" : "none", | ||||
|                 ["last_updated"] = baseTime.AddMinutes(index).UtcDateTime, | ||||
|             }; | ||||
|  | ||||
|             builder.Add(document); | ||||
|         } | ||||
|  | ||||
|         return builder.MoveToImmutable(); | ||||
|     } | ||||
|  | ||||
|     private static BsonDocument CreateRawPayload(string upstreamId, string vulnerabilityAlias, ImmutableArray<BsonDocument> statements) | ||||
|     { | ||||
|         var doc = new BsonDocument | ||||
|         { | ||||
|             ["documentId"] = upstreamId, | ||||
|             ["title"] = $"Simulated VEX report {upstreamId}", | ||||
|             ["summary"] = $"Synthetic VEX payload for {vulnerabilityAlias}.", | ||||
|             ["statements"] = new BsonArray(statements), | ||||
|         }; | ||||
|  | ||||
|         return doc; | ||||
|     } | ||||
|  | ||||
|     private static string ComputeContentHash(BsonDocument rawPayload, string tenant, int group, int revision) | ||||
|     { | ||||
|         using var sha256 = SHA256.Create(); | ||||
|         var seed = $"{tenant}|{group}|{revision}"; | ||||
|         var rawBytes = rawPayload.ToBson(); | ||||
|         var seedBytes = System.Text.Encoding.UTF8.GetBytes(seed); | ||||
|         var combined = new byte[rawBytes.Length + seedBytes.Length]; | ||||
|         Buffer.BlockCopy(rawBytes, 0, combined, 0, rawBytes.Length); | ||||
|         Buffer.BlockCopy(seedBytes, 0, combined, rawBytes.Length, seedBytes.Length); | ||||
|         var hash = sha256.ComputeHash(combined); | ||||
|         return $"sha256:{Convert.ToHexString(hash)}"; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed record VexObservationSeed( | ||||
|     string ObservationId, | ||||
|     string Tenant, | ||||
|     string Vendor, | ||||
|     string Stream, | ||||
|     string Api, | ||||
|     string CollectorVersion, | ||||
|     string UpstreamId, | ||||
|     string DocumentVersion, | ||||
|     DateTimeOffset FetchedAt, | ||||
|     DateTimeOffset ReceivedAt, | ||||
|     string ContentHash, | ||||
|     string VulnerabilityAlias, | ||||
|     ImmutableArray<string> Aliases, | ||||
|     ImmutableArray<VexProduct> Products, | ||||
|     ImmutableArray<BsonDocument> Statements, | ||||
|     ImmutableArray<VexReference> References, | ||||
|     string ContentFormat, | ||||
|     string SpecVersion, | ||||
|     BsonDocument RawPayload) | ||||
| { | ||||
|     public BsonDocument ToBsonDocument() | ||||
|     { | ||||
|         var aliases = new BsonArray(Aliases.Select(alias => alias)); | ||||
|         var statements = new BsonArray(Statements); | ||||
|         var productsArray = new BsonArray(Products.Select(product => new BsonDocument | ||||
|         { | ||||
|             ["purl"] = product.Purl, | ||||
|             ["component"] = product.Component, | ||||
|             ["namespace"] = product.Namespace, | ||||
|         })); | ||||
|         var references = new BsonArray(References.Select(reference => new BsonDocument | ||||
|         { | ||||
|             ["type"] = reference.Type, | ||||
|             ["url"] = reference.Url, | ||||
|         })); | ||||
|  | ||||
|         var document = new BsonDocument | ||||
|         { | ||||
|             ["_id"] = ObservationId, | ||||
|             ["tenant"] = Tenant, | ||||
|             ["source"] = new BsonDocument | ||||
|             { | ||||
|                 ["vendor"] = Vendor, | ||||
|                 ["stream"] = Stream, | ||||
|                 ["api"] = Api, | ||||
|                 ["collector_version"] = CollectorVersion, | ||||
|             }, | ||||
|             ["upstream"] = new BsonDocument | ||||
|             { | ||||
|                 ["upstream_id"] = UpstreamId, | ||||
|                 ["document_version"] = DocumentVersion, | ||||
|                 ["fetched_at"] = FetchedAt.UtcDateTime, | ||||
|                 ["received_at"] = ReceivedAt.UtcDateTime, | ||||
|                 ["content_hash"] = ContentHash, | ||||
|                 ["signature"] = new BsonDocument | ||||
|                 { | ||||
|                     ["present"] = false, | ||||
|                     ["format"] = BsonNull.Value, | ||||
|                     ["key_id"] = BsonNull.Value, | ||||
|                     ["signature"] = BsonNull.Value, | ||||
|                 }, | ||||
|             }, | ||||
|             ["content"] = new BsonDocument | ||||
|             { | ||||
|                 ["format"] = ContentFormat, | ||||
|                 ["spec_version"] = SpecVersion, | ||||
|                 ["raw"] = RawPayload, | ||||
|             }, | ||||
|             ["identifiers"] = new BsonDocument | ||||
|             { | ||||
|                 ["aliases"] = aliases, | ||||
|                 ["primary"] = VulnerabilityAlias, | ||||
|             }, | ||||
|             ["statements"] = statements, | ||||
|             ["linkset"] = new BsonDocument | ||||
|             { | ||||
|                 ["aliases"] = aliases, | ||||
|                 ["products"] = productsArray, | ||||
|                 ["references"] = references, | ||||
|                 ["reconciled_from"] = new BsonArray { "/statements" }, | ||||
|             }, | ||||
|             ["supersedes"] = BsonNull.Value, | ||||
|         }; | ||||
|  | ||||
|         return document; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed record VexProduct(string Purl, string Component, string Namespace); | ||||
|  | ||||
| internal sealed record VexReference(string Type, string Url); | ||||
| @@ -0,0 +1,183 @@ | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge.Vex; | ||||
|  | ||||
| internal sealed record VexBenchmarkConfig( | ||||
|     double? ThresholdMs, | ||||
|     double? MinThroughputPerSecond, | ||||
|     double? MinEventThroughputPerSecond, | ||||
|     double? MaxAllocatedMb, | ||||
|     int? Iterations, | ||||
|     IReadOnlyList<VexScenarioConfig> Scenarios) | ||||
| { | ||||
|     public static async Task<VexBenchmarkConfig> LoadAsync(string path) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(path); | ||||
|  | ||||
|         var resolved = Path.GetFullPath(path); | ||||
|         if (!File.Exists(resolved)) | ||||
|         { | ||||
|             throw new FileNotFoundException($"Benchmark configuration '{resolved}' was not found.", resolved); | ||||
|         } | ||||
|  | ||||
|         await using var stream = File.OpenRead(resolved); | ||||
|         var model = await JsonSerializer.DeserializeAsync<VexBenchmarkConfigModel>( | ||||
|             stream, | ||||
|             new JsonSerializerOptions(JsonSerializerDefaults.Web) | ||||
|             { | ||||
|                 PropertyNameCaseInsensitive = true, | ||||
|                 ReadCommentHandling = JsonCommentHandling.Skip, | ||||
|                 AllowTrailingCommas = true, | ||||
|             }).ConfigureAwait(false); | ||||
|  | ||||
|         if (model is null) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Benchmark configuration '{resolved}' could not be parsed."); | ||||
|         } | ||||
|  | ||||
|         if (model.Scenarios.Count == 0) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Benchmark configuration '{resolved}' does not contain any scenarios."); | ||||
|         } | ||||
|  | ||||
|         foreach (var scenario in model.Scenarios) | ||||
|         { | ||||
|             scenario.Validate(); | ||||
|         } | ||||
|  | ||||
|         return new VexBenchmarkConfig( | ||||
|             model.ThresholdMs, | ||||
|             model.MinThroughputPerSecond, | ||||
|             model.MinEventThroughputPerSecond, | ||||
|             model.MaxAllocatedMb, | ||||
|             model.Iterations, | ||||
|             model.Scenarios); | ||||
|     } | ||||
|  | ||||
|     private sealed class VexBenchmarkConfigModel | ||||
|     { | ||||
|         [JsonPropertyName("thresholdMs")] | ||||
|         public double? ThresholdMs { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("minThroughputPerSecond")] | ||||
|         public double? MinThroughputPerSecond { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("minEventThroughputPerSecond")] | ||||
|         public double? MinEventThroughputPerSecond { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("maxAllocatedMb")] | ||||
|         public double? MaxAllocatedMb { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("iterations")] | ||||
|         public int? Iterations { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("scenarios")] | ||||
|         public List<VexScenarioConfig> Scenarios { get; init; } = new(); | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed class VexScenarioConfig | ||||
| { | ||||
|     private const int DefaultObservationCount = 4_000; | ||||
|     private const int DefaultAliasGroups = 400; | ||||
|     private const int DefaultStatementsPerObservation = 6; | ||||
|     private const int DefaultProductsPerObservation = 3; | ||||
|     private const int DefaultTenants = 3; | ||||
|     private const int DefaultBatchSize = 250; | ||||
|     private const int DefaultSeed = 520_025; | ||||
|  | ||||
|     [JsonPropertyName("id")] | ||||
|     public string? Id { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("label")] | ||||
|     public string? Label { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("observations")] | ||||
|     public int? Observations { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("aliasGroups")] | ||||
|     public int? AliasGroups { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("statementsPerObservation")] | ||||
|     public int? StatementsPerObservation { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("productsPerObservation")] | ||||
|     public int? ProductsPerObservation { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("tenants")] | ||||
|     public int? Tenants { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("batchSize")] | ||||
|     public int? BatchSize { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("seed")] | ||||
|     public int? Seed { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("iterations")] | ||||
|     public int? Iterations { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("thresholdMs")] | ||||
|     public double? ThresholdMs { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("minThroughputPerSecond")] | ||||
|     public double? MinThroughputPerSecond { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("minEventThroughputPerSecond")] | ||||
|     public double? MinEventThroughputPerSecond { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("maxAllocatedMb")] | ||||
|     public double? MaxAllocatedMb { get; init; } | ||||
|  | ||||
|     public string ScenarioId => string.IsNullOrWhiteSpace(Id) ? "vex" : Id!.Trim(); | ||||
|  | ||||
|     public string DisplayLabel => string.IsNullOrWhiteSpace(Label) ? ScenarioId : Label!.Trim(); | ||||
|  | ||||
|     public int ResolveObservationCount() => Observations is > 0 ? Observations.Value : DefaultObservationCount; | ||||
|  | ||||
|     public int ResolveAliasGroups() => AliasGroups is > 0 ? AliasGroups.Value : DefaultAliasGroups; | ||||
|  | ||||
|     public int ResolveStatementsPerObservation() => StatementsPerObservation is > 0 ? StatementsPerObservation.Value : DefaultStatementsPerObservation; | ||||
|  | ||||
|     public int ResolveProductsPerObservation() => ProductsPerObservation is > 0 ? ProductsPerObservation.Value : DefaultProductsPerObservation; | ||||
|  | ||||
|     public int ResolveTenantCount() => Tenants is > 0 ? Tenants.Value : DefaultTenants; | ||||
|  | ||||
|     public int ResolveBatchSize() => BatchSize is > 0 ? BatchSize.Value : DefaultBatchSize; | ||||
|  | ||||
|     public int ResolveSeed() => Seed is > 0 ? Seed.Value : DefaultSeed; | ||||
|  | ||||
|     public int ResolveIterations(int? defaultIterations) | ||||
|     { | ||||
|         var iterations = Iterations ?? defaultIterations ?? 3; | ||||
|         if (iterations <= 0) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Scenario '{ScenarioId}' requires iterations > 0."); | ||||
|         } | ||||
|  | ||||
|         return iterations; | ||||
|     } | ||||
|  | ||||
|     public void Validate() | ||||
|     { | ||||
|         if (ResolveObservationCount() <= 0) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Scenario '{ScenarioId}' requires observations > 0."); | ||||
|         } | ||||
|  | ||||
|         if (ResolveAliasGroups() <= 0) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Scenario '{ScenarioId}' requires aliasGroups > 0."); | ||||
|         } | ||||
|  | ||||
|         if (ResolveStatementsPerObservation() <= 0) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Scenario '{ScenarioId}' requires statementsPerObservation > 0."); | ||||
|         } | ||||
|  | ||||
|         if (ResolveProductsPerObservation() <= 0) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Scenario '{ScenarioId}' requires productsPerObservation > 0."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,14 @@ | ||||
| namespace StellaOps.Bench.LinkNotMerge.Vex; | ||||
|  | ||||
| internal sealed record VexScenarioExecutionResult( | ||||
|     IReadOnlyList<double> TotalDurationsMs, | ||||
|     IReadOnlyList<double> InsertDurationsMs, | ||||
|     IReadOnlyList<double> CorrelationDurationsMs, | ||||
|     IReadOnlyList<double> AllocatedMb, | ||||
|     IReadOnlyList<double> ObservationThroughputsPerSecond, | ||||
|     IReadOnlyList<double> EventThroughputsPerSecond, | ||||
|     int ObservationCount, | ||||
|     int AliasGroups, | ||||
|     int StatementCount, | ||||
|     int EventCount, | ||||
|     VexAggregationResult AggregationResult); | ||||
| @@ -0,0 +1,43 @@ | ||||
| using System.Globalization; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge.Vex; | ||||
|  | ||||
| internal sealed record VexScenarioResult( | ||||
|     string Id, | ||||
|     string Label, | ||||
|     int Iterations, | ||||
|     int ObservationCount, | ||||
|     int AliasGroups, | ||||
|     int StatementCount, | ||||
|     int EventCount, | ||||
|     DurationStatistics TotalStatistics, | ||||
|     DurationStatistics InsertStatistics, | ||||
|     DurationStatistics CorrelationStatistics, | ||||
|     ThroughputStatistics ObservationThroughputStatistics, | ||||
|     ThroughputStatistics EventThroughputStatistics, | ||||
|     AllocationStatistics AllocationStatistics, | ||||
|     double? ThresholdMs, | ||||
|     double? MinObservationThroughputPerSecond, | ||||
|     double? MinEventThroughputPerSecond, | ||||
|     double? MaxAllocatedThresholdMb) | ||||
| { | ||||
|     public string IdColumn => Id.Length <= 28 ? Id.PadRight(28) : Id[..28]; | ||||
|  | ||||
|     public string ObservationsColumn => ObservationCount.ToString("N0", CultureInfo.InvariantCulture).PadLeft(12); | ||||
|  | ||||
|     public string StatementColumn => StatementCount.ToString("N0", CultureInfo.InvariantCulture).PadLeft(10); | ||||
|  | ||||
|     public string EventColumn => EventCount.ToString("N0", CultureInfo.InvariantCulture).PadLeft(8); | ||||
|  | ||||
|     public string TotalMeanColumn => TotalStatistics.MeanMs.ToString("F2", CultureInfo.InvariantCulture).PadLeft(10); | ||||
|  | ||||
|     public string CorrelationMeanColumn => CorrelationStatistics.MeanMs.ToString("F2", CultureInfo.InvariantCulture).PadLeft(10); | ||||
|  | ||||
|     public string InsertMeanColumn => InsertStatistics.MeanMs.ToString("F2", CultureInfo.InvariantCulture).PadLeft(10); | ||||
|  | ||||
|     public string ObservationThroughputColumn => (ObservationThroughputStatistics.MinPerSecond / 1_000d).ToString("F2", CultureInfo.InvariantCulture).PadLeft(11); | ||||
|  | ||||
|     public string EventThroughputColumn => (EventThroughputStatistics.MinPerSecond / 1_000d).ToString("F2", CultureInfo.InvariantCulture).PadLeft(11); | ||||
|  | ||||
|     public string AllocatedColumn => AllocationStatistics.MaxAllocatedMb.ToString("F2", CultureInfo.InvariantCulture).PadLeft(9); | ||||
| } | ||||
| @@ -0,0 +1,138 @@ | ||||
| using System.Diagnostics; | ||||
| using EphemeralMongo; | ||||
| using MongoDB.Bson; | ||||
| using MongoDB.Driver; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge.Vex; | ||||
|  | ||||
| internal sealed class VexScenarioRunner | ||||
| { | ||||
|     private readonly VexScenarioConfig _config; | ||||
|     private readonly IReadOnlyList<VexObservationSeed> _seeds; | ||||
|  | ||||
|     public VexScenarioRunner(VexScenarioConfig config) | ||||
|     { | ||||
|         _config = config ?? throw new ArgumentNullException(nameof(config)); | ||||
|         _seeds = VexObservationGenerator.Generate(config); | ||||
|     } | ||||
|  | ||||
|     public VexScenarioExecutionResult Execute(int iterations, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (iterations <= 0) | ||||
|         { | ||||
|             throw new ArgumentOutOfRangeException(nameof(iterations), iterations, "Iterations must be positive."); | ||||
|         } | ||||
|  | ||||
|         var totalDurations = new double[iterations]; | ||||
|         var insertDurations = new double[iterations]; | ||||
|         var correlationDurations = new double[iterations]; | ||||
|         var allocated = new double[iterations]; | ||||
|         var observationThroughputs = new double[iterations]; | ||||
|         var eventThroughputs = new double[iterations]; | ||||
|         VexAggregationResult lastAggregation = new(0, 0, 0, Array.Empty<BsonDocument>()); | ||||
|  | ||||
|         for (var iteration = 0; iteration < iterations; iteration++) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             using var runner = MongoRunner.Run(new MongoRunnerOptions | ||||
|             { | ||||
|                 UseSingleNodeReplicaSet = false, | ||||
|             }); | ||||
|  | ||||
|             var client = new MongoClient(runner.ConnectionString); | ||||
|             var database = client.GetDatabase("linknotmerge_vex_bench"); | ||||
|             var collection = database.GetCollection<BsonDocument>("vex_observations"); | ||||
|  | ||||
|             CreateIndexes(collection, cancellationToken); | ||||
|  | ||||
|             var beforeAllocated = GC.GetTotalAllocatedBytes(); | ||||
|  | ||||
|             var insertStopwatch = Stopwatch.StartNew(); | ||||
|             InsertObservations(collection, _seeds, _config.ResolveBatchSize(), cancellationToken); | ||||
|             insertStopwatch.Stop(); | ||||
|  | ||||
|             var correlationStopwatch = Stopwatch.StartNew(); | ||||
|             var documents = collection | ||||
|                 .Find(FilterDefinition<BsonDocument>.Empty) | ||||
|                 .Project(Builders<BsonDocument>.Projection | ||||
|                     .Include("tenant") | ||||
|                     .Include("statements") | ||||
|                     .Include("linkset")) | ||||
|                 .ToList(cancellationToken); | ||||
|  | ||||
|             var aggregator = new VexLinksetAggregator(); | ||||
|             lastAggregation = aggregator.Correlate(documents); | ||||
|             correlationStopwatch.Stop(); | ||||
|  | ||||
|             var totalElapsed = insertStopwatch.Elapsed + correlationStopwatch.Elapsed; | ||||
|             var afterAllocated = GC.GetTotalAllocatedBytes(); | ||||
|  | ||||
|             totalDurations[iteration] = totalElapsed.TotalMilliseconds; | ||||
|             insertDurations[iteration] = insertStopwatch.Elapsed.TotalMilliseconds; | ||||
|             correlationDurations[iteration] = correlationStopwatch.Elapsed.TotalMilliseconds; | ||||
|             allocated[iteration] = Math.Max(0, afterAllocated - beforeAllocated) / (1024d * 1024d); | ||||
|  | ||||
|             var totalSeconds = Math.Max(totalElapsed.TotalSeconds, 0.0001d); | ||||
|             observationThroughputs[iteration] = _seeds.Count / totalSeconds; | ||||
|  | ||||
|             var eventSeconds = Math.Max(correlationStopwatch.Elapsed.TotalSeconds, 0.0001d); | ||||
|             var eventCount = Math.Max(lastAggregation.EventCount, 1); | ||||
|             eventThroughputs[iteration] = eventCount / eventSeconds; | ||||
|         } | ||||
|  | ||||
|         return new VexScenarioExecutionResult( | ||||
|             totalDurations, | ||||
|             insertDurations, | ||||
|             correlationDurations, | ||||
|             allocated, | ||||
|             observationThroughputs, | ||||
|             eventThroughputs, | ||||
|             ObservationCount: _seeds.Count, | ||||
|             AliasGroups: _config.ResolveAliasGroups(), | ||||
|             StatementCount: lastAggregation.StatementCount, | ||||
|             EventCount: lastAggregation.EventCount, | ||||
|             AggregationResult: lastAggregation); | ||||
|     } | ||||
|  | ||||
|     private static void InsertObservations( | ||||
|         IMongoCollection<BsonDocument> collection, | ||||
|         IReadOnlyList<VexObservationSeed> seeds, | ||||
|         int batchSize, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         for (var offset = 0; offset < seeds.Count; offset += batchSize) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             var remaining = Math.Min(batchSize, seeds.Count - offset); | ||||
|             var batch = new List<BsonDocument>(remaining); | ||||
|             for (var index = 0; index < remaining; index++) | ||||
|             { | ||||
|                 batch.Add(seeds[offset + index].ToBsonDocument()); | ||||
|             } | ||||
|  | ||||
|             collection.InsertMany(batch, new InsertManyOptions | ||||
|             { | ||||
|                 IsOrdered = false, | ||||
|                 BypassDocumentValidation = true, | ||||
|             }, cancellationToken); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static void CreateIndexes(IMongoCollection<BsonDocument> collection, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var indexKeys = Builders<BsonDocument>.IndexKeys | ||||
|             .Ascending("tenant") | ||||
|             .Ascending("linkset.aliases"); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             collection.Indexes.CreateOne(new CreateIndexModel<BsonDocument>(indexKeys), cancellationToken: cancellationToken); | ||||
|         } | ||||
|         catch | ||||
|         { | ||||
|             // non-fatal | ||||
|         } | ||||
|     } | ||||
| } | ||||
							
								
								
									
										4
									
								
								src/StellaOps.Bench/LinkNotMerge.Vex/baseline.csv
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								src/StellaOps.Bench/LinkNotMerge.Vex/baseline.csv
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,4 @@ | ||||
| scenario,iterations,observations,statements,events,mean_total_ms,p95_total_ms,max_total_ms,mean_insert_ms,mean_correlation_ms,mean_observation_throughput_per_sec,min_observation_throughput_per_sec,mean_event_throughput_per_sec,min_event_throughput_per_sec,max_allocated_mb | ||||
| vex_ingest_baseline,5,4000,24000,21326,842.8191,1319.3038,1432.7675,346.7277,496.0915,5349.8940,2791.7998,48942.4901,24653.0556,138.6365 | ||||
| vex_ingest_medium,5,8000,64000,56720,1525.9929,1706.8900,1748.9056,533.3378,992.6552,5274.5883,4574.2892,57654.9190,48531.7353,326.8638 | ||||
| vex_ingest_high,5,12000,120000,106910,2988.5094,3422.1728,3438.9364,903.3927,2085.1167,4066.2300,3489.4510,52456.9493,42358.0556,583.9903 | ||||
| 
 | 
							
								
								
									
										54
									
								
								src/StellaOps.Bench/LinkNotMerge.Vex/config.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src/StellaOps.Bench/LinkNotMerge.Vex/config.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | ||||
| { | ||||
|   "thresholdMs": 4200, | ||||
|   "minThroughputPerSecond": 1800, | ||||
|   "minEventThroughputPerSecond": 2000, | ||||
|   "maxAllocatedMb": 800, | ||||
|   "iterations": 5, | ||||
|   "scenarios": [ | ||||
|     { | ||||
|       "id": "vex_ingest_baseline", | ||||
|       "label": "4k observations, 400 aliases", | ||||
|       "observations": 4000, | ||||
|       "aliasGroups": 400, | ||||
|       "statementsPerObservation": 6, | ||||
|       "productsPerObservation": 3, | ||||
|       "tenants": 3, | ||||
|       "batchSize": 200, | ||||
|       "seed": 420020, | ||||
|       "thresholdMs": 2300, | ||||
|       "minThroughputPerSecond": 1800, | ||||
|       "minEventThroughputPerSecond": 2000, | ||||
|       "maxAllocatedMb": 220 | ||||
|     }, | ||||
|     { | ||||
|       "id": "vex_ingest_medium", | ||||
|       "label": "8k observations, 700 aliases", | ||||
|       "observations": 8000, | ||||
|       "aliasGroups": 700, | ||||
|       "statementsPerObservation": 8, | ||||
|       "productsPerObservation": 4, | ||||
|       "tenants": 5, | ||||
|       "batchSize": 300, | ||||
|       "seed": 520020, | ||||
|       "thresholdMs": 3200, | ||||
|       "minThroughputPerSecond": 2200, | ||||
|       "minEventThroughputPerSecond": 2500, | ||||
|       "maxAllocatedMb": 400 | ||||
|     }, | ||||
|     { | ||||
|       "id": "vex_ingest_high", | ||||
|       "label": "12k observations, 1100 aliases", | ||||
|       "observations": 12000, | ||||
|       "aliasGroups": 1100, | ||||
|       "statementsPerObservation": 10, | ||||
|       "productsPerObservation": 5, | ||||
|       "tenants": 7, | ||||
|       "batchSize": 400, | ||||
|       "seed": 620020, | ||||
|       "thresholdMs": 4200, | ||||
|       "minThroughputPerSecond": 2200, | ||||
|       "minEventThroughputPerSecond": 2500, | ||||
|       "maxAllocatedMb": 700 | ||||
|     } | ||||
|   ] | ||||
| } | ||||
							
								
								
									
										26
									
								
								src/StellaOps.Bench/LinkNotMerge/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								src/StellaOps.Bench/LinkNotMerge/README.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| # Link-Not-Merge Bench | ||||
|  | ||||
| Synthetic workload that measures advisory observation ingestion and linkset correlation throughput for the Link-Not-Merge program. | ||||
|  | ||||
| ## Scenarios | ||||
|  | ||||
| `config.json` defines three scenarios that vary observation volume, alias density, and correlation fan-out. Each scenario captures: | ||||
|  | ||||
| - Total latency (ingest + correlation) and p95/max percentiles | ||||
| - Insert latency against an ephemeral MongoDB instance | ||||
| - Correlator-only latency, tracking fan-out costs | ||||
| - Observation and Mongo insert throughput (ops/sec) | ||||
| - Peak managed heap allocations | ||||
|  | ||||
| ## Running locally | ||||
|  | ||||
| ```bash | ||||
| dotnet run \ | ||||
|   --project src/StellaOps.Bench/LinkNotMerge/StellaOps.Bench.LinkNotMerge/StellaOps.Bench.LinkNotMerge.csproj \ | ||||
|   -- \ | ||||
|   --csv out/linknotmerge-bench.csv \ | ||||
|   --json out/linknotmerge-bench.json \ | ||||
|   --prometheus out/linknotmerge-bench.prom | ||||
| ``` | ||||
|  | ||||
| The benchmark exits non-zero if latency exceeds configured thresholds, throughput falls below the floor, Mongo insert throughput regresses, allocations exceed the ceiling, or regression ratios breach the baseline. | ||||
| @@ -0,0 +1,38 @@ | ||||
| using System.IO; | ||||
| using System.Threading; | ||||
| using System.Threading.Tasks; | ||||
| using StellaOps.Bench.LinkNotMerge.Baseline; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge.Tests; | ||||
|  | ||||
| public sealed class BaselineLoaderTests | ||||
| { | ||||
|     [Fact] | ||||
|     public async Task LoadAsync_ReadsEntries() | ||||
|     { | ||||
|         var path = Path.GetTempFileName(); | ||||
|         try | ||||
|         { | ||||
|             await File.WriteAllTextAsync( | ||||
|                 path, | ||||
|                 "scenario,iterations,observations,aliases,linksets,mean_total_ms,p95_total_ms,max_total_ms,mean_insert_ms,mean_correlation_ms,mean_throughput_per_sec,min_throughput_per_sec,mean_mongo_throughput_per_sec,min_mongo_throughput_per_sec,max_allocated_mb\n" + | ||||
|                 "lnm_ingest_baseline,5,5000,500,450,320.5,340.1,360.9,120.2,210.3,15000.0,13500.0,18000.0,16500.0,96.5\n"); | ||||
|  | ||||
|             var baseline = await BaselineLoader.LoadAsync(path, CancellationToken.None); | ||||
|             var entry = Assert.Single(baseline); | ||||
|  | ||||
|             Assert.Equal("lnm_ingest_baseline", entry.Key); | ||||
|             Assert.Equal(5, entry.Value.Iterations); | ||||
|             Assert.Equal(5000, entry.Value.Observations); | ||||
|             Assert.Equal(500, entry.Value.Aliases); | ||||
|             Assert.Equal(360.9, entry.Value.MaxTotalMs); | ||||
|             Assert.Equal(16500.0, entry.Value.MinMongoThroughputPerSecond); | ||||
|             Assert.Equal(96.5, entry.Value.MaxAllocatedMb); | ||||
|         } | ||||
|         finally | ||||
|         { | ||||
|             File.Delete(path); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,81 @@ | ||||
| using StellaOps.Bench.LinkNotMerge.Baseline; | ||||
| using StellaOps.Bench.LinkNotMerge.Reporting; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge.Tests; | ||||
|  | ||||
| public sealed class BenchmarkScenarioReportTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void RegressionDetection_FlagsBreaches() | ||||
|     { | ||||
|         var result = new ScenarioResult( | ||||
|             Id: "scenario", | ||||
|             Label: "Scenario", | ||||
|             Iterations: 3, | ||||
|             ObservationCount: 1000, | ||||
|             AliasGroups: 100, | ||||
|             LinksetCount: 90, | ||||
|             TotalStatistics: new DurationStatistics(200, 240, 260), | ||||
|             InsertStatistics: new DurationStatistics(80, 90, 100), | ||||
|             CorrelationStatistics: new DurationStatistics(120, 150, 170), | ||||
|             TotalThroughputStatistics: new ThroughputStatistics(8000, 7000), | ||||
|             InsertThroughputStatistics: new ThroughputStatistics(9000, 8000), | ||||
|             AllocationStatistics: new AllocationStatistics(120), | ||||
|             ThresholdMs: null, | ||||
|             MinThroughputThresholdPerSecond: null, | ||||
|             MinMongoThroughputThresholdPerSecond: null, | ||||
|             MaxAllocatedThresholdMb: null); | ||||
|  | ||||
|         var baseline = new BaselineEntry( | ||||
|             ScenarioId: "scenario", | ||||
|             Iterations: 3, | ||||
|             Observations: 1000, | ||||
|             Aliases: 100, | ||||
|             Linksets: 90, | ||||
|             MeanTotalMs: 150, | ||||
|             P95TotalMs: 170, | ||||
|             MaxTotalMs: 180, | ||||
|             MeanInsertMs: 60, | ||||
|             MeanCorrelationMs: 90, | ||||
|             MeanThroughputPerSecond: 9000, | ||||
|             MinThroughputPerSecond: 8500, | ||||
|             MeanMongoThroughputPerSecond: 10000, | ||||
|             MinMongoThroughputPerSecond: 9500, | ||||
|             MaxAllocatedMb: 100); | ||||
|  | ||||
|         var report = new BenchmarkScenarioReport(result, baseline, regressionLimit: 1.1); | ||||
|  | ||||
|         Assert.True(report.DurationRegressionBreached); | ||||
|         Assert.True(report.ThroughputRegressionBreached); | ||||
|         Assert.True(report.MongoThroughputRegressionBreached); | ||||
|         Assert.Contains(report.BuildRegressionFailureMessages(), message => message.Contains("max duration")); | ||||
|     } | ||||
|  | ||||
|     [Fact] | ||||
|     public void RegressionDetection_NoBaseline_NoBreaches() | ||||
|     { | ||||
|         var result = new ScenarioResult( | ||||
|             Id: "scenario", | ||||
|             Label: "Scenario", | ||||
|             Iterations: 3, | ||||
|             ObservationCount: 1000, | ||||
|             AliasGroups: 100, | ||||
|             LinksetCount: 90, | ||||
|             TotalStatistics: new DurationStatistics(200, 220, 230), | ||||
|             InsertStatistics: new DurationStatistics(90, 100, 110), | ||||
|             CorrelationStatistics: new DurationStatistics(110, 120, 130), | ||||
|             TotalThroughputStatistics: new ThroughputStatistics(8000, 7900), | ||||
|             InsertThroughputStatistics: new ThroughputStatistics(9000, 8900), | ||||
|             AllocationStatistics: new AllocationStatistics(64), | ||||
|             ThresholdMs: null, | ||||
|             MinThroughputThresholdPerSecond: null, | ||||
|             MinMongoThroughputThresholdPerSecond: null, | ||||
|             MaxAllocatedThresholdMb: null); | ||||
|  | ||||
|         var report = new BenchmarkScenarioReport(result, baseline: null, regressionLimit: null); | ||||
|  | ||||
|         Assert.False(report.RegressionBreached); | ||||
|         Assert.Empty(report.BuildRegressionFailureMessages()); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,38 @@ | ||||
| using System.Linq; | ||||
| using System.Threading; | ||||
| using StellaOps.Bench.LinkNotMerge.Baseline; | ||||
| using Xunit; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge.Tests; | ||||
|  | ||||
| public sealed class LinkNotMergeScenarioRunnerTests | ||||
| { | ||||
|     [Fact] | ||||
|     public void Execute_BuildsDeterministicAggregation() | ||||
|     { | ||||
|         var config = new LinkNotMergeScenarioConfig | ||||
|         { | ||||
|             Id = "unit", | ||||
|             Observations = 120, | ||||
|             AliasGroups = 24, | ||||
|             PurlsPerObservation = 3, | ||||
|             CpesPerObservation = 2, | ||||
|             ReferencesPerObservation = 2, | ||||
|             Tenants = 3, | ||||
|             BatchSize = 40, | ||||
|             Seed = 1337, | ||||
|         }; | ||||
|  | ||||
|         var runner = new LinkNotMergeScenarioRunner(config); | ||||
|         var result = runner.Execute(iterations: 2, CancellationToken.None); | ||||
|  | ||||
|         Assert.Equal(120, result.ObservationCount); | ||||
|         Assert.Equal(24, result.AliasGroups); | ||||
|         Assert.True(result.TotalDurationsMs.All(value => value > 0)); | ||||
|         Assert.True(result.InsertThroughputsPerSecond.All(value => value > 0)); | ||||
|         Assert.True(result.TotalThroughputsPerSecond.All(value => value > 0)); | ||||
|         Assert.True(result.AllocatedMb.All(value => value >= 0)); | ||||
|         Assert.Equal(result.AggregationResult.LinksetCount, result.LinksetCount); | ||||
|         Assert.Equal(result.AggregationResult.ObservationCount, result.ObservationCount); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,28 @@ | ||||
| <Project Sdk="Microsoft.NET.Sdk"> | ||||
|   <PropertyGroup> | ||||
|     <TargetFramework>net10.0</TargetFramework> | ||||
|     <Nullable>enable</Nullable> | ||||
|     <ImplicitUsings>enable</ImplicitUsings> | ||||
|     <LangVersion>preview</LangVersion> | ||||
|     <TreatWarningsAsErrors>true</TreatWarningsAsErrors> | ||||
|     <UseConcelierTestInfra>false</UseConcelierTestInfra> | ||||
|   </PropertyGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0" /> | ||||
|     <PackageReference Include="xunit" Version="2.9.2" /> | ||||
|     <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"> | ||||
|       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||
|       <PrivateAssets>all</PrivateAssets> | ||||
|     </PackageReference> | ||||
|     <PackageReference Include="coverlet.collector" Version="6.0.4"> | ||||
|       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||
|       <PrivateAssets>all</PrivateAssets> | ||||
|     </PackageReference> | ||||
|     <PackageReference Include="EphemeralMongo" Version="3.0.0" /> | ||||
|   </ItemGroup> | ||||
|  | ||||
|   <ItemGroup> | ||||
|     <ProjectReference Include="..\StellaOps.Bench.LinkNotMerge\StellaOps.Bench.LinkNotMerge.csproj" /> | ||||
|   </ItemGroup> | ||||
| </Project> | ||||
| @@ -0,0 +1,18 @@ | ||||
| namespace StellaOps.Bench.LinkNotMerge.Baseline; | ||||
|  | ||||
| internal sealed record BaselineEntry( | ||||
|     string ScenarioId, | ||||
|     int Iterations, | ||||
|     int Observations, | ||||
|     int Aliases, | ||||
|     int Linksets, | ||||
|     double MeanTotalMs, | ||||
|     double P95TotalMs, | ||||
|     double MaxTotalMs, | ||||
|     double MeanInsertMs, | ||||
|     double MeanCorrelationMs, | ||||
|     double MeanThroughputPerSecond, | ||||
|     double MinThroughputPerSecond, | ||||
|     double MeanMongoThroughputPerSecond, | ||||
|     double MinMongoThroughputPerSecond, | ||||
|     double MaxAllocatedMb); | ||||
| @@ -0,0 +1,87 @@ | ||||
| using System.Globalization; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge.Baseline; | ||||
|  | ||||
| internal static class BaselineLoader | ||||
| { | ||||
|     public static async Task<IReadOnlyDictionary<string, BaselineEntry>> LoadAsync(string path, CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(path); | ||||
|  | ||||
|         var resolved = Path.GetFullPath(path); | ||||
|         if (!File.Exists(resolved)) | ||||
|         { | ||||
|             return new Dictionary<string, BaselineEntry>(StringComparer.OrdinalIgnoreCase); | ||||
|         } | ||||
|  | ||||
|         var result = new Dictionary<string, BaselineEntry>(StringComparer.OrdinalIgnoreCase); | ||||
|  | ||||
|         await using var stream = new FileStream(resolved, FileMode.Open, FileAccess.Read, FileShare.Read); | ||||
|         using var reader = new StreamReader(stream); | ||||
|  | ||||
|         var lineNumber = 0; | ||||
|         while (true) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             var line = await reader.ReadLineAsync().ConfigureAwait(false); | ||||
|             if (line is null) | ||||
|             { | ||||
|                 break; | ||||
|             } | ||||
|  | ||||
|             lineNumber++; | ||||
|             if (lineNumber == 1 || string.IsNullOrWhiteSpace(line)) | ||||
|             { | ||||
|                 continue; | ||||
|             } | ||||
|  | ||||
|             var parts = line.Split(',', StringSplitOptions.TrimEntries); | ||||
|             if (parts.Length < 15) | ||||
|             { | ||||
|                 throw new InvalidOperationException($"Baseline '{resolved}' line {lineNumber} is invalid (expected 15 columns, found {parts.Length})."); | ||||
|             } | ||||
|  | ||||
|             var entry = new BaselineEntry( | ||||
|                 ScenarioId: parts[0], | ||||
|                 Iterations: ParseInt(parts[1], resolved, lineNumber), | ||||
|                 Observations: ParseInt(parts[2], resolved, lineNumber), | ||||
|                 Aliases: ParseInt(parts[3], resolved, lineNumber), | ||||
|                 Linksets: ParseInt(parts[4], resolved, lineNumber), | ||||
|                 MeanTotalMs: ParseDouble(parts[5], resolved, lineNumber), | ||||
|                 P95TotalMs: ParseDouble(parts[6], resolved, lineNumber), | ||||
|                 MaxTotalMs: ParseDouble(parts[7], resolved, lineNumber), | ||||
|                 MeanInsertMs: ParseDouble(parts[8], resolved, lineNumber), | ||||
|                 MeanCorrelationMs: ParseDouble(parts[9], resolved, lineNumber), | ||||
|                 MeanThroughputPerSecond: ParseDouble(parts[10], resolved, lineNumber), | ||||
|                 MinThroughputPerSecond: ParseDouble(parts[11], resolved, lineNumber), | ||||
|                 MeanMongoThroughputPerSecond: ParseDouble(parts[12], resolved, lineNumber), | ||||
|                 MinMongoThroughputPerSecond: ParseDouble(parts[13], resolved, lineNumber), | ||||
|                 MaxAllocatedMb: ParseDouble(parts[14], resolved, lineNumber)); | ||||
|  | ||||
|             result[entry.ScenarioId] = entry; | ||||
|         } | ||||
|  | ||||
|         return result; | ||||
|     } | ||||
|  | ||||
|     private static int ParseInt(string value, string file, int line) | ||||
|     { | ||||
|         if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var result)) | ||||
|         { | ||||
|             return result; | ||||
|         } | ||||
|  | ||||
|         throw new InvalidOperationException($"Baseline '{file}' line {line} contains an invalid integer '{value}'."); | ||||
|     } | ||||
|  | ||||
|     private static double ParseDouble(string value, string file, int line) | ||||
|     { | ||||
|         if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var result)) | ||||
|         { | ||||
|             return result; | ||||
|         } | ||||
|  | ||||
|         throw new InvalidOperationException($"Baseline '{file}' line {line} contains an invalid number '{value}'."); | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,210 @@ | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge; | ||||
|  | ||||
| internal sealed record BenchmarkConfig( | ||||
|     double? ThresholdMs, | ||||
|     double? MinThroughputPerSecond, | ||||
|     double? MinMongoThroughputPerSecond, | ||||
|     double? MaxAllocatedMb, | ||||
|     int? Iterations, | ||||
|     IReadOnlyList<LinkNotMergeScenarioConfig> Scenarios) | ||||
| { | ||||
|     public static async Task<BenchmarkConfig> LoadAsync(string path) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(path); | ||||
|  | ||||
|         var resolved = Path.GetFullPath(path); | ||||
|         if (!File.Exists(resolved)) | ||||
|         { | ||||
|             throw new FileNotFoundException($"Benchmark configuration '{resolved}' was not found.", resolved); | ||||
|         } | ||||
|  | ||||
|         await using var stream = File.OpenRead(resolved); | ||||
|         var model = await JsonSerializer.DeserializeAsync<BenchmarkConfigModel>( | ||||
|             stream, | ||||
|             new JsonSerializerOptions(JsonSerializerDefaults.Web) | ||||
|             { | ||||
|                 PropertyNameCaseInsensitive = true, | ||||
|                 ReadCommentHandling = JsonCommentHandling.Skip, | ||||
|                 AllowTrailingCommas = true, | ||||
|             }).ConfigureAwait(false); | ||||
|  | ||||
|         if (model is null) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Benchmark configuration '{resolved}' could not be parsed."); | ||||
|         } | ||||
|  | ||||
|         if (model.Scenarios.Count == 0) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Benchmark configuration '{resolved}' does not contain any scenarios."); | ||||
|         } | ||||
|  | ||||
|         foreach (var scenario in model.Scenarios) | ||||
|         { | ||||
|             scenario.Validate(); | ||||
|         } | ||||
|  | ||||
|         return new BenchmarkConfig( | ||||
|             model.ThresholdMs, | ||||
|             model.MinThroughputPerSecond, | ||||
|             model.MinMongoThroughputPerSecond, | ||||
|             model.MaxAllocatedMb, | ||||
|             model.Iterations, | ||||
|             model.Scenarios); | ||||
|     } | ||||
|  | ||||
|     private sealed class BenchmarkConfigModel | ||||
|     { | ||||
|         [JsonPropertyName("thresholdMs")] | ||||
|         public double? ThresholdMs { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("minThroughputPerSecond")] | ||||
|         public double? MinThroughputPerSecond { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("minMongoThroughputPerSecond")] | ||||
|         public double? MinMongoThroughputPerSecond { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("maxAllocatedMb")] | ||||
|         public double? MaxAllocatedMb { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("iterations")] | ||||
|         public int? Iterations { get; init; } | ||||
|  | ||||
|         [JsonPropertyName("scenarios")] | ||||
|         public List<LinkNotMergeScenarioConfig> Scenarios { get; init; } = new(); | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed class LinkNotMergeScenarioConfig | ||||
| { | ||||
|     private const int DefaultObservationCount = 5_000; | ||||
|     private const int DefaultAliasGroups = 500; | ||||
|     private const int DefaultPurlsPerObservation = 4; | ||||
|     private const int DefaultCpesPerObservation = 2; | ||||
|     private const int DefaultReferencesPerObservation = 3; | ||||
|     private const int DefaultTenants = 4; | ||||
|     private const int DefaultBatchSize = 500; | ||||
|     private const int DefaultSeed = 42_022; | ||||
|  | ||||
|     [JsonPropertyName("id")] | ||||
|     public string? Id { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("label")] | ||||
|     public string? Label { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("observations")] | ||||
|     public int? Observations { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("aliasGroups")] | ||||
|     public int? AliasGroups { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("purlsPerObservation")] | ||||
|     public int? PurlsPerObservation { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("cpesPerObservation")] | ||||
|     public int? CpesPerObservation { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("referencesPerObservation")] | ||||
|     public int? ReferencesPerObservation { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("tenants")] | ||||
|     public int? Tenants { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("batchSize")] | ||||
|     public int? BatchSize { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("seed")] | ||||
|     public int? Seed { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("iterations")] | ||||
|     public int? Iterations { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("thresholdMs")] | ||||
|     public double? ThresholdMs { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("minThroughputPerSecond")] | ||||
|     public double? MinThroughputPerSecond { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("minMongoThroughputPerSecond")] | ||||
|     public double? MinMongoThroughputPerSecond { get; init; } | ||||
|  | ||||
|     [JsonPropertyName("maxAllocatedMb")] | ||||
|     public double? MaxAllocatedMb { get; init; } | ||||
|  | ||||
|     public string ScenarioId => string.IsNullOrWhiteSpace(Id) ? "linknotmerge" : Id!.Trim(); | ||||
|  | ||||
|     public string DisplayLabel => string.IsNullOrWhiteSpace(Label) ? ScenarioId : Label!.Trim(); | ||||
|  | ||||
|     public int ResolveObservationCount() => Observations.HasValue && Observations.Value > 0 | ||||
|         ? Observations.Value | ||||
|         : DefaultObservationCount; | ||||
|  | ||||
|     public int ResolveAliasGroups() => AliasGroups.HasValue && AliasGroups.Value > 0 | ||||
|         ? AliasGroups.Value | ||||
|         : DefaultAliasGroups; | ||||
|  | ||||
|     public int ResolvePurlsPerObservation() => PurlsPerObservation.HasValue && PurlsPerObservation.Value > 0 | ||||
|         ? PurlsPerObservation.Value | ||||
|         : DefaultPurlsPerObservation; | ||||
|  | ||||
|     public int ResolveCpesPerObservation() => CpesPerObservation.HasValue && CpesPerObservation.Value >= 0 | ||||
|         ? CpesPerObservation.Value | ||||
|         : DefaultCpesPerObservation; | ||||
|  | ||||
|     public int ResolveReferencesPerObservation() => ReferencesPerObservation.HasValue && ReferencesPerObservation.Value >= 0 | ||||
|         ? ReferencesPerObservation.Value | ||||
|         : DefaultReferencesPerObservation; | ||||
|  | ||||
|     public int ResolveTenantCount() => Tenants.HasValue && Tenants.Value > 0 | ||||
|         ? Tenants.Value | ||||
|         : DefaultTenants; | ||||
|  | ||||
|     public int ResolveBatchSize() => BatchSize.HasValue && BatchSize.Value > 0 | ||||
|         ? BatchSize.Value | ||||
|         : DefaultBatchSize; | ||||
|  | ||||
|     public int ResolveSeed() => Seed.HasValue && Seed.Value > 0 | ||||
|         ? Seed.Value | ||||
|         : DefaultSeed; | ||||
|  | ||||
|     public int ResolveIterations(int? defaultIterations) | ||||
|     { | ||||
|         var iterations = Iterations ?? defaultIterations ?? 3; | ||||
|         if (iterations <= 0) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Scenario '{ScenarioId}' requires iterations > 0."); | ||||
|         } | ||||
|  | ||||
|         return iterations; | ||||
|     } | ||||
|  | ||||
|     public void Validate() | ||||
|     { | ||||
|         if (ResolveObservationCount() <= 0) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Scenario '{ScenarioId}' requires observations > 0."); | ||||
|         } | ||||
|  | ||||
|         if (ResolveAliasGroups() <= 0) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Scenario '{ScenarioId}' requires aliasGroups > 0."); | ||||
|         } | ||||
|  | ||||
|         if (ResolvePurlsPerObservation() <= 0) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Scenario '{ScenarioId}' requires purlsPerObservation > 0."); | ||||
|         } | ||||
|  | ||||
|         if (ResolveTenantCount() <= 0) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Scenario '{ScenarioId}' requires tenants > 0."); | ||||
|         } | ||||
|  | ||||
|         if (ResolveBatchSize() > ResolveObservationCount()) | ||||
|         { | ||||
|             throw new InvalidOperationException($"Scenario '{ScenarioId}' batchSize cannot exceed observations."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,135 @@ | ||||
| using System.Diagnostics; | ||||
| using EphemeralMongo; | ||||
| using MongoDB.Bson; | ||||
| using MongoDB.Driver; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge; | ||||
|  | ||||
| internal sealed class LinkNotMergeScenarioRunner | ||||
| { | ||||
|     private readonly LinkNotMergeScenarioConfig _config; | ||||
|     private readonly IReadOnlyList<ObservationSeed> _seeds; | ||||
|  | ||||
|     public LinkNotMergeScenarioRunner(LinkNotMergeScenarioConfig config) | ||||
|     { | ||||
|         _config = config ?? throw new ArgumentNullException(nameof(config)); | ||||
|         _seeds = ObservationGenerator.Generate(config); | ||||
|     } | ||||
|  | ||||
|     public ScenarioExecutionResult Execute(int iterations, CancellationToken cancellationToken) | ||||
|     { | ||||
|         if (iterations <= 0) | ||||
|         { | ||||
|             throw new ArgumentOutOfRangeException(nameof(iterations), iterations, "Iterations must be positive."); | ||||
|         } | ||||
|  | ||||
|         var totalDurations = new double[iterations]; | ||||
|         var insertDurations = new double[iterations]; | ||||
|         var correlationDurations = new double[iterations]; | ||||
|         var allocated = new double[iterations]; | ||||
|         var totalThroughputs = new double[iterations]; | ||||
|         var insertThroughputs = new double[iterations]; | ||||
|         LinksetAggregationResult lastAggregation = new(0, 0, 0, 0, 0); | ||||
|  | ||||
|         for (var iteration = 0; iteration < iterations; iteration++) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             using var runner = MongoRunner.Run(new MongoRunnerOptions | ||||
|             { | ||||
|                 UseSingleNodeReplicaSet = false, | ||||
|             }); | ||||
|  | ||||
|             var client = new MongoClient(runner.ConnectionString); | ||||
|             var database = client.GetDatabase("linknotmerge_bench"); | ||||
|             var collection = database.GetCollection<BsonDocument>("advisory_observations"); | ||||
|  | ||||
|             CreateIndexes(collection, cancellationToken); | ||||
|  | ||||
|             var beforeAllocated = GC.GetTotalAllocatedBytes(); | ||||
|             var insertStopwatch = Stopwatch.StartNew(); | ||||
|             InsertObservations(collection, _seeds, _config.ResolveBatchSize(), cancellationToken); | ||||
|             insertStopwatch.Stop(); | ||||
|  | ||||
|             var correlationStopwatch = Stopwatch.StartNew(); | ||||
|             var documents = collection | ||||
|                 .Find(FilterDefinition<BsonDocument>.Empty) | ||||
|                 .Project(Builders<BsonDocument>.Projection | ||||
|                     .Include("tenant") | ||||
|                     .Include("linkset")) | ||||
|                 .ToList(cancellationToken); | ||||
|  | ||||
|             var correlator = new LinksetAggregator(); | ||||
|             lastAggregation = correlator.Correlate(documents); | ||||
|             correlationStopwatch.Stop(); | ||||
|  | ||||
|             var totalElapsed = insertStopwatch.Elapsed + correlationStopwatch.Elapsed; | ||||
|             var afterAllocated = GC.GetTotalAllocatedBytes(); | ||||
|  | ||||
|             totalDurations[iteration] = totalElapsed.TotalMilliseconds; | ||||
|             insertDurations[iteration] = insertStopwatch.Elapsed.TotalMilliseconds; | ||||
|             correlationDurations[iteration] = correlationStopwatch.Elapsed.TotalMilliseconds; | ||||
|             allocated[iteration] = Math.Max(0, afterAllocated - beforeAllocated) / (1024d * 1024d); | ||||
|  | ||||
|             var totalSeconds = Math.Max(totalElapsed.TotalSeconds, 0.0001d); | ||||
|             totalThroughputs[iteration] = _seeds.Count / totalSeconds; | ||||
|  | ||||
|             var insertSeconds = Math.Max(insertStopwatch.Elapsed.TotalSeconds, 0.0001d); | ||||
|             insertThroughputs[iteration] = _seeds.Count / insertSeconds; | ||||
|         } | ||||
|  | ||||
|         return new ScenarioExecutionResult( | ||||
|             totalDurations, | ||||
|             insertDurations, | ||||
|             correlationDurations, | ||||
|             allocated, | ||||
|             totalThroughputs, | ||||
|             insertThroughputs, | ||||
|             ObservationCount: _seeds.Count, | ||||
|             AliasGroups: _config.ResolveAliasGroups(), | ||||
|             LinksetCount: lastAggregation.LinksetCount, | ||||
|             TenantCount: _config.ResolveTenantCount(), | ||||
|             AggregationResult: lastAggregation); | ||||
|     } | ||||
|  | ||||
|     private static void InsertObservations( | ||||
|         IMongoCollection<BsonDocument> collection, | ||||
|         IReadOnlyList<ObservationSeed> seeds, | ||||
|         int batchSize, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         for (var offset = 0; offset < seeds.Count; offset += batchSize) | ||||
|         { | ||||
|             cancellationToken.ThrowIfCancellationRequested(); | ||||
|  | ||||
|             var remaining = Math.Min(batchSize, seeds.Count - offset); | ||||
|             var batch = new List<BsonDocument>(remaining); | ||||
|             for (var index = 0; index < remaining; index++) | ||||
|             { | ||||
|                 batch.Add(seeds[offset + index].ToBsonDocument()); | ||||
|             } | ||||
|  | ||||
|             collection.InsertMany(batch, new InsertManyOptions | ||||
|             { | ||||
|                 IsOrdered = false, | ||||
|                 BypassDocumentValidation = true, | ||||
|             }, cancellationToken); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static void CreateIndexes(IMongoCollection<BsonDocument> collection, CancellationToken cancellationToken) | ||||
|     { | ||||
|         var indexKeys = Builders<BsonDocument>.IndexKeys | ||||
|             .Ascending("tenant") | ||||
|             .Ascending("identifiers.aliases"); | ||||
|  | ||||
|         try | ||||
|         { | ||||
|             collection.Indexes.CreateOne(new CreateIndexModel<BsonDocument>(indexKeys), cancellationToken: cancellationToken); | ||||
|         } | ||||
|         catch | ||||
|         { | ||||
|             // Index creation failures should not abort the benchmark; they may occur when running multiple iterations concurrently. | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,140 @@ | ||||
| using MongoDB.Bson; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge; | ||||
|  | ||||
| internal sealed class LinksetAggregator | ||||
| { | ||||
|     public LinksetAggregationResult Correlate(IEnumerable<BsonDocument> documents) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(documents); | ||||
|  | ||||
|         var groups = new Dictionary<string, LinksetAccumulator>(StringComparer.Ordinal); | ||||
|         var totalObservations = 0; | ||||
|  | ||||
|         foreach (var document in documents) | ||||
|         { | ||||
|             totalObservations++; | ||||
|  | ||||
|             var tenant = document.GetValue("tenant", "unknown").AsString; | ||||
|             var linkset = document.GetValue("linkset", new BsonDocument()).AsBsonDocument; | ||||
|             var aliases = linkset.GetValue("aliases", new BsonArray()).AsBsonArray; | ||||
|             var purls = linkset.GetValue("purls", new BsonArray()).AsBsonArray; | ||||
|             var cpes = linkset.GetValue("cpes", new BsonArray()).AsBsonArray; | ||||
|             var references = linkset.GetValue("references", new BsonArray()).AsBsonArray; | ||||
|  | ||||
|             foreach (var aliasValue in aliases) | ||||
|             { | ||||
|                 if (!aliasValue.IsString) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 var alias = aliasValue.AsString; | ||||
|                 var key = string.Create(alias.Length + tenant.Length + 1, (tenant, alias), static (span, data) => | ||||
|                 { | ||||
|                     var (tenantValue, aliasValue) = data; | ||||
|                     tenantValue.AsSpan().CopyTo(span); | ||||
|                     span[tenantValue.Length] = '|'; | ||||
|                     aliasValue.AsSpan().CopyTo(span[(tenantValue.Length + 1)..]); | ||||
|                 }); | ||||
|  | ||||
|                 if (!groups.TryGetValue(key, out var accumulator)) | ||||
|                 { | ||||
|                     accumulator = new LinksetAccumulator(tenant, alias); | ||||
|                     groups[key] = accumulator; | ||||
|                 } | ||||
|  | ||||
|                 accumulator.AddPurls(purls); | ||||
|                 accumulator.AddCpes(cpes); | ||||
|                 accumulator.AddReferences(references); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         var totalReferences = 0; | ||||
|         var totalPurls = 0; | ||||
|         var totalCpes = 0; | ||||
|  | ||||
|         foreach (var accumulator in groups.Values) | ||||
|         { | ||||
|             totalReferences += accumulator.ReferenceCount; | ||||
|             totalPurls += accumulator.PurlCount; | ||||
|             totalCpes += accumulator.CpeCount; | ||||
|         } | ||||
|  | ||||
|         return new LinksetAggregationResult( | ||||
|             LinksetCount: groups.Count, | ||||
|             ObservationCount: totalObservations, | ||||
|             TotalPurls: totalPurls, | ||||
|             TotalCpes: totalCpes, | ||||
|             TotalReferences: totalReferences); | ||||
|     } | ||||
|  | ||||
|     private sealed class LinksetAccumulator | ||||
|     { | ||||
|         private readonly HashSet<string> _purls = new(StringComparer.Ordinal); | ||||
|         private readonly HashSet<string> _cpes = new(StringComparer.Ordinal); | ||||
|         private readonly HashSet<string> _references = new(StringComparer.Ordinal); | ||||
|  | ||||
|         public LinksetAccumulator(string tenant, string alias) | ||||
|         { | ||||
|             Tenant = tenant; | ||||
|             Alias = alias; | ||||
|         } | ||||
|  | ||||
|         public string Tenant { get; } | ||||
|  | ||||
|         public string Alias { get; } | ||||
|  | ||||
|         public int PurlCount => _purls.Count; | ||||
|  | ||||
|         public int CpeCount => _cpes.Count; | ||||
|  | ||||
|         public int ReferenceCount => _references.Count; | ||||
|  | ||||
|         public void AddPurls(BsonArray array) | ||||
|         { | ||||
|             foreach (var item in array) | ||||
|             { | ||||
|                 if (item.IsString) | ||||
|                 { | ||||
|                     _purls.Add(item.AsString); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public void AddCpes(BsonArray array) | ||||
|         { | ||||
|             foreach (var item in array) | ||||
|             { | ||||
|                 if (item.IsString) | ||||
|                 { | ||||
|                     _cpes.Add(item.AsString); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         public void AddReferences(BsonArray array) | ||||
|         { | ||||
|             foreach (var item in array) | ||||
|             { | ||||
|                 if (!item.IsBsonDocument) | ||||
|                 { | ||||
|                     continue; | ||||
|                 } | ||||
|  | ||||
|                 var document = item.AsBsonDocument; | ||||
|                 if (document.TryGetValue("url", out var urlValue) && urlValue.IsString) | ||||
|                 { | ||||
|                     _references.Add(urlValue.AsString); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed record LinksetAggregationResult( | ||||
|     int LinksetCount, | ||||
|     int ObservationCount, | ||||
|     int TotalPurls, | ||||
|     int TotalCpes, | ||||
|     int TotalReferences); | ||||
| @@ -0,0 +1,270 @@ | ||||
| using System.Collections.Immutable; | ||||
| using System.Security.Cryptography; | ||||
| using MongoDB.Bson; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge; | ||||
|  | ||||
| internal static class ObservationGenerator | ||||
| { | ||||
|     public static IReadOnlyList<ObservationSeed> Generate(LinkNotMergeScenarioConfig config) | ||||
|     { | ||||
|         ArgumentNullException.ThrowIfNull(config); | ||||
|  | ||||
|         var observationCount = config.ResolveObservationCount(); | ||||
|         var aliasGroups = config.ResolveAliasGroups(); | ||||
|         var purlsPerObservation = config.ResolvePurlsPerObservation(); | ||||
|         var cpesPerObservation = config.ResolveCpesPerObservation(); | ||||
|         var referencesPerObservation = config.ResolveReferencesPerObservation(); | ||||
|         var tenantCount = config.ResolveTenantCount(); | ||||
|         var seed = config.ResolveSeed(); | ||||
|  | ||||
|         var seeds = new ObservationSeed[observationCount]; | ||||
|         var random = new Random(seed); | ||||
|         var baseTime = new DateTimeOffset(2025, 10, 1, 0, 0, 0, TimeSpan.Zero); | ||||
|  | ||||
|         for (var index = 0; index < observationCount; index++) | ||||
|         { | ||||
|             var tenantIndex = index % tenantCount; | ||||
|             var tenant = $"tenant-{tenantIndex:D2}"; | ||||
|             var group = index % aliasGroups; | ||||
|             var revision = index / aliasGroups; | ||||
|             var primaryAlias = $"CVE-2025-{group:D4}"; | ||||
|             var vendorAlias = $"VENDOR-{group:D4}"; | ||||
|             var thirdAlias = $"GHSA-{group:D4}-{(revision % 26 + 'a')}{(revision % 26 + 'a')}"; | ||||
|             var aliases = ImmutableArray.Create(primaryAlias, vendorAlias, thirdAlias); | ||||
|  | ||||
|             var observationId = $"{tenant}:advisory:{group:D5}:{revision:D6}"; | ||||
|             var upstreamId = primaryAlias; | ||||
|             var documentVersion = baseTime.AddMinutes(revision).ToString("O"); | ||||
|             var fetchedAt = baseTime.AddSeconds(index % 1_800); | ||||
|             var receivedAt = fetchedAt.AddSeconds(1); | ||||
|  | ||||
|             var purls = CreatePurls(group, revision, purlsPerObservation); | ||||
|             var cpes = CreateCpes(group, revision, cpesPerObservation); | ||||
|             var references = CreateReferences(primaryAlias, referencesPerObservation); | ||||
|  | ||||
|             var rawPayload = CreateRawPayload(primaryAlias, vendorAlias, purls, cpes, references); | ||||
|             var contentHash = ComputeContentHash(rawPayload, tenant, group, revision); | ||||
|  | ||||
|             seeds[index] = new ObservationSeed( | ||||
|                 ObservationId: observationId, | ||||
|                 Tenant: tenant, | ||||
|                 Vendor: "concelier-bench", | ||||
|                 Stream: "simulated", | ||||
|                 Api: $"https://bench.stella/{group:D4}/{revision:D2}", | ||||
|                 CollectorVersion: "1.0.0-bench", | ||||
|                 UpstreamId: upstreamId, | ||||
|                 DocumentVersion: documentVersion, | ||||
|                 FetchedAt: fetchedAt, | ||||
|                 ReceivedAt: receivedAt, | ||||
|                 ContentHash: contentHash, | ||||
|                 Aliases: aliases, | ||||
|                 Purls: purls, | ||||
|                 Cpes: cpes, | ||||
|                 References: references, | ||||
|                 ContentFormat: "CSAF", | ||||
|                 SpecVersion: "2.0", | ||||
|                 RawPayload: rawPayload); | ||||
|         } | ||||
|  | ||||
|         return seeds; | ||||
|     } | ||||
|  | ||||
|     private static ImmutableArray<string> CreatePurls(int group, int revision, int count) | ||||
|     { | ||||
|         if (count <= 0) | ||||
|         { | ||||
|             return ImmutableArray<string>.Empty; | ||||
|         } | ||||
|  | ||||
|         var builder = ImmutableArray.CreateBuilder<string>(count); | ||||
|         for (var index = 0; index < count; index++) | ||||
|         { | ||||
|             var version = $"{revision % 9 + 1}.{index + 1}.{group % 10}"; | ||||
|             builder.Add($"pkg:generic/stella/sample-{group:D4}-{index}@{version}"); | ||||
|         } | ||||
|  | ||||
|         return builder.MoveToImmutable(); | ||||
|     } | ||||
|  | ||||
|     private static ImmutableArray<string> CreateCpes(int group, int revision, int count) | ||||
|     { | ||||
|         if (count <= 0) | ||||
|         { | ||||
|             return ImmutableArray<string>.Empty; | ||||
|         } | ||||
|  | ||||
|         var builder = ImmutableArray.CreateBuilder<string>(count); | ||||
|         for (var index = 0; index < count; index++) | ||||
|         { | ||||
|             var component = $"benchtool{group % 50:D2}"; | ||||
|             var version = $"{revision % 5}.{index}"; | ||||
|             builder.Add($"cpe:2.3:a:stellaops:{component}:{version}:*:*:*:*:*:*:*"); | ||||
|         } | ||||
|  | ||||
|         return builder.MoveToImmutable(); | ||||
|     } | ||||
|  | ||||
|     private static ImmutableArray<ObservationReference> CreateReferences(string primaryAlias, int count) | ||||
|     { | ||||
|         if (count <= 0) | ||||
|         { | ||||
|             return ImmutableArray<ObservationReference>.Empty; | ||||
|         } | ||||
|  | ||||
|         var builder = ImmutableArray.CreateBuilder<ObservationReference>(count); | ||||
|         for (var index = 0; index < count; index++) | ||||
|         { | ||||
|             builder.Add(new ObservationReference( | ||||
|                 Type: index % 2 == 0 ? "advisory" : "patch", | ||||
|                 Url: $"https://vendor.example/{primaryAlias.ToLowerInvariant()}/ref/{index:D2}")); | ||||
|         } | ||||
|  | ||||
|         return builder.MoveToImmutable(); | ||||
|     } | ||||
|  | ||||
|     private static BsonDocument CreateRawPayload( | ||||
|         string primaryAlias, | ||||
|         string vendorAlias, | ||||
|         IReadOnlyCollection<string> purls, | ||||
|         IReadOnlyCollection<string> cpes, | ||||
|         IReadOnlyCollection<ObservationReference> references) | ||||
|     { | ||||
|         var document = new BsonDocument | ||||
|         { | ||||
|             ["id"] = primaryAlias, | ||||
|             ["vendorId"] = vendorAlias, | ||||
|             ["title"] = $"Simulated advisory {primaryAlias}", | ||||
|             ["summary"] = "Synthetic payload produced by Link-Not-Merge benchmark.", | ||||
|             ["metrics"] = new BsonArray | ||||
|             { | ||||
|                 new BsonDocument | ||||
|                 { | ||||
|                     ["kind"] = "cvss:v3.1", | ||||
|                     ["score"] = 7.5, | ||||
|                     ["vector"] = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N", | ||||
|                 }, | ||||
|             }, | ||||
|         }; | ||||
|  | ||||
|         if (purls.Count > 0) | ||||
|         { | ||||
|             document["purls"] = new BsonArray(purls); | ||||
|         } | ||||
|  | ||||
|         if (cpes.Count > 0) | ||||
|         { | ||||
|             document["cpes"] = new BsonArray(cpes); | ||||
|         } | ||||
|  | ||||
|         if (references.Count > 0) | ||||
|         { | ||||
|             document["references"] = new BsonArray(references.Select(reference => new BsonDocument | ||||
|             { | ||||
|                 ["type"] = reference.Type, | ||||
|                 ["url"] = reference.Url, | ||||
|             })); | ||||
|         } | ||||
|  | ||||
|         return document; | ||||
|     } | ||||
|  | ||||
|     private static string ComputeContentHash(BsonDocument rawPayload, string tenant, int group, int revision) | ||||
|     { | ||||
|         using var sha256 = SHA256.Create(); | ||||
|         var seed = $"{tenant}|{group}|{revision}"; | ||||
|         var rawBytes = rawPayload.ToBson(); | ||||
|         var seedBytes = System.Text.Encoding.UTF8.GetBytes(seed); | ||||
|         var combined = new byte[rawBytes.Length + seedBytes.Length]; | ||||
|         Buffer.BlockCopy(rawBytes, 0, combined, 0, rawBytes.Length); | ||||
|         Buffer.BlockCopy(seedBytes, 0, combined, rawBytes.Length, seedBytes.Length); | ||||
|         var hash = sha256.ComputeHash(combined); | ||||
|         return $"sha256:{Convert.ToHexString(hash)}"; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed record ObservationSeed( | ||||
|     string ObservationId, | ||||
|     string Tenant, | ||||
|     string Vendor, | ||||
|     string Stream, | ||||
|     string Api, | ||||
|     string CollectorVersion, | ||||
|     string UpstreamId, | ||||
|     string DocumentVersion, | ||||
|     DateTimeOffset FetchedAt, | ||||
|     DateTimeOffset ReceivedAt, | ||||
|     string ContentHash, | ||||
|     ImmutableArray<string> Aliases, | ||||
|     ImmutableArray<string> Purls, | ||||
|     ImmutableArray<string> Cpes, | ||||
|     ImmutableArray<ObservationReference> References, | ||||
|     string ContentFormat, | ||||
|     string SpecVersion, | ||||
|     BsonDocument RawPayload) | ||||
| { | ||||
|     public BsonDocument ToBsonDocument() | ||||
|     { | ||||
|         var aliases = new BsonArray(Aliases.Select(alias => alias)); | ||||
|         var purls = new BsonArray(Purls.Select(purl => purl)); | ||||
|         var cpes = new BsonArray(Cpes.Select(cpe => cpe)); | ||||
|         var references = new BsonArray(References.Select(reference => new BsonDocument | ||||
|         { | ||||
|             ["type"] = reference.Type, | ||||
|             ["url"] = reference.Url, | ||||
|         })); | ||||
|  | ||||
|         var document = new BsonDocument | ||||
|         { | ||||
|             ["_id"] = ObservationId, | ||||
|             ["tenant"] = Tenant, | ||||
|             ["source"] = new BsonDocument | ||||
|             { | ||||
|                 ["vendor"] = Vendor, | ||||
|                 ["stream"] = Stream, | ||||
|                 ["api"] = Api, | ||||
|                 ["collector_version"] = CollectorVersion, | ||||
|             }, | ||||
|             ["upstream"] = new BsonDocument | ||||
|             { | ||||
|                 ["upstream_id"] = UpstreamId, | ||||
|                 ["document_version"] = DocumentVersion, | ||||
|                 ["fetched_at"] = FetchedAt.UtcDateTime, | ||||
|                 ["received_at"] = ReceivedAt.UtcDateTime, | ||||
|                 ["content_hash"] = ContentHash, | ||||
|                 ["signature"] = new BsonDocument | ||||
|                 { | ||||
|                     ["present"] = false, | ||||
|                     ["format"] = BsonNull.Value, | ||||
|                     ["key_id"] = BsonNull.Value, | ||||
|                     ["signature"] = BsonNull.Value, | ||||
|                 }, | ||||
|             }, | ||||
|             ["content"] = new BsonDocument | ||||
|             { | ||||
|                 ["format"] = ContentFormat, | ||||
|                 ["spec_version"] = SpecVersion, | ||||
|                 ["raw"] = RawPayload, | ||||
|             }, | ||||
|             ["identifiers"] = new BsonDocument | ||||
|             { | ||||
|                 ["aliases"] = aliases, | ||||
|                 ["primary"] = UpstreamId, | ||||
|                 ["cve"] = Aliases.FirstOrDefault(alias => alias.StartsWith("CVE-", StringComparison.Ordinal)) ?? UpstreamId, | ||||
|             }, | ||||
|             ["linkset"] = new BsonDocument | ||||
|             { | ||||
|                 ["aliases"] = aliases, | ||||
|                 ["purls"] = purls, | ||||
|                 ["cpes"] = cpes, | ||||
|                 ["references"] = references, | ||||
|                 ["reconciled_from"] = new BsonArray { "/content/product_tree" }, | ||||
|             }, | ||||
|             ["supersedes"] = BsonNull.Value, | ||||
|         }; | ||||
|  | ||||
|         return document; | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal sealed record ObservationReference(string Type, string Url); | ||||
| @@ -0,0 +1,375 @@ | ||||
| using System.Globalization; | ||||
| using StellaOps.Bench.LinkNotMerge.Baseline; | ||||
| using StellaOps.Bench.LinkNotMerge.Reporting; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge; | ||||
|  | ||||
| internal static class Program | ||||
| { | ||||
|     public static async Task<int> Main(string[] args) | ||||
|     { | ||||
|         try | ||||
|         { | ||||
|             var options = ProgramOptions.Parse(args); | ||||
|             var config = await BenchmarkConfig.LoadAsync(options.ConfigPath).ConfigureAwait(false); | ||||
|             var baseline = await BaselineLoader.LoadAsync(options.BaselinePath, CancellationToken.None).ConfigureAwait(false); | ||||
|  | ||||
|             var results = new List<ScenarioResult>(); | ||||
|             var reports = new List<BenchmarkScenarioReport>(); | ||||
|             var failures = new List<string>(); | ||||
|  | ||||
|             foreach (var scenario in config.Scenarios) | ||||
|             { | ||||
|                 var iterations = scenario.ResolveIterations(config.Iterations); | ||||
|                 var runner = new LinkNotMergeScenarioRunner(scenario); | ||||
|                 var execution = runner.Execute(iterations, CancellationToken.None); | ||||
|  | ||||
|                 var totalStats = DurationStatistics.From(execution.TotalDurationsMs); | ||||
|                 var insertStats = DurationStatistics.From(execution.InsertDurationsMs); | ||||
|                 var correlationStats = DurationStatistics.From(execution.CorrelationDurationsMs); | ||||
|                 var allocationStats = AllocationStatistics.From(execution.AllocatedMb); | ||||
|                 var throughputStats = ThroughputStatistics.From(execution.TotalThroughputsPerSecond); | ||||
|                 var mongoThroughputStats = ThroughputStatistics.From(execution.InsertThroughputsPerSecond); | ||||
|  | ||||
|                 var thresholdMs = scenario.ThresholdMs ?? options.ThresholdMs ?? config.ThresholdMs; | ||||
|                 var throughputFloor = scenario.MinThroughputPerSecond ?? options.MinThroughputPerSecond ?? config.MinThroughputPerSecond; | ||||
|                 var mongoThroughputFloor = scenario.MinMongoThroughputPerSecond ?? options.MinMongoThroughputPerSecond ?? config.MinMongoThroughputPerSecond; | ||||
|                 var allocationLimit = scenario.MaxAllocatedMb ?? options.MaxAllocatedMb ?? config.MaxAllocatedMb; | ||||
|  | ||||
|                 var result = new ScenarioResult( | ||||
|                     scenario.ScenarioId, | ||||
|                     scenario.DisplayLabel, | ||||
|                     iterations, | ||||
|                     execution.ObservationCount, | ||||
|                     execution.AliasGroups, | ||||
|                     execution.LinksetCount, | ||||
|                     totalStats, | ||||
|                     insertStats, | ||||
|                     correlationStats, | ||||
|                     throughputStats, | ||||
|                     mongoThroughputStats, | ||||
|                     allocationStats, | ||||
|                     thresholdMs, | ||||
|                     throughputFloor, | ||||
|                     mongoThroughputFloor, | ||||
|                     allocationLimit); | ||||
|  | ||||
|                 results.Add(result); | ||||
|  | ||||
|                 if (thresholdMs is { } threshold && result.TotalStatistics.MaxMs > threshold) | ||||
|                 { | ||||
|                     failures.Add($"{result.Id} exceeded total latency threshold: {result.TotalStatistics.MaxMs:F2} ms > {threshold:F2} ms"); | ||||
|                 } | ||||
|  | ||||
|                 if (throughputFloor is { } floor && result.TotalThroughputStatistics.MinPerSecond < floor) | ||||
|                 { | ||||
|                     failures.Add($"{result.Id} fell below throughput floor: {result.TotalThroughputStatistics.MinPerSecond:N0} obs/s < {floor:N0} obs/s"); | ||||
|                 } | ||||
|  | ||||
|                 if (mongoThroughputFloor is { } mongoFloor && result.InsertThroughputStatistics.MinPerSecond < mongoFloor) | ||||
|                 { | ||||
|                     failures.Add($"{result.Id} fell below Mongo throughput floor: {result.InsertThroughputStatistics.MinPerSecond:N0} ops/s < {mongoFloor:N0} ops/s"); | ||||
|                 } | ||||
|  | ||||
|                 if (allocationLimit is { } limit && result.AllocationStatistics.MaxAllocatedMb > limit) | ||||
|                 { | ||||
|                     failures.Add($"{result.Id} exceeded allocation budget: {result.AllocationStatistics.MaxAllocatedMb:F2} MB > {limit:F2} MB"); | ||||
|                 } | ||||
|  | ||||
|                 baseline.TryGetValue(result.Id, out var baselineEntry); | ||||
|                 var report = new BenchmarkScenarioReport(result, baselineEntry, options.RegressionLimit); | ||||
|                 reports.Add(report); | ||||
|                 failures.AddRange(report.BuildRegressionFailureMessages()); | ||||
|             } | ||||
|  | ||||
|             TablePrinter.Print(results); | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(options.CsvOutPath)) | ||||
|             { | ||||
|                 CsvWriter.Write(options.CsvOutPath!, results); | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(options.JsonOutPath)) | ||||
|             { | ||||
|                 var metadata = new BenchmarkJsonMetadata( | ||||
|                     SchemaVersion: "linknotmerge-bench/1.0", | ||||
|                     CapturedAtUtc: (options.CapturedAtUtc ?? DateTimeOffset.UtcNow).ToUniversalTime(), | ||||
|                     Commit: options.Commit, | ||||
|                     Environment: options.Environment); | ||||
|  | ||||
|                 await BenchmarkJsonWriter.WriteAsync(options.JsonOutPath!, metadata, reports, CancellationToken.None).ConfigureAwait(false); | ||||
|             } | ||||
|  | ||||
|             if (!string.IsNullOrWhiteSpace(options.PrometheusOutPath)) | ||||
|             { | ||||
|                 PrometheusWriter.Write(options.PrometheusOutPath!, reports); | ||||
|             } | ||||
|  | ||||
|             if (failures.Count > 0) | ||||
|             { | ||||
|                 Console.Error.WriteLine(); | ||||
|                 Console.Error.WriteLine("Benchmark failures detected:"); | ||||
|                 foreach (var failure in failures.Distinct()) | ||||
|                 { | ||||
|                     Console.Error.WriteLine($" - {failure}"); | ||||
|                 } | ||||
|  | ||||
|                 return 1; | ||||
|             } | ||||
|  | ||||
|             return 0; | ||||
|         } | ||||
|         catch (Exception ex) | ||||
|         { | ||||
|             Console.Error.WriteLine($"linknotmerge-bench error: {ex.Message}"); | ||||
|             return 1; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private sealed record ProgramOptions( | ||||
|         string ConfigPath, | ||||
|         int? Iterations, | ||||
|         double? ThresholdMs, | ||||
|         double? MinThroughputPerSecond, | ||||
|         double? MinMongoThroughputPerSecond, | ||||
|         double? MaxAllocatedMb, | ||||
|         string? CsvOutPath, | ||||
|         string? JsonOutPath, | ||||
|         string? PrometheusOutPath, | ||||
|         string BaselinePath, | ||||
|         DateTimeOffset? CapturedAtUtc, | ||||
|         string? Commit, | ||||
|         string? Environment, | ||||
|         double? RegressionLimit) | ||||
|     { | ||||
|         public static ProgramOptions Parse(string[] args) | ||||
|         { | ||||
|             var configPath = DefaultConfigPath(); | ||||
|             var baselinePath = DefaultBaselinePath(); | ||||
|  | ||||
|             int? iterations = null; | ||||
|             double? thresholdMs = null; | ||||
|             double? minThroughput = null; | ||||
|             double? minMongoThroughput = null; | ||||
|             double? maxAllocated = null; | ||||
|             string? csvOut = null; | ||||
|             string? jsonOut = null; | ||||
|             string? promOut = null; | ||||
|             DateTimeOffset? capturedAt = null; | ||||
|             string? commit = null; | ||||
|             string? environment = null; | ||||
|             double? regressionLimit = null; | ||||
|  | ||||
|             for (var index = 0; index < args.Length; index++) | ||||
|             { | ||||
|                 var current = args[index]; | ||||
|                 switch (current) | ||||
|                 { | ||||
|                     case "--config": | ||||
|                         EnsureNext(args, index); | ||||
|                         configPath = Path.GetFullPath(args[++index]); | ||||
|                         break; | ||||
|                     case "--iterations": | ||||
|                         EnsureNext(args, index); | ||||
|                         iterations = int.Parse(args[++index], CultureInfo.InvariantCulture); | ||||
|                         break; | ||||
|                     case "--threshold-ms": | ||||
|                         EnsureNext(args, index); | ||||
|                         thresholdMs = double.Parse(args[++index], CultureInfo.InvariantCulture); | ||||
|                         break; | ||||
|                     case "--min-throughput": | ||||
|                         EnsureNext(args, index); | ||||
|                         minThroughput = double.Parse(args[++index], CultureInfo.InvariantCulture); | ||||
|                         break; | ||||
|                     case "--min-mongo-throughput": | ||||
|                         EnsureNext(args, index); | ||||
|                         minMongoThroughput = double.Parse(args[++index], CultureInfo.InvariantCulture); | ||||
|                         break; | ||||
|                     case "--max-allocated-mb": | ||||
|                         EnsureNext(args, index); | ||||
|                         maxAllocated = double.Parse(args[++index], CultureInfo.InvariantCulture); | ||||
|                         break; | ||||
|                     case "--csv": | ||||
|                         EnsureNext(args, index); | ||||
|                         csvOut = args[++index]; | ||||
|                         break; | ||||
|                     case "--json": | ||||
|                         EnsureNext(args, index); | ||||
|                         jsonOut = args[++index]; | ||||
|                         break; | ||||
|                     case "--prometheus": | ||||
|                         EnsureNext(args, index); | ||||
|                         promOut = args[++index]; | ||||
|                         break; | ||||
|                     case "--baseline": | ||||
|                         EnsureNext(args, index); | ||||
|                         baselinePath = Path.GetFullPath(args[++index]); | ||||
|                         break; | ||||
|                     case "--captured-at": | ||||
|                         EnsureNext(args, index); | ||||
|                         capturedAt = DateTimeOffset.Parse(args[++index], CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal); | ||||
|                         break; | ||||
|                     case "--commit": | ||||
|                         EnsureNext(args, index); | ||||
|                         commit = args[++index]; | ||||
|                         break; | ||||
|                     case "--environment": | ||||
|                         EnsureNext(args, index); | ||||
|                         environment = args[++index]; | ||||
|                         break; | ||||
|                     case "--regression-limit": | ||||
|                         EnsureNext(args, index); | ||||
|                         regressionLimit = double.Parse(args[++index], CultureInfo.InvariantCulture); | ||||
|                         break; | ||||
|                     case "--help": | ||||
|                     case "-h": | ||||
|                         PrintUsage(); | ||||
|                         System.Environment.Exit(0); | ||||
|                         break; | ||||
|                     default: | ||||
|                         throw new ArgumentException($"Unknown argument '{current}'."); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             return new ProgramOptions( | ||||
|                 configPath, | ||||
|                 iterations, | ||||
|                 thresholdMs, | ||||
|                 minThroughput, | ||||
|                 minMongoThroughput, | ||||
|                 maxAllocated, | ||||
|                 csvOut, | ||||
|                 jsonOut, | ||||
|                 promOut, | ||||
|                 baselinePath, | ||||
|                 capturedAt, | ||||
|                 commit, | ||||
|                 environment, | ||||
|                 regressionLimit); | ||||
|         } | ||||
|  | ||||
|         private static string DefaultConfigPath() | ||||
|         { | ||||
|             var binaryDir = AppContext.BaseDirectory; | ||||
|             var projectDir = Path.GetFullPath(Path.Combine(binaryDir, "..", "..", "..")); | ||||
|             var benchRoot = Path.GetFullPath(Path.Combine(projectDir, "..")); | ||||
|             return Path.Combine(benchRoot, "config.json"); | ||||
|         } | ||||
|  | ||||
|         private static string DefaultBaselinePath() | ||||
|         { | ||||
|             var binaryDir = AppContext.BaseDirectory; | ||||
|             var projectDir = Path.GetFullPath(Path.Combine(binaryDir, "..", "..", "..")); | ||||
|             var benchRoot = Path.GetFullPath(Path.Combine(projectDir, "..")); | ||||
|             return Path.Combine(benchRoot, "baseline.csv"); | ||||
|         } | ||||
|  | ||||
|         private static void EnsureNext(string[] args, int index) | ||||
|         { | ||||
|             if (index + 1 >= args.Length) | ||||
|             { | ||||
|                 throw new ArgumentException("Missing value for argument."); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         private static void PrintUsage() | ||||
|         { | ||||
|             Console.WriteLine("Usage: linknotmerge-bench [options]"); | ||||
|             Console.WriteLine(); | ||||
|             Console.WriteLine("Options:"); | ||||
|             Console.WriteLine("  --config <path>                 Path to benchmark configuration JSON."); | ||||
|             Console.WriteLine("  --iterations <count>            Override iteration count."); | ||||
|             Console.WriteLine("  --threshold-ms <value>          Global latency threshold in milliseconds."); | ||||
|             Console.WriteLine("  --min-throughput <value>        Global throughput floor (observations/second)."); | ||||
|             Console.WriteLine("  --min-mongo-throughput <value>  Mongo insert throughput floor (ops/second)."); | ||||
|             Console.WriteLine("  --max-allocated-mb <value>      Global allocation ceiling (MB)."); | ||||
|             Console.WriteLine("  --csv <path>                    Write CSV results to path."); | ||||
|             Console.WriteLine("  --json <path>                   Write JSON results to path."); | ||||
|             Console.WriteLine("  --prometheus <path>             Write Prometheus exposition metrics to path."); | ||||
|             Console.WriteLine("  --baseline <path>               Baseline CSV path."); | ||||
|             Console.WriteLine("  --captured-at <iso8601>         Timestamp to embed in JSON metadata."); | ||||
|             Console.WriteLine("  --commit <sha>                  Commit identifier for metadata."); | ||||
|             Console.WriteLine("  --environment <name>            Environment label for metadata."); | ||||
|             Console.WriteLine("  --regression-limit <value>      Regression multiplier (default 1.15)."); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal static class TablePrinter | ||||
| { | ||||
|     public static void Print(IEnumerable<ScenarioResult> results) | ||||
|     { | ||||
|         Console.WriteLine("Scenario                     |   Observations | Aliases | Linksets |  Total(ms) | Correl(ms) |  Insert(ms) |  Min k/s | Mongo k/s | Alloc(MB)"); | ||||
|         Console.WriteLine("---------------------------- | ------------- | ------- | -------- | ---------- | ---------- | ----------- | -------- | --------- | --------"); | ||||
|         foreach (var row in results) | ||||
|         { | ||||
|             Console.WriteLine(string.Join(" | ", new[] | ||||
|             { | ||||
|                 row.IdColumn, | ||||
|                 row.ObservationsColumn, | ||||
|                 row.AliasColumn, | ||||
|                 row.LinksetColumn, | ||||
|                 row.TotalMeanColumn, | ||||
|                 row.CorrelationMeanColumn, | ||||
|                 row.InsertMeanColumn, | ||||
|                 row.ThroughputColumn, | ||||
|                 row.MongoThroughputColumn, | ||||
|                 row.AllocatedColumn, | ||||
|             })); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal static class CsvWriter | ||||
| { | ||||
|     public static void Write(string path, IEnumerable<ScenarioResult> results) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(path); | ||||
|         ArgumentNullException.ThrowIfNull(results); | ||||
|  | ||||
|         var resolved = Path.GetFullPath(path); | ||||
|         var directory = Path.GetDirectoryName(resolved); | ||||
|         if (!string.IsNullOrEmpty(directory)) | ||||
|         { | ||||
|             Directory.CreateDirectory(directory); | ||||
|         } | ||||
|  | ||||
|         using var stream = new FileStream(resolved, FileMode.Create, FileAccess.Write, FileShare.None); | ||||
|         using var writer = new StreamWriter(stream); | ||||
|         writer.WriteLine("scenario,iterations,observations,aliases,linksets,mean_total_ms,p95_total_ms,max_total_ms,mean_insert_ms,mean_correlation_ms,mean_throughput_per_sec,min_throughput_per_sec,mean_mongo_throughput_per_sec,min_mongo_throughput_per_sec,max_allocated_mb"); | ||||
|  | ||||
|         foreach (var result in results) | ||||
|         { | ||||
|             writer.Write(result.Id); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.Iterations.ToString(CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.ObservationCount.ToString(CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.AliasGroups.ToString(CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.LinksetCount.ToString(CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.TotalStatistics.MeanMs.ToString("F4", CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.TotalStatistics.P95Ms.ToString("F4", CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.TotalStatistics.MaxMs.ToString("F4", CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.InsertStatistics.MeanMs.ToString("F4", CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.CorrelationStatistics.MeanMs.ToString("F4", CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.TotalThroughputStatistics.MeanPerSecond.ToString("F4", CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.TotalThroughputStatistics.MinPerSecond.ToString("F4", CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.InsertThroughputStatistics.MeanPerSecond.ToString("F4", CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.InsertThroughputStatistics.MinPerSecond.ToString("F4", CultureInfo.InvariantCulture)); | ||||
|             writer.Write(','); | ||||
|             writer.Write(result.AllocationStatistics.MaxAllocatedMb.ToString("F4", CultureInfo.InvariantCulture)); | ||||
|             writer.WriteLine(); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,3 @@ | ||||
| using System.Runtime.CompilerServices; | ||||
|  | ||||
| [assembly: InternalsVisibleTo("StellaOps.Bench.LinkNotMerge.Tests")] | ||||
| @@ -0,0 +1,151 @@ | ||||
| using System.Text.Json; | ||||
| using System.Text.Json.Serialization; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge.Reporting; | ||||
|  | ||||
| internal static class BenchmarkJsonWriter | ||||
| { | ||||
|     private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) | ||||
|     { | ||||
|         WriteIndented = true, | ||||
|         DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, | ||||
|     }; | ||||
|  | ||||
|     public static async Task WriteAsync( | ||||
|         string path, | ||||
|         BenchmarkJsonMetadata metadata, | ||||
|         IReadOnlyList<BenchmarkScenarioReport> reports, | ||||
|         CancellationToken cancellationToken) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(path); | ||||
|         ArgumentNullException.ThrowIfNull(metadata); | ||||
|         ArgumentNullException.ThrowIfNull(reports); | ||||
|  | ||||
|         var resolved = Path.GetFullPath(path); | ||||
|         var directory = Path.GetDirectoryName(resolved); | ||||
|         if (!string.IsNullOrEmpty(directory)) | ||||
|         { | ||||
|             Directory.CreateDirectory(directory); | ||||
|         } | ||||
|  | ||||
|         var document = new BenchmarkJsonDocument( | ||||
|             metadata.SchemaVersion, | ||||
|             metadata.CapturedAtUtc, | ||||
|             metadata.Commit, | ||||
|             metadata.Environment, | ||||
|             reports.Select(CreateScenario).ToArray()); | ||||
|  | ||||
|         await using var stream = new FileStream(resolved, FileMode.Create, FileAccess.Write, FileShare.None); | ||||
|         await JsonSerializer.SerializeAsync(stream, document, SerializerOptions, cancellationToken).ConfigureAwait(false); | ||||
|         await stream.FlushAsync(cancellationToken).ConfigureAwait(false); | ||||
|     } | ||||
|  | ||||
|     private static BenchmarkJsonScenario CreateScenario(BenchmarkScenarioReport report) | ||||
|     { | ||||
|         var baseline = report.Baseline; | ||||
|         return new BenchmarkJsonScenario( | ||||
|             report.Result.Id, | ||||
|             report.Result.Label, | ||||
|             report.Result.Iterations, | ||||
|             report.Result.ObservationCount, | ||||
|             report.Result.AliasGroups, | ||||
|             report.Result.LinksetCount, | ||||
|             report.Result.TotalStatistics.MeanMs, | ||||
|             report.Result.TotalStatistics.P95Ms, | ||||
|             report.Result.TotalStatistics.MaxMs, | ||||
|             report.Result.InsertStatistics.MeanMs, | ||||
|             report.Result.CorrelationStatistics.MeanMs, | ||||
|             report.Result.TotalThroughputStatistics.MeanPerSecond, | ||||
|             report.Result.TotalThroughputStatistics.MinPerSecond, | ||||
|             report.Result.InsertThroughputStatistics.MeanPerSecond, | ||||
|             report.Result.InsertThroughputStatistics.MinPerSecond, | ||||
|             report.Result.AllocationStatistics.MaxAllocatedMb, | ||||
|             report.Result.ThresholdMs, | ||||
|             report.Result.MinThroughputThresholdPerSecond, | ||||
|             report.Result.MinMongoThroughputThresholdPerSecond, | ||||
|             report.Result.MaxAllocatedThresholdMb, | ||||
|             baseline is null | ||||
|                 ? null | ||||
|                 : new BenchmarkJsonScenarioBaseline( | ||||
|                     baseline.Iterations, | ||||
|                     baseline.Observations, | ||||
|                     baseline.Aliases, | ||||
|                     baseline.Linksets, | ||||
|                     baseline.MeanTotalMs, | ||||
|                     baseline.P95TotalMs, | ||||
|                     baseline.MaxTotalMs, | ||||
|                     baseline.MeanInsertMs, | ||||
|                     baseline.MeanCorrelationMs, | ||||
|                     baseline.MeanThroughputPerSecond, | ||||
|                     baseline.MinThroughputPerSecond, | ||||
|                     baseline.MeanMongoThroughputPerSecond, | ||||
|                     baseline.MinMongoThroughputPerSecond, | ||||
|                     baseline.MaxAllocatedMb), | ||||
|             new BenchmarkJsonScenarioRegression( | ||||
|                 report.DurationRegressionRatio, | ||||
|                 report.ThroughputRegressionRatio, | ||||
|                 report.MongoThroughputRegressionRatio, | ||||
|                 report.RegressionLimit, | ||||
|                 report.RegressionBreached)); | ||||
|     } | ||||
|  | ||||
|     private sealed record BenchmarkJsonDocument( | ||||
|         string SchemaVersion, | ||||
|         DateTimeOffset CapturedAt, | ||||
|         string? Commit, | ||||
|         string? Environment, | ||||
|         IReadOnlyList<BenchmarkJsonScenario> Scenarios); | ||||
|  | ||||
|     private sealed record BenchmarkJsonScenario( | ||||
|         string Id, | ||||
|         string Label, | ||||
|         int Iterations, | ||||
|         int Observations, | ||||
|         int Aliases, | ||||
|         int Linksets, | ||||
|         double MeanTotalMs, | ||||
|         double P95TotalMs, | ||||
|         double MaxTotalMs, | ||||
|         double MeanInsertMs, | ||||
|         double MeanCorrelationMs, | ||||
|         double MeanThroughputPerSecond, | ||||
|         double MinThroughputPerSecond, | ||||
|         double MeanMongoThroughputPerSecond, | ||||
|         double MinMongoThroughputPerSecond, | ||||
|         double MaxAllocatedMb, | ||||
|         double? ThresholdMs, | ||||
|         double? MinThroughputThresholdPerSecond, | ||||
|         double? MinMongoThroughputThresholdPerSecond, | ||||
|         double? MaxAllocatedThresholdMb, | ||||
|         BenchmarkJsonScenarioBaseline? Baseline, | ||||
|         BenchmarkJsonScenarioRegression Regression); | ||||
|  | ||||
|     private sealed record BenchmarkJsonScenarioBaseline( | ||||
|         int Iterations, | ||||
|         int Observations, | ||||
|         int Aliases, | ||||
|         int Linksets, | ||||
|         double MeanTotalMs, | ||||
|         double P95TotalMs, | ||||
|         double MaxTotalMs, | ||||
|         double MeanInsertMs, | ||||
|         double MeanCorrelationMs, | ||||
|         double MeanThroughputPerSecond, | ||||
|         double MinThroughputPerSecond, | ||||
|         double MeanMongoThroughputPerSecond, | ||||
|         double MinMongoThroughputPerSecond, | ||||
|         double MaxAllocatedMb); | ||||
|  | ||||
|     private sealed record BenchmarkJsonScenarioRegression( | ||||
|         double? DurationRatio, | ||||
|         double? ThroughputRatio, | ||||
|         double? MongoThroughputRatio, | ||||
|         double Limit, | ||||
|         bool Breached); | ||||
| } | ||||
|  | ||||
| internal sealed record BenchmarkJsonMetadata( | ||||
|     string SchemaVersion, | ||||
|     DateTimeOffset CapturedAtUtc, | ||||
|     string? Commit, | ||||
|     string? Environment); | ||||
| @@ -0,0 +1,89 @@ | ||||
| using StellaOps.Bench.LinkNotMerge.Baseline; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge.Reporting; | ||||
|  | ||||
| internal sealed class BenchmarkScenarioReport | ||||
| { | ||||
|     private const double DefaultRegressionLimit = 1.15d; | ||||
|  | ||||
|     public BenchmarkScenarioReport(ScenarioResult result, BaselineEntry? baseline, double? regressionLimit = null) | ||||
|     { | ||||
|         Result = result ?? throw new ArgumentNullException(nameof(result)); | ||||
|         Baseline = baseline; | ||||
|         RegressionLimit = regressionLimit is { } limit && limit > 0 ? limit : DefaultRegressionLimit; | ||||
|         DurationRegressionRatio = CalculateRatio(result.TotalStatistics.MaxMs, baseline?.MaxTotalMs); | ||||
|         ThroughputRegressionRatio = CalculateInverseRatio(result.TotalThroughputStatistics.MinPerSecond, baseline?.MinThroughputPerSecond); | ||||
|         MongoThroughputRegressionRatio = CalculateInverseRatio(result.InsertThroughputStatistics.MinPerSecond, baseline?.MinMongoThroughputPerSecond); | ||||
|     } | ||||
|  | ||||
|     public ScenarioResult Result { get; } | ||||
|  | ||||
|     public BaselineEntry? Baseline { get; } | ||||
|  | ||||
|     public double RegressionLimit { get; } | ||||
|  | ||||
|     public double? DurationRegressionRatio { get; } | ||||
|  | ||||
|     public double? ThroughputRegressionRatio { get; } | ||||
|  | ||||
|     public double? MongoThroughputRegressionRatio { get; } | ||||
|  | ||||
|     public bool DurationRegressionBreached => DurationRegressionRatio is { } ratio && ratio >= RegressionLimit; | ||||
|  | ||||
|     public bool ThroughputRegressionBreached => ThroughputRegressionRatio is { } ratio && ratio >= RegressionLimit; | ||||
|  | ||||
|     public bool MongoThroughputRegressionBreached => MongoThroughputRegressionRatio is { } ratio && ratio >= RegressionLimit; | ||||
|  | ||||
|     public bool RegressionBreached => DurationRegressionBreached || ThroughputRegressionBreached || MongoThroughputRegressionBreached; | ||||
|  | ||||
|     public IEnumerable<string> BuildRegressionFailureMessages() | ||||
|     { | ||||
|         if (Baseline is null) | ||||
|         { | ||||
|             yield break; | ||||
|         } | ||||
|  | ||||
|         if (DurationRegressionBreached && DurationRegressionRatio is { } durationRatio) | ||||
|         { | ||||
|             var delta = (durationRatio - 1d) * 100d; | ||||
|             yield return $"{Result.Id} exceeded max duration budget: {Result.TotalStatistics.MaxMs:F2} ms vs baseline {Baseline.MaxTotalMs:F2} ms (+{delta:F1}%)."; | ||||
|         } | ||||
|  | ||||
|         if (ThroughputRegressionBreached && ThroughputRegressionRatio is { } throughputRatio) | ||||
|         { | ||||
|             var delta = (throughputRatio - 1d) * 100d; | ||||
|             yield return $"{Result.Id} throughput regressed: min {Result.TotalThroughputStatistics.MinPerSecond:N0} obs/s vs baseline {Baseline.MinThroughputPerSecond:N0} obs/s (-{delta:F1}%)."; | ||||
|         } | ||||
|  | ||||
|         if (MongoThroughputRegressionBreached && MongoThroughputRegressionRatio is { } mongoRatio) | ||||
|         { | ||||
|             var delta = (mongoRatio - 1d) * 100d; | ||||
|             yield return $"{Result.Id} Mongo throughput regressed: min {Result.InsertThroughputStatistics.MinPerSecond:N0} ops/s vs baseline {Baseline.MinMongoThroughputPerSecond:N0} ops/s (-{delta:F1}%)."; | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     private static double? CalculateRatio(double current, double? baseline) | ||||
|     { | ||||
|         if (!baseline.HasValue || baseline.Value <= 0d) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         return current / baseline.Value; | ||||
|     } | ||||
|  | ||||
|     private static double? CalculateInverseRatio(double current, double? baseline) | ||||
|     { | ||||
|         if (!baseline.HasValue || baseline.Value <= 0d) | ||||
|         { | ||||
|             return null; | ||||
|         } | ||||
|  | ||||
|         if (current <= 0d) | ||||
|         { | ||||
|             return double.PositiveInfinity; | ||||
|         } | ||||
|  | ||||
|         return baseline.Value / current; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,101 @@ | ||||
| using System.Globalization; | ||||
| using System.Text; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge.Reporting; | ||||
|  | ||||
| internal static class PrometheusWriter | ||||
| { | ||||
|     public static void Write(string path, IReadOnlyList<BenchmarkScenarioReport> reports) | ||||
|     { | ||||
|         ArgumentException.ThrowIfNullOrWhiteSpace(path); | ||||
|         ArgumentNullException.ThrowIfNull(reports); | ||||
|  | ||||
|         var resolved = Path.GetFullPath(path); | ||||
|         var directory = Path.GetDirectoryName(resolved); | ||||
|         if (!string.IsNullOrEmpty(directory)) | ||||
|         { | ||||
|             Directory.CreateDirectory(directory); | ||||
|         } | ||||
|  | ||||
|         var builder = new StringBuilder(); | ||||
|         builder.AppendLine("# HELP linknotmerge_bench_total_ms Link-Not-Merge benchmark total duration metrics (milliseconds)."); | ||||
|         builder.AppendLine("# TYPE linknotmerge_bench_total_ms gauge"); | ||||
|         builder.AppendLine("# HELP linknotmerge_bench_correlation_ms Link-Not-Merge benchmark correlation duration metrics (milliseconds)."); | ||||
|         builder.AppendLine("# TYPE linknotmerge_bench_correlation_ms gauge"); | ||||
|         builder.AppendLine("# HELP linknotmerge_bench_insert_ms Link-Not-Merge benchmark Mongo insert duration metrics (milliseconds)."); | ||||
|         builder.AppendLine("# TYPE linknotmerge_bench_insert_ms gauge"); | ||||
|         builder.AppendLine("# HELP linknotmerge_bench_throughput_per_sec Link-Not-Merge benchmark throughput metrics (observations per second)."); | ||||
|         builder.AppendLine("# TYPE linknotmerge_bench_throughput_per_sec gauge"); | ||||
|         builder.AppendLine("# HELP linknotmerge_bench_mongo_throughput_per_sec Link-Not-Merge benchmark Mongo throughput metrics (operations per second)."); | ||||
|         builder.AppendLine("# TYPE linknotmerge_bench_mongo_throughput_per_sec gauge"); | ||||
|         builder.AppendLine("# HELP linknotmerge_bench_allocated_mb Link-Not-Merge benchmark allocation metrics (megabytes)."); | ||||
|         builder.AppendLine("# TYPE linknotmerge_bench_allocated_mb gauge"); | ||||
|  | ||||
|         foreach (var report in reports) | ||||
|         { | ||||
|             var scenario = Escape(report.Result.Id); | ||||
|             AppendMetric(builder, "linknotmerge_bench_mean_total_ms", scenario, report.Result.TotalStatistics.MeanMs); | ||||
|             AppendMetric(builder, "linknotmerge_bench_p95_total_ms", scenario, report.Result.TotalStatistics.P95Ms); | ||||
|             AppendMetric(builder, "linknotmerge_bench_max_total_ms", scenario, report.Result.TotalStatistics.MaxMs); | ||||
|             AppendMetric(builder, "linknotmerge_bench_threshold_ms", scenario, report.Result.ThresholdMs); | ||||
|  | ||||
|             AppendMetric(builder, "linknotmerge_bench_mean_correlation_ms", scenario, report.Result.CorrelationStatistics.MeanMs); | ||||
|             AppendMetric(builder, "linknotmerge_bench_mean_insert_ms", scenario, report.Result.InsertStatistics.MeanMs); | ||||
|  | ||||
|             AppendMetric(builder, "linknotmerge_bench_mean_throughput_per_sec", scenario, report.Result.TotalThroughputStatistics.MeanPerSecond); | ||||
|             AppendMetric(builder, "linknotmerge_bench_min_throughput_per_sec", scenario, report.Result.TotalThroughputStatistics.MinPerSecond); | ||||
|             AppendMetric(builder, "linknotmerge_bench_throughput_floor_per_sec", scenario, report.Result.MinThroughputThresholdPerSecond); | ||||
|  | ||||
|             AppendMetric(builder, "linknotmerge_bench_mean_mongo_throughput_per_sec", scenario, report.Result.InsertThroughputStatistics.MeanPerSecond); | ||||
|             AppendMetric(builder, "linknotmerge_bench_min_mongo_throughput_per_sec", scenario, report.Result.InsertThroughputStatistics.MinPerSecond); | ||||
|             AppendMetric(builder, "linknotmerge_bench_mongo_throughput_floor_per_sec", scenario, report.Result.MinMongoThroughputThresholdPerSecond); | ||||
|  | ||||
|             AppendMetric(builder, "linknotmerge_bench_max_allocated_mb", scenario, report.Result.AllocationStatistics.MaxAllocatedMb); | ||||
|             AppendMetric(builder, "linknotmerge_bench_max_allocated_threshold_mb", scenario, report.Result.MaxAllocatedThresholdMb); | ||||
|  | ||||
|             if (report.Baseline is { } baseline) | ||||
|             { | ||||
|                 AppendMetric(builder, "linknotmerge_bench_baseline_max_total_ms", scenario, baseline.MaxTotalMs); | ||||
|                 AppendMetric(builder, "linknotmerge_bench_baseline_min_throughput_per_sec", scenario, baseline.MinThroughputPerSecond); | ||||
|                 AppendMetric(builder, "linknotmerge_bench_baseline_min_mongo_throughput_per_sec", scenario, baseline.MinMongoThroughputPerSecond); | ||||
|             } | ||||
|  | ||||
|             if (report.DurationRegressionRatio is { } durationRatio) | ||||
|             { | ||||
|                 AppendMetric(builder, "linknotmerge_bench_duration_regression_ratio", scenario, durationRatio); | ||||
|             } | ||||
|  | ||||
|             if (report.ThroughputRegressionRatio is { } throughputRatio) | ||||
|             { | ||||
|                 AppendMetric(builder, "linknotmerge_bench_throughput_regression_ratio", scenario, throughputRatio); | ||||
|             } | ||||
|  | ||||
|             if (report.MongoThroughputRegressionRatio is { } mongoRatio) | ||||
|             { | ||||
|                 AppendMetric(builder, "linknotmerge_bench_mongo_throughput_regression_ratio", scenario, mongoRatio); | ||||
|             } | ||||
|  | ||||
|             AppendMetric(builder, "linknotmerge_bench_regression_limit", scenario, report.RegressionLimit); | ||||
|             AppendMetric(builder, "linknotmerge_bench_regression_breached", scenario, report.RegressionBreached ? 1 : 0); | ||||
|         } | ||||
|  | ||||
|         File.WriteAllText(resolved, builder.ToString(), Encoding.UTF8); | ||||
|     } | ||||
|  | ||||
|     private static void AppendMetric(StringBuilder builder, string metric, string scenario, double? value) | ||||
|     { | ||||
|         if (!value.HasValue) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         builder.Append(metric); | ||||
|         builder.Append("{scenario=\""); | ||||
|         builder.Append(scenario); | ||||
|         builder.Append("\"} "); | ||||
|         builder.AppendLine(value.Value.ToString("G17", CultureInfo.InvariantCulture)); | ||||
|     } | ||||
|  | ||||
|     private static string Escape(string value) => | ||||
|         value.Replace("\\", "\\\\", StringComparison.Ordinal).Replace("\"", "\\\"", StringComparison.Ordinal); | ||||
| } | ||||
| @@ -0,0 +1,14 @@ | ||||
| namespace StellaOps.Bench.LinkNotMerge; | ||||
|  | ||||
| internal sealed record ScenarioExecutionResult( | ||||
|     IReadOnlyList<double> TotalDurationsMs, | ||||
|     IReadOnlyList<double> InsertDurationsMs, | ||||
|     IReadOnlyList<double> CorrelationDurationsMs, | ||||
|     IReadOnlyList<double> AllocatedMb, | ||||
|     IReadOnlyList<double> TotalThroughputsPerSecond, | ||||
|     IReadOnlyList<double> InsertThroughputsPerSecond, | ||||
|     int ObservationCount, | ||||
|     int AliasGroups, | ||||
|     int LinksetCount, | ||||
|     int TenantCount, | ||||
|     LinksetAggregationResult AggregationResult); | ||||
| @@ -0,0 +1,42 @@ | ||||
| using System.Globalization; | ||||
|  | ||||
| namespace StellaOps.Bench.LinkNotMerge; | ||||
|  | ||||
| internal sealed record ScenarioResult( | ||||
|     string Id, | ||||
|     string Label, | ||||
|     int Iterations, | ||||
|     int ObservationCount, | ||||
|     int AliasGroups, | ||||
|     int LinksetCount, | ||||
|     DurationStatistics TotalStatistics, | ||||
|     DurationStatistics InsertStatistics, | ||||
|     DurationStatistics CorrelationStatistics, | ||||
|     ThroughputStatistics TotalThroughputStatistics, | ||||
|     ThroughputStatistics InsertThroughputStatistics, | ||||
|     AllocationStatistics AllocationStatistics, | ||||
|     double? ThresholdMs, | ||||
|     double? MinThroughputThresholdPerSecond, | ||||
|     double? MinMongoThroughputThresholdPerSecond, | ||||
|     double? MaxAllocatedThresholdMb) | ||||
| { | ||||
|     public string IdColumn => Id.Length <= 28 ? Id.PadRight(28) : Id[..28]; | ||||
|  | ||||
|     public string ObservationsColumn => ObservationCount.ToString("N0", CultureInfo.InvariantCulture).PadLeft(12); | ||||
|  | ||||
|     public string AliasColumn => AliasGroups.ToString("N0", CultureInfo.InvariantCulture).PadLeft(8); | ||||
|  | ||||
|     public string LinksetColumn => LinksetCount.ToString("N0", CultureInfo.InvariantCulture).PadLeft(9); | ||||
|  | ||||
|     public string TotalMeanColumn => TotalStatistics.MeanMs.ToString("F2", CultureInfo.InvariantCulture).PadLeft(10); | ||||
|  | ||||
|     public string CorrelationMeanColumn => CorrelationStatistics.MeanMs.ToString("F2", CultureInfo.InvariantCulture).PadLeft(10); | ||||
|  | ||||
|     public string InsertMeanColumn => InsertStatistics.MeanMs.ToString("F2", CultureInfo.InvariantCulture).PadLeft(10); | ||||
|  | ||||
|     public string ThroughputColumn => (TotalThroughputStatistics.MinPerSecond / 1_000d).ToString("F2", CultureInfo.InvariantCulture).PadLeft(11); | ||||
|  | ||||
|     public string MongoThroughputColumn => (InsertThroughputStatistics.MinPerSecond / 1_000d).ToString("F2", CultureInfo.InvariantCulture).PadLeft(11); | ||||
|  | ||||
|     public string AllocatedColumn => AllocationStatistics.MaxAllocatedMb.ToString("F2", CultureInfo.InvariantCulture).PadLeft(9); | ||||
| } | ||||
| @@ -0,0 +1,84 @@ | ||||
| namespace StellaOps.Bench.LinkNotMerge; | ||||
|  | ||||
| internal readonly record struct DurationStatistics(double MeanMs, double P95Ms, double MaxMs) | ||||
| { | ||||
|     public static DurationStatistics From(IReadOnlyList<double> values) | ||||
|     { | ||||
|         if (values.Count == 0) | ||||
|         { | ||||
|             return new DurationStatistics(0, 0, 0); | ||||
|         } | ||||
|  | ||||
|         var sorted = values.ToArray(); | ||||
|         Array.Sort(sorted); | ||||
|  | ||||
|         var total = 0d; | ||||
|         foreach (var value in values) | ||||
|         { | ||||
|             total += value; | ||||
|         } | ||||
|  | ||||
|         var mean = total / values.Count; | ||||
|         var p95 = Percentile(sorted, 95); | ||||
|         var max = sorted[^1]; | ||||
|  | ||||
|         return new DurationStatistics(mean, p95, max); | ||||
|     } | ||||
|  | ||||
|     private static double Percentile(IReadOnlyList<double> sorted, double percentile) | ||||
|     { | ||||
|         if (sorted.Count == 0) | ||||
|         { | ||||
|             return 0; | ||||
|         } | ||||
|  | ||||
|         var rank = (percentile / 100d) * (sorted.Count - 1); | ||||
|         var lower = (int)Math.Floor(rank); | ||||
|         var upper = (int)Math.Ceiling(rank); | ||||
|         var weight = rank - lower; | ||||
|  | ||||
|         if (upper >= sorted.Count) | ||||
|         { | ||||
|             return sorted[lower]; | ||||
|         } | ||||
|  | ||||
|         return sorted[lower] + weight * (sorted[upper] - sorted[lower]); | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal readonly record struct ThroughputStatistics(double MeanPerSecond, double MinPerSecond) | ||||
| { | ||||
|     public static ThroughputStatistics From(IReadOnlyList<double> values) | ||||
|     { | ||||
|         if (values.Count == 0) | ||||
|         { | ||||
|             return new ThroughputStatistics(0, 0); | ||||
|         } | ||||
|  | ||||
|         var total = 0d; | ||||
|         var min = double.MaxValue; | ||||
|  | ||||
|         foreach (var value in values) | ||||
|         { | ||||
|             total += value; | ||||
|             min = Math.Min(min, value); | ||||
|         } | ||||
|  | ||||
|         var mean = total / values.Count; | ||||
|         return new ThroughputStatistics(mean, min); | ||||
|     } | ||||
| } | ||||
|  | ||||
| internal readonly record struct AllocationStatistics(double MaxAllocatedMb) | ||||
| { | ||||
|     public static AllocationStatistics From(IReadOnlyList<double> values) | ||||
|     { | ||||
|         var max = 0d; | ||||
|         foreach (var value in values) | ||||
|         { | ||||
|             max = Math.Max(max, value); | ||||
|         } | ||||
|  | ||||
|         return new AllocationStatistics(max); | ||||
|     } | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user