Add Policy DSL Validator, Schema Exporter, and Simulation Smoke tools
- 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